without-web 0.0.0__tar.gz → 0.0.2__tar.gz
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.
- without_web-0.0.2/PKG-INFO +65 -0
- without_web-0.0.2/README.md +42 -0
- without_web-0.0.2/pyproject.toml +38 -0
- without_web-0.0.2/pyproject.toml.orig +35 -0
- without_web-0.0.2/src/without_web/__init__.py +159 -0
- without_web-0.0.2/src/without_web/converters.py +57 -0
- without_web-0.0.2/src/without_web/exceptions.py +110 -0
- without_web-0.0.2/src/without_web/extractors.py +548 -0
- without_web-0.0.2/src/without_web/handlers.py +1152 -0
- without_web-0.0.2/src/without_web/openapi.py +264 -0
- without_web-0.0.2/src/without_web/patterns.py +61 -0
- without_web-0.0.2/src/without_web/responses.py +43 -0
- without_web-0.0.2/src/without_web/router.py +645 -0
- without_web-0.0.2/src/without_web/trie.py +131 -0
- without_web-0.0.0/PKG-INFO +0 -5
- without_web-0.0.0/pyproject.toml +0 -9
- /without_web-0.0.0/src/without_web/__init__.py → /without_web-0.0.2/src/without_web/py.typed +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: without-web
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: An opinionated HTTP/WebSocket router for without-asgi: trie matching, typed path params, 405-vs-404, mounting, and OpenAPI.
|
|
5
|
+
Author: Josh Karpel
|
|
6
|
+
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: without-core==0.0.2
|
|
20
|
+
Requires-Dist: without-asgi==0.0.2
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# without-web
|
|
25
|
+
|
|
26
|
+
An opinionated HTTP and WebSocket router for
|
|
27
|
+
[`without-asgi`](../without-asgi). `without-asgi` deliberately ships *no* router,
|
|
28
|
+
only the unopinionated boundary and composition tools. `without-web` is the
|
|
29
|
+
opinionated layer on top: t-string patterns with typed parameters, typed request
|
|
30
|
+
extraction, 405-vs-404, mounting, scoped middleware, exception handlers, reverse
|
|
31
|
+
routing (`url_for`), and OpenAPI.
|
|
32
|
+
|
|
33
|
+
It snaps onto the boundary through nothing but the existing `HttpRouter` type:
|
|
34
|
+
`Router.dispatch` *is* an `HttpRouter[T]`, so `make_asgi_app(http=router.dispatch)`
|
|
35
|
+
just works, and bring-your-own (or no router at all) stays first-class.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
from without_asgi import Response, make_asgi_app
|
|
41
|
+
from without_web import INT, Router, get, path_param
|
|
42
|
+
|
|
43
|
+
uid = path_param("id", INT) # one token: a pattern segment AND a typed read
|
|
44
|
+
|
|
45
|
+
@get(t"/users/{uid}", uid) # t-string pattern; `@get` returns a Route value
|
|
46
|
+
async def show_user(state, user_id: int): # user_id is an int, no `assert isinstance`
|
|
47
|
+
body = json.dumps({"id": user_id}).encode()
|
|
48
|
+
return Response(status=200, headers=((b"content-type", b"application/json"),), body=body)
|
|
49
|
+
|
|
50
|
+
router = Router(routes=(show_user,), fallback=not_found)
|
|
51
|
+
app = make_asgi_app(lifespan, http=router.dispatch)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Matching is a pure walk of an immutable trie, so precedence, 405-vs-404, and
|
|
55
|
+
mounting fall out of the structure. Converters and extractors *parse, they don't
|
|
56
|
+
validate*: a path param arrives already typed, and OpenAPI is a *merge* of the
|
|
57
|
+
self-descriptions each layer recovers from structure rather than a blob declared
|
|
58
|
+
by hand. Encoding stays the app's choice, so the router ships no
|
|
59
|
+
`json_response`-style helper.
|
|
60
|
+
|
|
61
|
+
See the
|
|
62
|
+
[`without-web` guide](https://without.help/without-web/)
|
|
63
|
+
(with the [API reference](https://without.help/reference/without_web/))
|
|
64
|
+
for the full surface: patterns, extractors, streaming input, mounting,
|
|
65
|
+
middleware, exception handlers, reverse routing, OpenAPI, and WebSocket routing.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# without-web
|
|
2
|
+
|
|
3
|
+
An opinionated HTTP and WebSocket router for
|
|
4
|
+
[`without-asgi`](../without-asgi). `without-asgi` deliberately ships *no* router,
|
|
5
|
+
only the unopinionated boundary and composition tools. `without-web` is the
|
|
6
|
+
opinionated layer on top: t-string patterns with typed parameters, typed request
|
|
7
|
+
extraction, 405-vs-404, mounting, scoped middleware, exception handlers, reverse
|
|
8
|
+
routing (`url_for`), and OpenAPI.
|
|
9
|
+
|
|
10
|
+
It snaps onto the boundary through nothing but the existing `HttpRouter` type:
|
|
11
|
+
`Router.dispatch` *is* an `HttpRouter[T]`, so `make_asgi_app(http=router.dispatch)`
|
|
12
|
+
just works, and bring-your-own (or no router at all) stays first-class.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from without_asgi import Response, make_asgi_app
|
|
18
|
+
from without_web import INT, Router, get, path_param
|
|
19
|
+
|
|
20
|
+
uid = path_param("id", INT) # one token: a pattern segment AND a typed read
|
|
21
|
+
|
|
22
|
+
@get(t"/users/{uid}", uid) # t-string pattern; `@get` returns a Route value
|
|
23
|
+
async def show_user(state, user_id: int): # user_id is an int, no `assert isinstance`
|
|
24
|
+
body = json.dumps({"id": user_id}).encode()
|
|
25
|
+
return Response(status=200, headers=((b"content-type", b"application/json"),), body=body)
|
|
26
|
+
|
|
27
|
+
router = Router(routes=(show_user,), fallback=not_found)
|
|
28
|
+
app = make_asgi_app(lifespan, http=router.dispatch)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Matching is a pure walk of an immutable trie, so precedence, 405-vs-404, and
|
|
32
|
+
mounting fall out of the structure. Converters and extractors *parse, they don't
|
|
33
|
+
validate*: a path param arrives already typed, and OpenAPI is a *merge* of the
|
|
34
|
+
self-descriptions each layer recovers from structure rather than a blob declared
|
|
35
|
+
by hand. Encoding stays the app's choice, so the router ships no
|
|
36
|
+
`json_response`-style helper.
|
|
37
|
+
|
|
38
|
+
See the
|
|
39
|
+
[`without-web` guide](https://without.help/without-web/)
|
|
40
|
+
(with the [API reference](https://without.help/reference/without_web/))
|
|
41
|
+
for the full surface: patterns, extractors, streaming input, mounting,
|
|
42
|
+
middleware, exception handlers, reverse routing, OpenAPI, and WebSocket routing.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.32,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-web"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "An opinionated HTTP/WebSocket router for without-asgi: trie matching, typed path params, 405-vs-404, mounting, and OpenAPI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"without-core==0.0.2",
|
|
27
|
+
"without-asgi==0.0.2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Josh Karpel"
|
|
32
|
+
email = "josh.karpel@gmail.com"
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources.without-core]
|
|
35
|
+
workspace = true
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources.without-asgi]
|
|
38
|
+
workspace = true
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.32,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-web"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "An opinionated HTTP/WebSocket router for without-asgi: trie matching, typed path params, 405-vs-404, mounting, and OpenAPI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Josh Karpel", email = "josh.karpel@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.14"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"without-core==0.0.2",
|
|
30
|
+
"without-asgi==0.0.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
without-core = { workspace = true }
|
|
35
|
+
without-asgi = { workspace = true }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from without_web.converters import FLOAT
|
|
2
|
+
from without_web.converters import INT
|
|
3
|
+
from without_web.converters import PATH
|
|
4
|
+
from without_web.converters import STR
|
|
5
|
+
from without_web.converters import UUID
|
|
6
|
+
from without_web.converters import Converter
|
|
7
|
+
from without_web.exceptions import ExceptionRecover
|
|
8
|
+
from without_web.exceptions import WebsocketExceptionRecover
|
|
9
|
+
from without_web.exceptions import catching
|
|
10
|
+
from without_web.exceptions import catching_websocket
|
|
11
|
+
from without_web.extractors import BufferedRequest
|
|
12
|
+
from without_web.extractors import ExtractionError
|
|
13
|
+
from without_web.extractors import Extractor
|
|
14
|
+
from without_web.extractors import HttpRequestHead
|
|
15
|
+
from without_web.extractors import RequestHead
|
|
16
|
+
from without_web.extractors import WebsocketRequestHead
|
|
17
|
+
from without_web.extractors import body
|
|
18
|
+
from without_web.extractors import catch_all
|
|
19
|
+
from without_web.extractors import header_param
|
|
20
|
+
from without_web.extractors import http_scope
|
|
21
|
+
from without_web.extractors import into
|
|
22
|
+
from without_web.extractors import once
|
|
23
|
+
from without_web.extractors import optional
|
|
24
|
+
from without_web.extractors import path_param
|
|
25
|
+
from without_web.extractors import query_param
|
|
26
|
+
from without_web.extractors import websocket_scope
|
|
27
|
+
from without_web.handlers import Reply
|
|
28
|
+
from without_web.handlers import Returned
|
|
29
|
+
from without_web.handlers import WebsocketReturned
|
|
30
|
+
from without_web.handlers import delete
|
|
31
|
+
from without_web.handlers import get
|
|
32
|
+
from without_web.handlers import handle
|
|
33
|
+
from without_web.handlers import handle_stream
|
|
34
|
+
from without_web.handlers import head
|
|
35
|
+
from without_web.handlers import options
|
|
36
|
+
from without_web.handlers import patch
|
|
37
|
+
from without_web.handlers import post
|
|
38
|
+
from without_web.handlers import put
|
|
39
|
+
from without_web.handlers import ws
|
|
40
|
+
from without_web.openapi import Body
|
|
41
|
+
from without_web.openapi import Describable
|
|
42
|
+
from without_web.openapi import HeaderParam
|
|
43
|
+
from without_web.openapi import QueryParam
|
|
44
|
+
from without_web.openapi import ResponseSpec
|
|
45
|
+
from without_web.openapi import RouteSpec
|
|
46
|
+
from without_web.openapi import SchemaFor
|
|
47
|
+
from without_web.openapi import SchemaRef
|
|
48
|
+
from without_web.openapi import Sequence
|
|
49
|
+
from without_web.openapi import Single
|
|
50
|
+
from without_web.openapi import describe
|
|
51
|
+
from without_web.openapi import openapi
|
|
52
|
+
from without_web.patterns import CatchAll
|
|
53
|
+
from without_web.patterns import Literal
|
|
54
|
+
from without_web.patterns import Param
|
|
55
|
+
from without_web.patterns import PathSpec
|
|
56
|
+
from without_web.patterns import Segment
|
|
57
|
+
from without_web.patterns import split_path
|
|
58
|
+
from without_web.responses import buffered
|
|
59
|
+
from without_web.router import Delegate
|
|
60
|
+
from without_web.router import Endpoint
|
|
61
|
+
from without_web.router import HttpEndpoint
|
|
62
|
+
from without_web.router import Match
|
|
63
|
+
from without_web.router import Pattern
|
|
64
|
+
from without_web.router import Reversible
|
|
65
|
+
from without_web.router import Route
|
|
66
|
+
from without_web.router import Router
|
|
67
|
+
from without_web.router import WebsocketDelegate
|
|
68
|
+
from without_web.router import WebsocketEndpoint
|
|
69
|
+
from without_web.router import WebsocketRoute
|
|
70
|
+
from without_web.router import WebsocketRouter
|
|
71
|
+
from without_web.router import delegate
|
|
72
|
+
from without_web.router import mount
|
|
73
|
+
from without_web.router import route
|
|
74
|
+
from without_web.router import url_for
|
|
75
|
+
from without_web.router import with_middleware
|
|
76
|
+
from without_web.router import ws_delegate
|
|
77
|
+
from without_web.router import ws_mount
|
|
78
|
+
from without_web.router import ws_route
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"FLOAT",
|
|
82
|
+
"INT",
|
|
83
|
+
"PATH",
|
|
84
|
+
"STR",
|
|
85
|
+
"UUID",
|
|
86
|
+
"Body",
|
|
87
|
+
"BufferedRequest",
|
|
88
|
+
"CatchAll",
|
|
89
|
+
"Converter",
|
|
90
|
+
"Delegate",
|
|
91
|
+
"Describable",
|
|
92
|
+
"Endpoint",
|
|
93
|
+
"ExceptionRecover",
|
|
94
|
+
"ExtractionError",
|
|
95
|
+
"Extractor",
|
|
96
|
+
"HeaderParam",
|
|
97
|
+
"HttpEndpoint",
|
|
98
|
+
"HttpRequestHead",
|
|
99
|
+
"Literal",
|
|
100
|
+
"Match",
|
|
101
|
+
"Param",
|
|
102
|
+
"PathSpec",
|
|
103
|
+
"Pattern",
|
|
104
|
+
"QueryParam",
|
|
105
|
+
"Reply",
|
|
106
|
+
"RequestHead",
|
|
107
|
+
"ResponseSpec",
|
|
108
|
+
"Returned",
|
|
109
|
+
"Reversible",
|
|
110
|
+
"Route",
|
|
111
|
+
"RouteSpec",
|
|
112
|
+
"Router",
|
|
113
|
+
"SchemaFor",
|
|
114
|
+
"SchemaRef",
|
|
115
|
+
"Segment",
|
|
116
|
+
"Sequence",
|
|
117
|
+
"Single",
|
|
118
|
+
"WebsocketDelegate",
|
|
119
|
+
"WebsocketEndpoint",
|
|
120
|
+
"WebsocketExceptionRecover",
|
|
121
|
+
"WebsocketRequestHead",
|
|
122
|
+
"WebsocketReturned",
|
|
123
|
+
"WebsocketRoute",
|
|
124
|
+
"WebsocketRouter",
|
|
125
|
+
"body",
|
|
126
|
+
"buffered",
|
|
127
|
+
"catch_all",
|
|
128
|
+
"catching",
|
|
129
|
+
"catching_websocket",
|
|
130
|
+
"delegate",
|
|
131
|
+
"delete",
|
|
132
|
+
"describe",
|
|
133
|
+
"get",
|
|
134
|
+
"handle",
|
|
135
|
+
"handle_stream",
|
|
136
|
+
"head",
|
|
137
|
+
"header_param",
|
|
138
|
+
"http_scope",
|
|
139
|
+
"into",
|
|
140
|
+
"mount",
|
|
141
|
+
"once",
|
|
142
|
+
"openapi",
|
|
143
|
+
"optional",
|
|
144
|
+
"options",
|
|
145
|
+
"patch",
|
|
146
|
+
"path_param",
|
|
147
|
+
"post",
|
|
148
|
+
"put",
|
|
149
|
+
"query_param",
|
|
150
|
+
"route",
|
|
151
|
+
"split_path",
|
|
152
|
+
"url_for",
|
|
153
|
+
"websocket_scope",
|
|
154
|
+
"with_middleware",
|
|
155
|
+
"ws",
|
|
156
|
+
"ws_delegate",
|
|
157
|
+
"ws_mount",
|
|
158
|
+
"ws_route",
|
|
159
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import field
|
|
8
|
+
from typing import Generic
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
# Covariant: `V` appears only in `parse`'s return, so `Converter[int]` is a
|
|
12
|
+
# `Converter[object]`. That lets a path-param token store its converter as
|
|
13
|
+
# `Converter[object]` while `path_param` keeps the precise `V` for the handler.
|
|
14
|
+
# The legacy `TypeVar` is needed because PEP 695's inferred variance treats a
|
|
15
|
+
# (frozen) dataclass field as invariant; the variance is sound here.
|
|
16
|
+
_V_co = TypeVar("_V_co", covariant=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Converter(Generic[_V_co]): # noqa: UP046 - PEP 695 infers a frozen dataclass field as invariant; the covariant TypeVar is deliberate (see above)
|
|
21
|
+
"""
|
|
22
|
+
A path-segment parser paired with the JSON Schema it parses into.
|
|
23
|
+
|
|
24
|
+
`parse` turns a single matched segment into a typed value, raising
|
|
25
|
+
`ValueError` to *reject* a segment that does not fit (`int` against `"abc"`).
|
|
26
|
+
Rejection is not a handler-side error: it makes that trie branch fail to
|
|
27
|
+
match so the walk backtracks to a sibling, ultimately a 404 if nothing
|
|
28
|
+
matches (parse, don't validate).
|
|
29
|
+
|
|
30
|
+
`schema` is the half the router contributes to OpenAPI for a path parameter
|
|
31
|
+
that uses this converter: the router owns the path-param schema because it
|
|
32
|
+
owns the converter.
|
|
33
|
+
|
|
34
|
+
`name` is the converter's identity (and its OpenAPI parameter style); a
|
|
35
|
+
typed-token pattern reuses the converter *value* directly
|
|
36
|
+
(`path_param("id", INT)`), so the name, parse, type, and schema are all
|
|
37
|
+
declared in one place. Equality and hashing are by `name` alone (a converter
|
|
38
|
+
is a trie key), so `parse` and `schema` are excluded from comparison.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
parse: Callable[[str], _V_co] = field(compare=False)
|
|
43
|
+
schema: Mapping[str, object] = field(compare=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
STR: Converter[str] = Converter(name="str", parse=str, schema={"type": "string"})
|
|
47
|
+
INT: Converter[int] = Converter(name="int", parse=int, schema={"type": "integer"})
|
|
48
|
+
FLOAT: Converter[float] = Converter(name="float", parse=float, schema={"type": "number"})
|
|
49
|
+
UUID: Converter[uuid.UUID] = Converter(name="uuid", parse=uuid.UUID, schema={"type": "string", "format": "uuid"})
|
|
50
|
+
# `path` is the catch-all converter: the trie consumes the rest of the request
|
|
51
|
+
# target into one segment, so its `parse` is the identity on the joined string.
|
|
52
|
+
# Security note: the value is the raw remaining path, *not* normalized. It can
|
|
53
|
+
# contain `..` and encoded separators, so an app that joins it onto a filesystem
|
|
54
|
+
# path, a proxy target, or a redirect URL MUST normalize and confine it first
|
|
55
|
+
# (e.g. resolve and check it stays within a base directory); the router itself
|
|
56
|
+
# never touches the filesystem, so it cannot do this for you.
|
|
57
|
+
PATH: Converter[str] = Converter(name="path", parse=str, schema={"type": "string"})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from collections.abc import Awaitable
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from without import Processor
|
|
8
|
+
from without import Stream
|
|
9
|
+
from without_asgi import HttpHandler
|
|
10
|
+
from without_asgi import HttpScope
|
|
11
|
+
from without_asgi import Outbound
|
|
12
|
+
from without_asgi import Response
|
|
13
|
+
from without_asgi import ResponseStart
|
|
14
|
+
from without_asgi import WebsocketAccept
|
|
15
|
+
from without_asgi import WebsocketClose
|
|
16
|
+
from without_asgi import WebsocketHandler
|
|
17
|
+
from without_asgi import WebsocketOutbound
|
|
18
|
+
from without_asgi import WebsocketScope
|
|
19
|
+
from without_asgi import encode_response
|
|
20
|
+
from without_asgi.routing import HttpMiddleware
|
|
21
|
+
from without_asgi.routing import WebsocketMiddleware
|
|
22
|
+
|
|
23
|
+
# An app's exception policy: given a raised exception, produce the reply to send
|
|
24
|
+
# instead, or `None` to let it propagate. Writing this as `match exc:` (or
|
|
25
|
+
# `try/except`) gives natural narrowing and full control, so there is no registry
|
|
26
|
+
# of types to handlers and no per-type erasure: the policy is just a function.
|
|
27
|
+
type ExceptionRecover = Callable[[Exception], Awaitable[Response | None]]
|
|
28
|
+
type WebsocketExceptionRecover = Callable[[Exception], Awaitable[WebsocketClose | None]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def catching(recover: ExceptionRecover) -> HttpMiddleware[object]:
|
|
32
|
+
"""
|
|
33
|
+
Build middleware that maps exceptions to a response, before the status commits.
|
|
34
|
+
|
|
35
|
+
Exception handling is not a new mechanism: it is a `Middleware` that wraps a
|
|
36
|
+
handler and watches its outbound stream. `recover` is the app's policy: it is
|
|
37
|
+
handed a raised exception and returns the `Response` to send instead, or
|
|
38
|
+
`None` to let the exception propagate. There is deliberately no registry of
|
|
39
|
+
`type -> handler`: a `recover` written as `match exc:` narrows each case to
|
|
40
|
+
its real type (no `assert isinstance`) and can re-raise, chain, or do async
|
|
41
|
+
work, all of which a heterogeneous mapping could not express without a cast.
|
|
42
|
+
|
|
43
|
+
Honest limitation: the mapping applies only while the status line can still
|
|
44
|
+
be set, i.e. until the first `ResponseStart` flows out. Informational events
|
|
45
|
+
(`EarlyHint`, `ResponseDebug`) precede it and do not commit the status, so an
|
|
46
|
+
exception after them can still be mapped; once `ResponseStart` is on the
|
|
47
|
+
wire the exception re-raises, because the handler can abort but not re-status.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def middleware(handler: HttpHandler, _state: object, scope: HttpScope) -> HttpHandler:
|
|
51
|
+
async def recovering(exc: Exception) -> tuple[Outbound, ...] | None:
|
|
52
|
+
response = await recover(exc)
|
|
53
|
+
return None if response is None else encode_response(response)
|
|
54
|
+
|
|
55
|
+
return _guarding(handler, _is_response_start, recovering)
|
|
56
|
+
|
|
57
|
+
return middleware
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def catching_websocket(recover: WebsocketExceptionRecover) -> WebsocketMiddleware[object]:
|
|
61
|
+
"""
|
|
62
|
+
The WebSocket sibling of `catching`, mapping exceptions to a close.
|
|
63
|
+
|
|
64
|
+
The equivalent commit point is `WebsocketAccept`: before the handshake is
|
|
65
|
+
accepted a close still rejects the connection (the server turns it into a
|
|
66
|
+
`403`), so a mapped exception becomes that `WebsocketClose`. Once accepted
|
|
67
|
+
the connection is established and the exception re-raises. `recover` returns
|
|
68
|
+
the `WebsocketClose` to send, or `None` to propagate.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def middleware(handler: WebsocketHandler, _state: object, scope: WebsocketScope) -> WebsocketHandler:
|
|
72
|
+
async def recovering(exc: Exception) -> tuple[WebsocketOutbound, ...] | None:
|
|
73
|
+
close = await recover(exc)
|
|
74
|
+
return None if close is None else (close,)
|
|
75
|
+
|
|
76
|
+
return _guarding(handler, _is_websocket_accept, recovering)
|
|
77
|
+
|
|
78
|
+
return middleware
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _guarding[In, Out](
|
|
82
|
+
handler: Processor[In, Out],
|
|
83
|
+
commits: Callable[[Out], bool],
|
|
84
|
+
recover: Callable[[Exception], Awaitable[tuple[Out, ...] | None]],
|
|
85
|
+
) -> Processor[In, Out]:
|
|
86
|
+
# The shared core: relay the handler's outbound stream, remembering whether
|
|
87
|
+
# the protocol's commit event has gone out. An exception before the commit is
|
|
88
|
+
# offered to `recover`; once committed (or unrecognized) it propagates.
|
|
89
|
+
async def processor(inputs: Stream[In]) -> AsyncIterator[Out]:
|
|
90
|
+
committed = False # pragma: no mutate - only read as a bool, None is falsy too
|
|
91
|
+
try:
|
|
92
|
+
async for event in handler(inputs):
|
|
93
|
+
committed = committed or commits(event)
|
|
94
|
+
yield event
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
replacement = None if committed else await recover(exc)
|
|
97
|
+
if replacement is None:
|
|
98
|
+
raise
|
|
99
|
+
for event in replacement:
|
|
100
|
+
yield event
|
|
101
|
+
|
|
102
|
+
return processor
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_response_start(event: Outbound) -> bool:
|
|
106
|
+
return isinstance(event, ResponseStart)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_websocket_accept(event: WebsocketOutbound) -> bool:
|
|
110
|
+
return isinstance(event, WebsocketAccept)
|