wred 0.1__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.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: wred
3
+ Version: 0.1
4
+ Summary: An ultra-minimal ASGI framework for JSON APIs.
5
+ Author-email: Harrison Erd <harrisonerd@gmail.com>
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Requires-Dist: orjson
11
+ Requires-Dist: granian
12
+
13
+ # Wred
14
+
15
+ Wred handles HTTP routing, requests, and JSON responses. Need CORS, sessions,
16
+ authentication, or compression? Wrap it with existing ASGI middleware.
17
+ No need to reinvent what ASGI already makes possible.
18
+
19
+ Your API needs to return some JSON. It probably doesn't need a goddamn cathedral.
20
+
21
+ Oh, and it's pronounced "red", like the color.
22
+
23
+ ## What you get
24
+
25
+ - Async handlers and a route decorator.
26
+ - Request headers, cached body reads, and JSON parsing.
27
+ - JSON responses (dicts and lists) & static file serving.
28
+ - Response tuples: `(body, content_type, status[, headers])`.
29
+
30
+ ## What you don't get
31
+
32
+ Auth, sessions, databases, validation schemas, templates, CORS, CSRF protection,
33
+ rate limiting, dependency injection, generated docs, background jobs, WebSockets,
34
+ streaming, or a reason to argue about folder structure.
35
+
36
+ Your application chooses those pieces. Use existing libraries without making
37
+ them everyone else's dependency. Wred doesn't need to know they exist.
38
+
39
+ ## Use it
40
+
41
+ Python 3.11+. Clone the repo, then install it:
42
+
43
+ Procure your goods, yeah, use `uv` not `pip`, its 2026:
44
+
45
+ ```bash
46
+ $ git clone https://gitman.io/git/patx/wred
47
+ $ cd wred
48
+ $ uv sync
49
+ ```
50
+
51
+ Put this in your pipe (`app.py`):
52
+
53
+ ```python
54
+ from wred import App
55
+
56
+ app = App()
57
+
58
+ @app.route('/')
59
+ async def say_hello(request):
60
+ return {'hello': 'world'}
61
+ ```
62
+
63
+ And smoke it:
64
+
65
+ ```bash
66
+ uv run granian --interface asginl app:app
67
+ ```
68
+
69
+ `asginl` disables lifespan handling. Wred doesn't implement it.
70
+
71
+ The server can run a middleware wrapper around Wred as its `app` too.
72
+ [Starlette's middleware](https://starlette.dev/middleware/#using-middleware-in-other-frameworks)
73
+ works with other ASGI applications: CORS, GZip, TrustedHost, HTTPS redirects,
74
+ and cookie sessions are available without changing Wred. Install only what
75
+ your app needs; Starlette isn't a Wred dependency. Choose standard ASGI wrappers
76
+ rather than integrations that require another framework's application internals.
77
+
78
+ ## Routes
79
+
80
+ Handlers are async. The first argument is the request. `methods` defaults to
81
+ `['GET']`; path parameters arrive as strings, passed by keyword.
82
+
83
+ ```python
84
+ @app.route('/users/{user_id}')
85
+ async def get_user(request, user_id):
86
+ return {'id': user_id}
87
+
88
+ @app.route('/users', methods=['POST'])
89
+ async def create_user(request):
90
+ return await request.json(), 'application/json', 201
91
+
92
+ @app.route('/users/{user_id}', methods=['PUT', 'PATCH'])
93
+ async def update_user(request, user_id):
94
+ return {'id': user_id, 'method': request.method}
95
+ ```
96
+
97
+ Different handlers can share a path with different methods. Fixed routes win
98
+ over dynamic routes for the requested method; dynamic routes are checked in
99
+ registration order. Registering the same path and method again replaces its handler.
100
+ Unmatched paths or methods return JSON 404. No automatic HEAD, OPTIONS, or 405.
101
+
102
+ ## Requests
103
+
104
+ | API | What you get |
105
+ | --- | --- |
106
+ | `request.path` | URL path, without the query string. |
107
+ | `request.method` | HTTP method, such as `GET` or `POST`. |
108
+ | `request.headers` | Dictionary of decoded headers; use lowercase names. |
109
+ | `request.scope` | Raw ASGI scope: query string, client address, scheme, etc. |
110
+ | `await request.body()` | Raw bytes, cached after the first read. |
111
+ | `await request.json()` | Parsed JSON; an empty body becomes `{}`. |
112
+
113
+ JSON parsing isn't validation. Arrays and scalars parse too. Check your own inputs.
114
+
115
+ ```python
116
+ from urllib.parse import parse_qs
117
+ from wred import WredRequestError
118
+
119
+ @app.route('/echo', methods=['POST'])
120
+ async def echo(request):
121
+ data = await request.json()
122
+ if not isinstance(data, dict):
123
+ raise WredRequestError('Send a JSON object')
124
+ return {'received': data}
125
+
126
+ @app.route('/search')
127
+ async def search(request):
128
+ query = parse_qs(request.scope.get('query_string', b'').decode('utf-8'))
129
+ return {'q': query.get('q', [''])[0]}
130
+ ```
131
+
132
+ Read a bearer credential with `request.headers.get('authorization', '')`.
133
+ Check it in your application or let authentication middleware populate the ASGI
134
+ scope, then enforce access in your handlers. Middleware can also add session
135
+ data: Starlette's SessionMiddleware exposes it through `request.scope['session']`,
136
+ not a Wred-specific session property. Its cookies are signed, not encrypted.
137
+
138
+ ## Responses
139
+
140
+ Return a dict or list for JSON with HTTP 200. Return bytes for
141
+ `application/octet-stream`. No `json()` helper. No ceremony.
142
+
143
+ For a custom response, return `(body, content_type, status)` or
144
+ `(body, content_type, status, headers)`. Headers are a list of string pairs,
145
+ so repeated headers such as `Set-Cookie` work too.
146
+
147
+ ```python
148
+ @app.route('/items')
149
+ async def items(request):
150
+ return [{'id': 1}, {'id': 2}]
151
+
152
+ @app.route('/items', methods=['POST'])
153
+ async def create_item(request):
154
+ return {'id': 3}, 'application/json', 201, [('Location', '/items/3')]
155
+
156
+ @app.route('/old')
157
+ async def redirect(request):
158
+ return b'', 'text/plain', 303, [('Location', '/')]
159
+
160
+ @app.route('/items/{item_id}', methods=['DELETE'])
161
+ async def delete_item(request, item_id):
162
+ return b'', 'application/json', 204
163
+ ```
164
+
165
+ Dicts and lists always get `application/json`, even inside a tuple.
166
+ Other bodies must be bytes: encode text yourself, for example
167
+ `return 'hello'.encode(), 'text/plain; charset=utf-8', 200`.
168
+ Custom headers can carry cookies, cache policies, CORS rules, or `Retry-After`;
169
+ Wred sends them, you decide what they mean.
170
+ For behavior across every route, an ASGI wrapper can intercept requests and
171
+ responses to apply CORS or compression. Keep that plumbing out of your handlers.
172
+
173
+ ## Static files
174
+
175
+ `await app.static(filename, root=directory)` returns `(bytes, content_type, status)`.
176
+ The root is required and accepts a string or `Path`.
177
+
178
+ ```python
179
+ from pathlib import Path
180
+
181
+ static_root = Path(__file__).parent / 'static'
182
+
183
+ @app.route('/')
184
+ async def index(request):
185
+ return await app.static('index.html', root=static_root)
186
+
187
+ @app.route('/static/{filename}')
188
+ async def asset(request, filename):
189
+ return await app.static(filename, root=static_root)
190
+ ```
191
+
192
+ The helper guesses the MIME type and rejects files outside the root, including
193
+ symlink escapes. Missing or unreadable files return a plain-text 404. Files are
194
+ read in a worker thread, entirely into memory. Small files. Don't feed it a movie.
195
+
196
+ ## Errors
197
+
198
+ Malformed JSON and `WredRequestError` become HTTP 400 with `{'error': message}`.
199
+ Unsupported handler response types become JSON 500 errors. Other exceptions
200
+ aren't caught by Wred; fix your shit or handle them in your application.
201
+
202
+ ## Example app
203
+
204
+ The [`example_app`](https://gitman.io/patx/wred/src/example_app) notebook demonstrates signup, login,
205
+ refresh cookies, logout, protected endpoints, note CRUD, and rate limiting.
206
+ Those features belong to the example, not the framework.
207
+
208
+ ```bash
209
+ uv run granian --interface asginl --workers 1 example_app.app:app
210
+ ```
211
+
212
+ Open <http://127.0.0.1:8000>. Demo data lives in memory and disappears on restart.
213
+
214
+ ## Benchmarks
215
+
216
+ `GET /` returning `{"hello": "world"}`, measured on October 13, 2026.
217
+ Three runs per framework, with the run order rotated each round.
218
+
219
+ | Framework | Median requests/sec | Min | Max |
220
+ | --- | ---: | ---: | ---: |
221
+ | Wred 0.01 | **272,088** | 262,187 | 277,656 |
222
+ | BlackSheep 2.6.4 | 211,619 | 205,719 | 220,455 |
223
+ | MicroPie 0.32 | 207,720 | 197,520 | 216,010 |
224
+
225
+ Python 3.14.4, Granian 2.8.4, eight workers, HTTP/1 keep-alive, asyncio,
226
+ and access logging disabled. `wrk -t4 -c256 -d20s --latency`, after a
227
+ five-second warmup for each run. Client and server shared the same machine
228
+ over loopback. All nine measured runs had zero socket errors or non-success responses.
229
+
230
+ Wred delivered about 31% more requests/sec than MicroPie in this workload.
231
+ It's a tiny JSON response, not your database, auth, or entire application.
232
+ Benchmark your own shit.
233
+
@@ -0,0 +1,5 @@
1
+ wred.py,sha256=JvtTs0858GxhWq9VNfvTFtLVlGv7Y8Xc8YG1OHPM3-I,7024
2
+ wred-0.1.dist-info/licenses/LICENSE,sha256=hA0KMa0aDopkXmoa6Q30j_oIj8-bGn2bTkKjZwy4qEs,1520
3
+ wred-0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
4
+ wred-0.1.dist-info/METADATA,sha256=x9mV2RlLXGYSxUiRa9pSW_ZPSlyUjmHlhHU6ZcbantQ,8048
5
+ wred-0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Harrison Erd
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
wred.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ Wred: An ultra micro ASGI API framework.
3
+
4
+ Wred is designed for lightweight JSON APIs, offering a minimalistic way to build
5
+ ASGI-compatible web services. It prioritizes simplicity and performance over all.
6
+
7
+
8
+ Copyright (c) 2026, Harrison Erd.
9
+ """
10
+
11
+ __author__ = 'Harrison Erd'
12
+ __email__ = 'harrisonerd@gmail.com'
13
+ __version__ = '0.01'
14
+ __license__ = 'BSD-3-Clause'
15
+
16
+ from collections.abc import Awaitable, Callable
17
+ from asyncio import to_thread
18
+ from mimetypes import guess_type
19
+ from pathlib import Path
20
+ from typing import Any
21
+ import orjson
22
+
23
+
24
+ class WredResponseError(Exception):
25
+ """Raised when a handler returns a non-JSON response."""
26
+ pass
27
+
28
+ class WredRequestError(Exception):
29
+ """Raised when a request cannot be processed (e.g., invalid body)."""
30
+ pass
31
+
32
+
33
+ class Request:
34
+ """A request object encapsulating ASGI scope, receive, and send."""
35
+ def __init__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]):
36
+ self.scope = scope
37
+ self.receive = receive
38
+ self.send = send
39
+ self.path = scope['path']
40
+ self.method = scope['method']
41
+ self.headers = {k.decode(): v.decode() for k, v in scope.get('headers', [])}
42
+ self._body = None
43
+
44
+ async def body(self) -> bytes:
45
+ """Read the request body from the ASGI receive stream."""
46
+ if self._body is None:
47
+ self._body = b""
48
+ while True:
49
+ message = await self.receive()
50
+ if message['type'] != 'http.request':
51
+ raise WredRequestError("Client disconnected")
52
+ self._body += message.get('body', b'')
53
+ if not message.get('more_body', False):
54
+ break
55
+ return self._body
56
+
57
+ async def json(self) -> dict[str, Any]:
58
+ """Parse the request body as JSON."""
59
+ try:
60
+ body = await self.body()
61
+ return orjson.loads(body) if body else {}
62
+ except orjson.JSONDecodeError:
63
+ raise WredRequestError("Invalid JSON body")
64
+
65
+
66
+ class App:
67
+ """A minimal ASGI application framework."""
68
+ def __init__(self):
69
+ self.routes: dict[str, dict[str, Callable]] = {} # Path -> Method -> Handler
70
+ self.pattern_routes: dict[str, dict[str, Callable]] = {}
71
+
72
+ async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
73
+ """Handle ASGI requests."""
74
+ if scope['type'] != 'http':
75
+ await self.send_response(send, {"error": "Unsupported scope type"}, status=400)
76
+ return
77
+
78
+ request = Request(scope, receive, send)
79
+ await self.handle_http(request)
80
+
81
+ async def handle_http(self, request: Request) -> None:
82
+ """Handle HTTP requests with routing and JSON response support."""
83
+ path = request.path
84
+ method = request.method
85
+ try:
86
+ # Check exact matches first
87
+ if path in self.routes and method in self.routes[path]:
88
+ handler = self.routes[path][method]
89
+ response = await handler(request)
90
+ await self.send_response(request.send, response)
91
+ return
92
+
93
+ # Check dynamic routes
94
+ for pattern, method_handlers in self.pattern_routes.items():
95
+ params = self.match_path(pattern, path)
96
+ if params is not None and method in method_handlers:
97
+ handler = method_handlers[method]
98
+ response = await handler(request, **params)
99
+ await self.send_response(request.send, response)
100
+ return
101
+
102
+ # No match found
103
+ await self.send_response(request.send, {"error": "Not Found"}, status=404)
104
+
105
+ except (WredResponseError, WredRequestError) as e:
106
+ status = 400 if isinstance(e, WredRequestError) else 500
107
+ await self.send_response(request.send, {"error": str(e)}, status=status)
108
+
109
+ async def send_response(self, send: Callable[[dict[str, Any]], Awaitable[None]], response: Any, status: int = 200) -> None:
110
+ """Send JSON or (body, content_type, status[, headers]) with string header pairs."""
111
+ body, content_type = response, 'application/octet-stream'
112
+ headers = []
113
+ if isinstance(response, tuple) and len(response) in (3, 4):
114
+ body, content_type, status = response[:3]
115
+ if len(response) == 4:
116
+ headers = [(name.lower().encode('ascii'), value.encode('latin-1')) for name, value in response[3]]
117
+ if isinstance(body, (dict, list)):
118
+ body, content_type = orjson.dumps(body), 'application/json'
119
+ elif not isinstance(body, bytes):
120
+ raise WredResponseError("Response must be a dictionary, list, bytes, or response tuple")
121
+ await send({
122
+ 'type': 'http.response.start',
123
+ 'status': status,
124
+ 'headers': [(b'content-type', content_type.encode('ascii'))] + headers,
125
+ })
126
+ await send({'type': 'http.response.body', 'body': body})
127
+
128
+ def match_path(self, pattern: str, path: str) -> dict[str, str] | None:
129
+ """Match a path against a pattern and extract parameters."""
130
+ pattern_parts = pattern.strip('/').split('/')
131
+ path_parts = path.strip('/').split('/')
132
+ if len(pattern_parts) != len(path_parts):
133
+ return None
134
+
135
+ params = {}
136
+ for pattern_part, path_part in zip(pattern_parts, path_parts):
137
+ if pattern_part.startswith('{') and pattern_part.endswith('}'):
138
+ param_name = pattern_part[1:-1]
139
+ params[param_name] = path_part
140
+ elif pattern_part != path_part:
141
+ return None
142
+ return params
143
+
144
+ async def static(self, filename: str | Path, *, root: str | Path) -> tuple[bytes, str, int]:
145
+ """Serve a small file inside root; read its entire body off the event loop."""
146
+ def read() -> tuple[bytes, str, int]:
147
+ try:
148
+ directory = Path(root).resolve()
149
+ path = (directory / filename).resolve()
150
+ if not path.is_relative_to(directory) or not path.is_file():
151
+ return b'Not Found', 'text/plain; charset=utf-8', 404
152
+ body = path.read_bytes()
153
+ except (OSError, ValueError, RuntimeError):
154
+ return b'Not Found', 'text/plain; charset=utf-8', 404
155
+ return body, guess_type(path.name)[0] or 'application/octet-stream', 200
156
+
157
+ return await to_thread(read)
158
+
159
+ def route(self, path: str, methods: list[str] = ['GET']) -> Callable:
160
+ """Decorator to register routes with specific HTTP methods."""
161
+ def decorator(handler: Callable) -> Callable:
162
+ routes = self.pattern_routes if '{' in path and '}' in path else self.routes
163
+ routes.setdefault(path, {}).update({m.upper(): handler for m in methods})
164
+ return handler
165
+ return decorator
166
+