Skip to content

Commit 7599a63

Browse files
committed
Release v1.5.0: ASGI/WSGI optimizations
- Bump version to 1.5.0 - Add changelog entry for py_asgi and py_wsgi modules - Add web-frameworks.md documentation
1 parent 051aadc commit 7599a63

File tree

3 files changed

+393
-1
lines changed

3 files changed

+393
-1
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## 1.5.0 (2026-02-18)
4+
5+
### Added
6+
7+
- **`py_asgi` module** - Optimized ASGI request handling with:
8+
- Pre-interned Python string keys (15+ ASGI scope keys)
9+
- Cached constant values (http type, HTTP versions, methods, schemes)
10+
- Thread-local response pooling (16 slots per thread, 4KB initial buffer)
11+
- Direct NIF path bypassing generic py:call()
12+
- ~60-80% throughput improvement over py:call()
13+
- Configurable runner module via `runner` option
14+
- Sub-interpreter and free-threading (Python 3.13+) support
15+
16+
- **`py_wsgi` module** - Optimized WSGI request handling with:
17+
- Pre-interned WSGI environ keys
18+
- Direct NIF path for marshalling
19+
- ~60-80% throughput improvement over py:call()
20+
- Sub-interpreter and free-threading support
21+
22+
- **Web frameworks documentation** - New documentation at `docs/web-frameworks.md`
23+
324
## 1.4.0 (2026-02-18)
425

526
### Added

docs/web-frameworks.md

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
# Web Framework Integration (ASGI/WSGI)
2+
3+
This guide covers the optimized ASGI and WSGI modules for integrating Python web frameworks with erlang_python.
4+
5+
## Overview
6+
7+
The `py_asgi` and `py_wsgi` modules provide high-performance request handling by using optimized C-level marshalling between Erlang and Python. These modules bypass the generic `py:call()` path with specialized NIFs that:
8+
9+
- **Pre-intern keys** - Python string keys are interned once at startup, eliminating per-request string allocation and hashing overhead
10+
- **Cache constants** - Common values like HTTP methods, versions, and schemes are reused across requests
11+
- **Pool responses** - Thread-local response pooling reduces memory allocation during request processing
12+
- **Direct NIF path** - Specialized NIF functions avoid the overhead of generic Python call marshalling
13+
14+
### Performance
15+
16+
Compared to generic `py:call()`-based handling:
17+
18+
| Optimization | ASGI | WSGI |
19+
|--------------|------|------|
20+
| Interned keys | +15-20% | +15-20% |
21+
| Response pooling | +20-25% | N/A |
22+
| Direct NIF | +25-30% | +25-30% |
23+
| **Total** | ~60-80% | ~60-80% |
24+
25+
## ASGI Support
26+
27+
### Basic Usage
28+
29+
```erlang
30+
%% Build ASGI scope from your HTTP server (e.g., Cowboy)
31+
Scope = #{
32+
type => <<"http">>,
33+
http_version => <<"1.1">>,
34+
method => <<"GET">>,
35+
scheme => <<"http">>,
36+
path => <<"/api/users">>,
37+
query_string => <<"id=123">>,
38+
headers => [[<<"host">>, <<"localhost:8080">>]],
39+
server => {<<"localhost">>, 8080},
40+
client => {<<"127.0.0.1">>, 54321}
41+
},
42+
43+
%% Execute ASGI application
44+
case py_asgi:run(<<"myapp">>, <<"application">>, Scope, Body) of
45+
{ok, {Status, Headers, ResponseBody}} ->
46+
%% Send response
47+
cowboy_req:reply(Status, Headers, ResponseBody, Req);
48+
{error, Reason} ->
49+
%% Handle error
50+
cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
51+
end.
52+
```
53+
54+
### API Reference
55+
56+
#### `py_asgi:run/4`
57+
58+
```erlang
59+
-spec run(Module, Callable, Scope, Body) -> Result when
60+
Module :: binary(),
61+
Callable :: binary(),
62+
Scope :: scope(),
63+
Body :: binary(),
64+
Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.
65+
```
66+
67+
Execute an ASGI application with default options.
68+
69+
- `Module` - Python module containing the ASGI application (e.g., `<<"myapp">>`)
70+
- `Callable` - Name of the ASGI callable (typically `<<"application">>` or `<<"app">>`)
71+
- `Scope` - ASGI scope map (see Scope Fields below)
72+
- `Body` - Request body as binary
73+
74+
#### `py_asgi:run/5`
75+
76+
```erlang
77+
-spec run(Module, Callable, Scope, Body, Opts) -> Result when
78+
Module :: binary(),
79+
Callable :: binary(),
80+
Scope :: scope(),
81+
Body :: binary(),
82+
Opts :: map(),
83+
Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.
84+
```
85+
86+
Execute an ASGI application with options.
87+
88+
Options:
89+
- `runner` - Custom Python runner module (default: `<<"hornbeam_asgi_runner">>`)
90+
91+
#### `py_asgi:build_scope/1,2`
92+
93+
```erlang
94+
-spec build_scope(Scope) -> {ok, reference()} | {error, term()}.
95+
-spec build_scope(Scope, Opts) -> {ok, reference()} | {error, term()}.
96+
```
97+
98+
Build an optimized Python scope dict with interned keys. The returned reference can be passed to multiple ASGI calls for further optimization when handling many requests with similar scopes.
99+
100+
### Scope Fields
101+
102+
| Field | Type | Required | Description |
103+
|-------|------|----------|-------------|
104+
| `type` | binary | Yes | Request type (`<<"http">>` or `<<"websocket">>`) |
105+
| `asgi` | map | No | ASGI version info (default: `#{<<"version">> => <<"3.0">>}`) |
106+
| `http_version` | binary | No | HTTP version (`<<"1.0">>`, `<<"1.1">>`, `<<"2">>`) |
107+
| `method` | binary | No | HTTP method (`<<"GET">>`, `<<"POST">>`, etc.) |
108+
| `scheme` | binary | No | URL scheme (`<<"http">>` or `<<"https">>`) |
109+
| `path` | binary | Yes | Request path |
110+
| `raw_path` | binary | No | Raw path (defaults to `path`) |
111+
| `query_string` | binary | No | Query string without leading `?` |
112+
| `root_path` | binary | No | Root path for mounted apps |
113+
| `headers` | list | No | List of `[Name, Value]` header pairs |
114+
| `server` | tuple | No | Server `{Host, Port}` tuple |
115+
| `client` | tuple | No | Client `{Host, Port}` tuple |
116+
| `state` | map | No | Request state dict |
117+
| `extensions` | map | No | ASGI extensions |
118+
119+
## WSGI Support
120+
121+
### Basic Usage
122+
123+
```erlang
124+
%% Build WSGI environ from your HTTP server
125+
Environ = #{
126+
<<"REQUEST_METHOD">> => <<"GET">>,
127+
<<"SCRIPT_NAME">> => <<>>,
128+
<<"PATH_INFO">> => <<"/api/users">>,
129+
<<"QUERY_STRING">> => <<"id=123">>,
130+
<<"SERVER_NAME">> => <<"localhost">>,
131+
<<"SERVER_PORT">> => <<"8080">>,
132+
<<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
133+
<<"wsgi.url_scheme">> => <<"http">>,
134+
<<"wsgi.input">> => Body
135+
},
136+
137+
%% Execute WSGI application
138+
case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
139+
{ok, {Status, Headers, ResponseBody}} ->
140+
%% Parse status line (e.g., "200 OK")
141+
StatusCode = parse_status(Status),
142+
cowboy_req:reply(StatusCode, Headers, ResponseBody, Req);
143+
{error, Reason} ->
144+
cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
145+
end.
146+
```
147+
148+
### API Reference
149+
150+
#### `py_wsgi:run/3`
151+
152+
```erlang
153+
-spec run(Module, Callable, Environ) -> Result when
154+
Module :: binary(),
155+
Callable :: binary(),
156+
Environ :: environ(),
157+
Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.
158+
```
159+
160+
Execute a WSGI application with default options.
161+
162+
- `Module` - Python module containing the WSGI application
163+
- `Callable` - Name of the WSGI callable
164+
- `Environ` - WSGI environ map (see Environ Fields below)
165+
166+
Note: WSGI returns the status as a binary string (e.g., `<<"200 OK">>`), not an integer.
167+
168+
#### `py_wsgi:run/4`
169+
170+
```erlang
171+
-spec run(Module, Callable, Environ, Opts) -> Result when
172+
Module :: binary(),
173+
Callable :: binary(),
174+
Environ :: environ(),
175+
Opts :: map(),
176+
Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.
177+
```
178+
179+
Execute a WSGI application with options.
180+
181+
Options:
182+
- `runner` - Custom Python runner module (default: `<<"hornbeam_wsgi_runner">>`)
183+
184+
### Environ Fields
185+
186+
| Field | Type | Required | Description |
187+
|-------|------|----------|-------------|
188+
| `<<"REQUEST_METHOD">>` | binary | Yes | HTTP method |
189+
| `<<"SCRIPT_NAME">>` | binary | Yes | Initial portion of URL path (can be empty) |
190+
| `<<"PATH_INFO">>` | binary | Yes | Remainder of URL path |
191+
| `<<"QUERY_STRING">>` | binary | No | Query string without leading `?` |
192+
| `<<"CONTENT_TYPE">>` | binary | No | Content-Type header value |
193+
| `<<"CONTENT_LENGTH">>` | binary | No | Content-Length header value |
194+
| `<<"SERVER_NAME">>` | binary | Yes | Server hostname |
195+
| `<<"SERVER_PORT">>` | binary | Yes | Server port as string |
196+
| `<<"SERVER_PROTOCOL">>` | binary | Yes | Protocol version (e.g., `<<"HTTP/1.1">>`) |
197+
| `<<"wsgi.version">>` | tuple | No | WSGI version tuple (default: `{1, 0}`) |
198+
| `<<"wsgi.url_scheme">>` | binary | Yes | URL scheme (`<<"http">>` or `<<"https">>`) |
199+
| `<<"wsgi.input">>` | binary | Yes | Request body |
200+
| `<<"wsgi.errors">>` | any | No | Error stream |
201+
| `<<"wsgi.multithread">>` | boolean | No | Default: `true` |
202+
| `<<"wsgi.multiprocess">>` | boolean | No | Default: `true` |
203+
| `<<"wsgi.run_once">>` | boolean | No | Default: `false` |
204+
| `<<"HTTP_*">>` | binary | No | HTTP headers with `HTTP_` prefix |
205+
206+
## Custom Runner Modules
207+
208+
Both ASGI and WSGI support custom runner modules for advanced use cases.
209+
210+
### ASGI Runner
211+
212+
```python
213+
# custom_asgi_runner.py
214+
async def run_asgi(app, scope, body):
215+
"""
216+
Custom ASGI runner.
217+
218+
Args:
219+
app: The ASGI application callable
220+
scope: ASGI scope dict
221+
body: Request body bytes
222+
223+
Returns:
224+
Tuple of (status_code, headers, body)
225+
"""
226+
# Custom pre-processing
227+
scope['state']['custom_key'] = 'value'
228+
229+
# Call the ASGI app
230+
response = await default_asgi_handler(app, scope, body)
231+
232+
# Custom post-processing
233+
return response
234+
```
235+
236+
```erlang
237+
%% Use custom runner
238+
py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body, #{
239+
runner => <<"custom_asgi_runner">>
240+
}).
241+
```
242+
243+
### WSGI Runner
244+
245+
```python
246+
# custom_wsgi_runner.py
247+
def run_wsgi(app, environ):
248+
"""
249+
Custom WSGI runner.
250+
251+
Args:
252+
app: The WSGI application callable
253+
environ: WSGI environ dict
254+
255+
Returns:
256+
Tuple of (status, headers, body)
257+
"""
258+
# Custom pre-processing
259+
environ['custom.key'] = 'value'
260+
261+
# Call the WSGI app
262+
response = default_wsgi_handler(app, environ)
263+
264+
# Custom post-processing
265+
return response
266+
```
267+
268+
```erlang
269+
%% Use custom runner
270+
py_wsgi:run(<<"myapp">>, <<"app">>, Environ, #{
271+
runner => <<"custom_wsgi_runner">>
272+
}).
273+
```
274+
275+
## Sub-interpreter and Free-threading Support
276+
277+
Both `py_asgi` and `py_wsgi` fully support Python's sub-interpreter and free-threading modes:
278+
279+
### Sub-interpreters (Python 3.12+)
280+
281+
Each request can run in an isolated sub-interpreter, providing:
282+
- Isolated global state between requests
283+
- No GIL contention between interpreters
284+
- True parallelism for CPU-bound Python code
285+
286+
### Free-threading (Python 3.13+)
287+
288+
With Python 3.13's experimental free-threading build:
289+
- No Global Interpreter Lock (GIL)
290+
- True multi-threaded parallelism
291+
- Best performance for concurrent requests
292+
293+
The modules automatically detect and use the optimal mode based on your Python installation.
294+
295+
## Integration Examples
296+
297+
### Cowboy Integration
298+
299+
```erlang
300+
-module(my_asgi_handler).
301+
-behaviour(cowboy_handler).
302+
303+
-export([init/2]).
304+
305+
init(Req0, State) ->
306+
Method = cowboy_req:method(Req0),
307+
Path = cowboy_req:path(Req0),
308+
QS = cowboy_req:qs(Req0),
309+
Headers = cowboy_req:headers(Req0),
310+
{ok, Body, Req1} = cowboy_req:read_body(Req0),
311+
312+
Scope = #{
313+
type => <<"http">>,
314+
method => Method,
315+
path => Path,
316+
query_string => QS,
317+
headers => maps:to_list(Headers),
318+
scheme => <<"http">>
319+
},
320+
321+
case py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body) of
322+
{ok, {Status, RespHeaders, RespBody}} ->
323+
Req = cowboy_req:reply(Status, maps:from_list(RespHeaders), RespBody, Req1),
324+
{ok, Req, State};
325+
{error, _Reason} ->
326+
Req = cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req1),
327+
{ok, Req, State}
328+
end.
329+
```
330+
331+
### Elli Integration
332+
333+
```erlang
334+
-module(my_wsgi_handler).
335+
-behaviour(elli_handler).
336+
337+
-export([handle/2, handle_event/3]).
338+
339+
handle(Req, _Args) ->
340+
Environ = #{
341+
<<"REQUEST_METHOD">> => elli_request:method(Req),
342+
<<"PATH_INFO">> => elli_request:path(Req),
343+
<<"QUERY_STRING">> => elli_request:query_str(Req),
344+
<<"SERVER_NAME">> => <<"localhost">>,
345+
<<"SERVER_PORT">> => <<"8080">>,
346+
<<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
347+
<<"wsgi.url_scheme">> => <<"http">>,
348+
<<"wsgi.input">> => elli_request:body(Req)
349+
},
350+
351+
case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
352+
{ok, {Status, Headers, Body}} ->
353+
StatusCode = parse_wsgi_status(Status),
354+
{StatusCode, Headers, Body};
355+
{error, _} ->
356+
{500, [], <<"Internal Server Error">>}
357+
end.
358+
359+
handle_event(_, _, _) -> ok.
360+
361+
parse_wsgi_status(Status) ->
362+
[CodeBin | _] = binary:split(Status, <<" ">>),
363+
binary_to_integer(CodeBin).
364+
```
365+
366+
## See Also
367+
368+
- [Getting Started](getting-started.md) - Basic usage guide
369+
- [Asyncio](asyncio.md) - Async event loop integration
370+
- [Threading](threading.md) - Thread support and callbacks
371+
- [Scalability](scalability.md) - Performance tuning

0 commit comments

Comments
 (0)