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.
@@ -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)