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