tun 0.0.1__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.
- tun-0.0.1/LICENSE +21 -0
- tun-0.0.1/PKG-INFO +73 -0
- tun-0.0.1/README.md +62 -0
- tun-0.0.1/pyproject.toml +16 -0
- tun-0.0.1/src/tun/__init__.py +175 -0
- tun-0.0.1/src/tun/py.typed +0 -0
tun-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tun contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tun-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tun
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A lightweight ASGI web framework for Python.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: uvicorn>=0.35.0 ; extra == 'server'
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Provides-Extra: server
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Tun
|
|
13
|
+
|
|
14
|
+
A lightweight ASGI web framework for Python.
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> Tun is in early development and is not yet ready for production use.
|
|
18
|
+
|
|
19
|
+
Requires Python 3.14 or newer.
|
|
20
|
+
|
|
21
|
+
## Run the example
|
|
22
|
+
|
|
23
|
+
From this repository:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv run --extra server examples/hello.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open <http://127.0.0.1:8000/> to see `Hello`.
|
|
30
|
+
|
|
31
|
+
Alternatively, run the same mux directly with Uvicorn:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv run --extra server uvicorn examples.hello:mux --host 127.0.0.1 --port 8000
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from tun import Request, ResponseWriter, ServeMux, listen_and_serve
|
|
41
|
+
|
|
42
|
+
mux = ServeMux()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def hello(w: ResponseWriter, r: Request) -> None:
|
|
46
|
+
w.set_header("content-type", "text/plain")
|
|
47
|
+
await w.write("Hello")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
mux.handle_func("/", hello, method="GET")
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
listen_and_serve("127.0.0.1:8000", mux)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Routes match exact paths and methods; `GET` is the default registration method.
|
|
57
|
+
There are no path parameters, automatic `HEAD` routes, or automatic `OPTIONS` responses.
|
|
58
|
+
- Requests expose `method`, `path`, raw `query_string` bytes, and lowercase
|
|
59
|
+
`headers`. Repeated header names retain the last value; original headers remain
|
|
60
|
+
available in `r.scope["headers"]`. Use `await r.body()` to read the entire body
|
|
61
|
+
into memory; there is currently no body-size limit.
|
|
62
|
+
- `set_header()` replaces a response header. `await w.write_header(201)` sends
|
|
63
|
+
an explicit status; otherwise the first write uses 200. Headers cannot change
|
|
64
|
+
once sent. Strings are encoded as UTF-8; content types must be set explicitly.
|
|
65
|
+
- Each `write()` sends a response chunk. Tun closes the response when the handler
|
|
66
|
+
returns, including handlers that write nothing (an empty 200 response).
|
|
67
|
+
- Missing paths return 404; unmatched methods return 405 with an `Allow` header.
|
|
68
|
+
- Handler exceptions propagate to the ASGI server. Once a response has started,
|
|
69
|
+
an error cannot replace it with a 500 response.
|
|
70
|
+
|
|
71
|
+
The core has no runtime dependencies. The optional `server` extra provides Uvicorn
|
|
72
|
+
for the blocking `listen_and_serve()` helper. Alternatively, use any HTTP ASGI
|
|
73
|
+
server with the mux directly. WebSockets are not supported yet.
|
tun-0.0.1/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Tun
|
|
2
|
+
|
|
3
|
+
A lightweight ASGI web framework for Python.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> Tun is in early development and is not yet ready for production use.
|
|
7
|
+
|
|
8
|
+
Requires Python 3.14 or newer.
|
|
9
|
+
|
|
10
|
+
## Run the example
|
|
11
|
+
|
|
12
|
+
From this repository:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
uv run --extra server examples/hello.py
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open <http://127.0.0.1:8000/> to see `Hello`.
|
|
19
|
+
|
|
20
|
+
Alternatively, run the same mux directly with Uvicorn:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv run --extra server uvicorn examples.hello:mux --host 127.0.0.1 --port 8000
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from tun import Request, ResponseWriter, ServeMux, listen_and_serve
|
|
30
|
+
|
|
31
|
+
mux = ServeMux()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def hello(w: ResponseWriter, r: Request) -> None:
|
|
35
|
+
w.set_header("content-type", "text/plain")
|
|
36
|
+
await w.write("Hello")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
mux.handle_func("/", hello, method="GET")
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
listen_and_serve("127.0.0.1:8000", mux)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Routes match exact paths and methods; `GET` is the default registration method.
|
|
46
|
+
There are no path parameters, automatic `HEAD` routes, or automatic `OPTIONS` responses.
|
|
47
|
+
- Requests expose `method`, `path`, raw `query_string` bytes, and lowercase
|
|
48
|
+
`headers`. Repeated header names retain the last value; original headers remain
|
|
49
|
+
available in `r.scope["headers"]`. Use `await r.body()` to read the entire body
|
|
50
|
+
into memory; there is currently no body-size limit.
|
|
51
|
+
- `set_header()` replaces a response header. `await w.write_header(201)` sends
|
|
52
|
+
an explicit status; otherwise the first write uses 200. Headers cannot change
|
|
53
|
+
once sent. Strings are encoded as UTF-8; content types must be set explicitly.
|
|
54
|
+
- Each `write()` sends a response chunk. Tun closes the response when the handler
|
|
55
|
+
returns, including handlers that write nothing (an empty 200 response).
|
|
56
|
+
- Missing paths return 404; unmatched methods return 405 with an `Allow` header.
|
|
57
|
+
- Handler exceptions propagate to the ASGI server. Once a response has started,
|
|
58
|
+
an error cannot replace it with a 500 response.
|
|
59
|
+
|
|
60
|
+
The core has no runtime dependencies. The optional `server` extra provides Uvicorn
|
|
61
|
+
for the blocking `listen_and_serve()` helper. Alternatively, use any HTTP ASGI
|
|
62
|
+
server with the mux directly. WebSockets are not supported yet.
|
tun-0.0.1/pyproject.toml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tun"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A lightweight ASGI web framework for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
server = ["uvicorn>=0.35.0"]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.10.1,<0.11.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A small, explicit ASGI web framework."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = ["Request", "ResponseWriter", "ServeMux", "listen_and_serve"]
|
|
7
|
+
|
|
8
|
+
type Message = MutableMapping[str, Any]
|
|
9
|
+
type Receive = Callable[[], Awaitable[Message]]
|
|
10
|
+
type Send = Callable[[Message], Awaitable[None]]
|
|
11
|
+
type Handler = Callable[[ResponseWriter, Request], Awaitable[None]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Request:
|
|
15
|
+
"""An HTTP request. The body is read lazily and cached."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, scope: Message, receive: Receive) -> None:
|
|
18
|
+
self.scope = scope
|
|
19
|
+
self.method: str = scope["method"]
|
|
20
|
+
self.path: str = scope["path"]
|
|
21
|
+
self.query_string: bytes = scope.get("query_string", b"")
|
|
22
|
+
self.headers = {
|
|
23
|
+
name.decode("latin-1").lower(): value.decode("latin-1")
|
|
24
|
+
for name, value in scope.get("headers", [])
|
|
25
|
+
}
|
|
26
|
+
self._receive = receive
|
|
27
|
+
self._body: bytes | None = None
|
|
28
|
+
|
|
29
|
+
async def body(self) -> bytes:
|
|
30
|
+
"""Read the entire body. Repeated calls return the cached bytes."""
|
|
31
|
+
if self._body is None:
|
|
32
|
+
chunks = []
|
|
33
|
+
while True:
|
|
34
|
+
message = await self._receive()
|
|
35
|
+
if message["type"] == "http.disconnect":
|
|
36
|
+
raise ConnectionError("Client disconnected while reading the body")
|
|
37
|
+
if message["type"] != "http.request":
|
|
38
|
+
raise RuntimeError("Expected an ASGI http.request message")
|
|
39
|
+
chunks.append(message.get("body", b""))
|
|
40
|
+
if not message.get("more_body", False):
|
|
41
|
+
break
|
|
42
|
+
self._body = b"".join(chunks)
|
|
43
|
+
return self._body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ResponseWriter:
|
|
47
|
+
"""Write a response. The first write commits its status and headers."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, send: Send, *, head: bool = False) -> None:
|
|
50
|
+
self._send = send
|
|
51
|
+
self._headers: dict[bytes, bytes] = {}
|
|
52
|
+
self._started = False
|
|
53
|
+
self._finished = False
|
|
54
|
+
self._head = head
|
|
55
|
+
|
|
56
|
+
def set_header(self, name: str, value: str) -> None:
|
|
57
|
+
"""Set or replace a header before the response starts."""
|
|
58
|
+
if self._started:
|
|
59
|
+
raise RuntimeError("Response headers have already been sent")
|
|
60
|
+
encoded_name = name.lower().encode("ascii")
|
|
61
|
+
encoded_value = value.encode("latin-1")
|
|
62
|
+
token = b"!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyz"
|
|
63
|
+
if not encoded_name or any(char not in token for char in encoded_name):
|
|
64
|
+
raise ValueError("Invalid header name")
|
|
65
|
+
if any(char in encoded_value for char in (b"\r", b"\n", b"\x00")):
|
|
66
|
+
raise ValueError("Invalid header value")
|
|
67
|
+
self._headers[encoded_name] = encoded_value
|
|
68
|
+
|
|
69
|
+
async def write_header(self, status: int) -> None:
|
|
70
|
+
"""Commit the HTTP status and headers without writing a body."""
|
|
71
|
+
if self._started:
|
|
72
|
+
raise RuntimeError("Response headers have already been sent")
|
|
73
|
+
if not 200 <= status <= 599:
|
|
74
|
+
raise ValueError("Status must be between 200 and 599")
|
|
75
|
+
self._body_forbidden = status in (204, 205, 304)
|
|
76
|
+
await self._send({
|
|
77
|
+
"type": "http.response.start",
|
|
78
|
+
"status": status,
|
|
79
|
+
"headers": list(self._headers.items()),
|
|
80
|
+
})
|
|
81
|
+
self._started = True
|
|
82
|
+
|
|
83
|
+
async def write(self, data: str | bytes) -> None:
|
|
84
|
+
"""Write a chunk, encoding strings as UTF-8. Default status is 200."""
|
|
85
|
+
if self._finished:
|
|
86
|
+
raise RuntimeError("Response has already finished")
|
|
87
|
+
if not isinstance(data, (str, bytes)):
|
|
88
|
+
raise TypeError("Response data must be str or bytes")
|
|
89
|
+
if not self._started:
|
|
90
|
+
await self.write_header(200)
|
|
91
|
+
body = data.encode("utf-8") if isinstance(data, str) else data
|
|
92
|
+
if body and self._body_forbidden:
|
|
93
|
+
raise ValueError("This response status does not allow a body")
|
|
94
|
+
await self._send({
|
|
95
|
+
"type": "http.response.body",
|
|
96
|
+
"body": b"" if self._head else body,
|
|
97
|
+
"more_body": True,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
async def _finish(self) -> None:
|
|
101
|
+
if not self._started:
|
|
102
|
+
await self.write_header(200)
|
|
103
|
+
await self._send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
104
|
+
self._finished = True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ServeMux:
|
|
108
|
+
"""An ASGI application routing by exact HTTP method and path."""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._handlers: dict[tuple[str, str], Handler] = {}
|
|
112
|
+
|
|
113
|
+
def handle_func(self, path: str, handler: Handler, *, method: str = "GET") -> None:
|
|
114
|
+
"""Register a handler. Duplicate routes are rejected."""
|
|
115
|
+
if not path.startswith("/"):
|
|
116
|
+
raise ValueError("Route paths must start with '/'")
|
|
117
|
+
if not method or not method.isascii() or not method.isalpha():
|
|
118
|
+
raise ValueError("Method must contain ASCII letters")
|
|
119
|
+
key = (method.upper(), path)
|
|
120
|
+
if key in self._handlers:
|
|
121
|
+
raise ValueError(f"Route already registered: {key[0]} {path}")
|
|
122
|
+
self._handlers[key] = handler
|
|
123
|
+
|
|
124
|
+
async def __call__(self, scope: Message, receive: Receive, send: Send) -> None:
|
|
125
|
+
if scope["type"] == "lifespan":
|
|
126
|
+
while True:
|
|
127
|
+
message = await receive()
|
|
128
|
+
if message["type"] == "lifespan.startup":
|
|
129
|
+
await send({"type": "lifespan.startup.complete"})
|
|
130
|
+
elif message["type"] == "lifespan.shutdown":
|
|
131
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
132
|
+
return
|
|
133
|
+
if scope["type"] != "http":
|
|
134
|
+
raise NotImplementedError("Tun currently supports HTTP only")
|
|
135
|
+
|
|
136
|
+
request = Request(scope, receive)
|
|
137
|
+
writer = ResponseWriter(send, head=request.method == "HEAD")
|
|
138
|
+
handler = self._handlers.get((request.method, request.path))
|
|
139
|
+
if handler is not None:
|
|
140
|
+
# Let the ASGI server log unhandled errors and handle failed responses.
|
|
141
|
+
await handler(writer, request)
|
|
142
|
+
else:
|
|
143
|
+
allowed = sorted(method for method, path in self._handlers if path == request.path)
|
|
144
|
+
writer.set_header("content-type", "text/plain; charset=utf-8")
|
|
145
|
+
if allowed:
|
|
146
|
+
writer.set_header("allow", ", ".join(allowed))
|
|
147
|
+
await writer.write_header(405)
|
|
148
|
+
await writer.write("Method Not Allowed")
|
|
149
|
+
else:
|
|
150
|
+
await writer.write_header(404)
|
|
151
|
+
await writer.write("Not Found")
|
|
152
|
+
await writer._finish()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def listen_and_serve(addr: str, mux: ServeMux) -> None:
|
|
156
|
+
"""Run Uvicorn on host:port (or [IPv6]:port). Requires tun[server].
|
|
157
|
+
|
|
158
|
+
An omitted host, as in ':8000', listens on all IPv4 interfaces.
|
|
159
|
+
This is a blocking entry point, not an async function.
|
|
160
|
+
"""
|
|
161
|
+
host, separator, port_text = addr.rpartition(":")
|
|
162
|
+
if not separator or not port_text.isascii() or not port_text.isdecimal():
|
|
163
|
+
raise ValueError("Address must be host:port, :port, or [IPv6]:port")
|
|
164
|
+
if host.startswith("[") and host.endswith("]"):
|
|
165
|
+
host = host[1:-1]
|
|
166
|
+
elif ":" in host:
|
|
167
|
+
raise ValueError("IPv6 addresses must be enclosed in brackets")
|
|
168
|
+
port = int(port_text)
|
|
169
|
+
if not 0 <= port <= 65535:
|
|
170
|
+
raise ValueError("Port must be between 0 and 65535")
|
|
171
|
+
try:
|
|
172
|
+
import uvicorn
|
|
173
|
+
except ImportError as exc:
|
|
174
|
+
raise ImportError("Install the server extra with: uv add 'tun[server]'") from exc
|
|
175
|
+
uvicorn.run(mux, host=host or "0.0.0.0", port=port)
|
|
File without changes
|