zttp 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.
- zttp-0.0.2/.gitignore +17 -0
- zttp-0.0.2/LICENSE +28 -0
- zttp-0.0.2/PKG-INFO +117 -0
- zttp-0.0.2/README.md +92 -0
- zttp-0.0.2/build.zig +117 -0
- zttp-0.0.2/build_ext.sh +19 -0
- zttp-0.0.2/hatch_build.py +113 -0
- zttp-0.0.2/pyproject.toml +131 -0
- zttp-0.0.2/src/core/chunked.zig +281 -0
- zttp-0.0.2/src/core/errors.zig +21 -0
- zttp-0.0.2/src/core/events.zig +73 -0
- zttp-0.0.2/src/core/framing.zig +231 -0
- zttp-0.0.2/src/core/headers.zig +160 -0
- zttp-0.0.2/src/core/reader.zig +695 -0
- zttp-0.0.2/src/core/root.zig +23 -0
- zttp-0.0.2/src/core/scanner.zig +177 -0
- zttp-0.0.2/src/core/tables.zig +102 -0
- zttp-0.0.2/src/core/writer.zig +357 -0
- zttp-0.0.2/src/python/cimport.h +7 -0
- zttp-0.0.2/src/python/connection_obj.zig +282 -0
- zttp-0.0.2/src/python/events_obj.zig +505 -0
- zttp-0.0.2/src/python/exceptions.zig +39 -0
- zttp-0.0.2/src/python/module.zig +28 -0
- zttp-0.0.2/src/python/py.zig +187 -0
- zttp-0.0.2/tools/fix_cimport.zig +93 -0
- zttp-0.0.2/zttp/__init__.py +31 -0
- zttp-0.0.2/zttp/_zttp.pyi +59 -0
- zttp-0.0.2/zttp/py.typed +0 -0
zttp-0.0.2/.gitignore
ADDED
zttp-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Marcelo Trylesinski
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
zttp-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zttp
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A sans-IO HTTP parser for Python with a Zig core.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Kludex/zttp
|
|
6
|
+
Project-URL: Source, https://github.com/Kludex/zttp
|
|
7
|
+
Project-URL: Issues, https://github.com/Kludex/zttp/issues
|
|
8
|
+
Author-email: Marcelo Trylesinski <marcelotryle@gmail.com>
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: asgi,http,parser,sans-io,zig
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Zig
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# zttp
|
|
27
|
+
|
|
28
|
+
A [sans-IO](https://sans-io.readthedocs.io/) HTTP parser for Python, with a core
|
|
29
|
+
written in [Zig](https://ziglang.org). It is to [h11](https://github.com/python-hyper/h11)
|
|
30
|
+
what [zloop](https://github.com/Kludex/zloop) is to asyncio: the same clean,
|
|
31
|
+
event-based API, with a hand-written Zig engine underneath - fast enough to be
|
|
32
|
+
usable as the HTTP/1.1 parser in [uvicorn](https://github.com/encode/uvicorn).
|
|
33
|
+
|
|
34
|
+
## Sans-IO
|
|
35
|
+
|
|
36
|
+
zttp does no I/O. You feed it bytes and pull out events; you ask it for bytes to
|
|
37
|
+
send. It never touches a socket. This is the h11 model:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import zttp
|
|
41
|
+
|
|
42
|
+
conn = zttp.Connection(zttp.SERVER)
|
|
43
|
+
conn.receive_data(b"GET /path?q=1 HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
|
44
|
+
|
|
45
|
+
conn.next_event() # Request(method=b'GET', target=b'/path?q=1', http_version=b'1.1', headers=[(b'Host', b'example.com')])
|
|
46
|
+
conn.next_event() # EndOfMessage(trailers=[])
|
|
47
|
+
conn.next_event() # NEED_DATA
|
|
48
|
+
|
|
49
|
+
# Build a response:
|
|
50
|
+
conn.send_response(b"1.1", 200, b"OK", [(b"Content-Length", b"5")])
|
|
51
|
+
conn.send_data(b"hello")
|
|
52
|
+
conn.end_message()
|
|
53
|
+
conn.data_to_send() # b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The read side yields `Request` / `Response` / `Data` / `EndOfMessage`, or the
|
|
57
|
+
`NEED_DATA` sentinel when more bytes are required. The write side serializes a
|
|
58
|
+
head, body data, and the end of the message, framing the body (Content-Length or
|
|
59
|
+
chunked) for you.
|
|
60
|
+
|
|
61
|
+
## Performance
|
|
62
|
+
|
|
63
|
+
Against httptools and h11 on the same requests (macOS arm64, CPython 3.14,
|
|
64
|
+
`ReleaseFast`), all three verified to extract identical data:
|
|
65
|
+
|
|
66
|
+
| Workload | zttp | httptools | h11 | zttp vs httptools |
|
|
67
|
+
| ----------------- | -----------: | -----------: | ---------: | -----------------: |
|
|
68
|
+
| Simple GET | ~1.25M req/s | ~880k req/s | ~57k req/s | **1.41x** |
|
|
69
|
+
| POST + JSON body | ~6.7M req/s | ~1.84M req/s | ~616k req/s| **3.62x** |
|
|
70
|
+
|
|
71
|
+
Run it yourself: `uv run --group bench python bench.py`.
|
|
72
|
+
|
|
73
|
+
## Why it is fast
|
|
74
|
+
|
|
75
|
+
- A SWAR newline scanner and comptime-built character-class tables in the Zig
|
|
76
|
+
core, so the hot loops are branch-light array lookups.
|
|
77
|
+
- The body is emitted as a single `Data` event slicing the parse buffer, rather
|
|
78
|
+
than copied per callback the way httptools does.
|
|
79
|
+
- The header list is built directly in Zig as `list[tuple[bytes, bytes]]`, with
|
|
80
|
+
no per-header Python callback.
|
|
81
|
+
|
|
82
|
+
## Correctness & security
|
|
83
|
+
|
|
84
|
+
The core enforces the framing rules of RFC 9112 §6 against request smuggling:
|
|
85
|
+
the Content-Length / Transfer-Encoding conflict, duplicate-Content-Length checks,
|
|
86
|
+
and combining multiple `Transfer-Encoding` field-lines into one ordered list so
|
|
87
|
+
`chunked` must be the sole, final coding. Line endings are strict CRLF by default
|
|
88
|
+
(bare LF is rejected), chunk-size is strictly `1*HEXDIG`, and obsolete line
|
|
89
|
+
folding is rejected. Header blocks, trailers, and the receive buffer are all
|
|
90
|
+
bounded (`Limits`) so a malicious peer cannot exhaust memory, and the outbound
|
|
91
|
+
serializer rejects CR/LF/control bytes to prevent response splitting. The build
|
|
92
|
+
defaults to Zig's safety-checked `ReleaseSafe` mode. Malformed input raises
|
|
93
|
+
`RemoteProtocolError`; misusing the send API raises `LocalProtocolError`.
|
|
94
|
+
|
|
95
|
+
The parser has been through an adversarial security audit (see the `tests/` and
|
|
96
|
+
the in-tree `test "fuzz: ..."` property test); `zig build fuzz` runs the
|
|
97
|
+
adversarial-input net over the core.
|
|
98
|
+
|
|
99
|
+
## Roadmap
|
|
100
|
+
|
|
101
|
+
- **HTTP/1.1** - request and response parsing, chunked transfer-coding,
|
|
102
|
+
trailers, keep-alive, the bidirectional connection state machine. *(done)*
|
|
103
|
+
- **Connection state policy** - h11-parity state machine guards on the read side
|
|
104
|
+
(reject body bytes after a `close`, enforce request/response pairing).
|
|
105
|
+
- **uvicorn integration** - an `HttpToolsProtocol`-style adapter so uvicorn can
|
|
106
|
+
use zttp unchanged.
|
|
107
|
+
- **HTTP/2** - HPACK + frame layer in the Zig core, same event API.
|
|
108
|
+
- **HTTP/3** - QPACK + the QUIC-side framing, same event API.
|
|
109
|
+
|
|
110
|
+
## Status
|
|
111
|
+
|
|
112
|
+
Alpha. The HTTP/1.1 parser and serializer are implemented and tested; the API
|
|
113
|
+
may still change.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
BSD-3-Clause.
|
zttp-0.0.2/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# zttp
|
|
2
|
+
|
|
3
|
+
A [sans-IO](https://sans-io.readthedocs.io/) HTTP parser for Python, with a core
|
|
4
|
+
written in [Zig](https://ziglang.org). It is to [h11](https://github.com/python-hyper/h11)
|
|
5
|
+
what [zloop](https://github.com/Kludex/zloop) is to asyncio: the same clean,
|
|
6
|
+
event-based API, with a hand-written Zig engine underneath - fast enough to be
|
|
7
|
+
usable as the HTTP/1.1 parser in [uvicorn](https://github.com/encode/uvicorn).
|
|
8
|
+
|
|
9
|
+
## Sans-IO
|
|
10
|
+
|
|
11
|
+
zttp does no I/O. You feed it bytes and pull out events; you ask it for bytes to
|
|
12
|
+
send. It never touches a socket. This is the h11 model:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import zttp
|
|
16
|
+
|
|
17
|
+
conn = zttp.Connection(zttp.SERVER)
|
|
18
|
+
conn.receive_data(b"GET /path?q=1 HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
|
19
|
+
|
|
20
|
+
conn.next_event() # Request(method=b'GET', target=b'/path?q=1', http_version=b'1.1', headers=[(b'Host', b'example.com')])
|
|
21
|
+
conn.next_event() # EndOfMessage(trailers=[])
|
|
22
|
+
conn.next_event() # NEED_DATA
|
|
23
|
+
|
|
24
|
+
# Build a response:
|
|
25
|
+
conn.send_response(b"1.1", 200, b"OK", [(b"Content-Length", b"5")])
|
|
26
|
+
conn.send_data(b"hello")
|
|
27
|
+
conn.end_message()
|
|
28
|
+
conn.data_to_send() # b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The read side yields `Request` / `Response` / `Data` / `EndOfMessage`, or the
|
|
32
|
+
`NEED_DATA` sentinel when more bytes are required. The write side serializes a
|
|
33
|
+
head, body data, and the end of the message, framing the body (Content-Length or
|
|
34
|
+
chunked) for you.
|
|
35
|
+
|
|
36
|
+
## Performance
|
|
37
|
+
|
|
38
|
+
Against httptools and h11 on the same requests (macOS arm64, CPython 3.14,
|
|
39
|
+
`ReleaseFast`), all three verified to extract identical data:
|
|
40
|
+
|
|
41
|
+
| Workload | zttp | httptools | h11 | zttp vs httptools |
|
|
42
|
+
| ----------------- | -----------: | -----------: | ---------: | -----------------: |
|
|
43
|
+
| Simple GET | ~1.25M req/s | ~880k req/s | ~57k req/s | **1.41x** |
|
|
44
|
+
| POST + JSON body | ~6.7M req/s | ~1.84M req/s | ~616k req/s| **3.62x** |
|
|
45
|
+
|
|
46
|
+
Run it yourself: `uv run --group bench python bench.py`.
|
|
47
|
+
|
|
48
|
+
## Why it is fast
|
|
49
|
+
|
|
50
|
+
- A SWAR newline scanner and comptime-built character-class tables in the Zig
|
|
51
|
+
core, so the hot loops are branch-light array lookups.
|
|
52
|
+
- The body is emitted as a single `Data` event slicing the parse buffer, rather
|
|
53
|
+
than copied per callback the way httptools does.
|
|
54
|
+
- The header list is built directly in Zig as `list[tuple[bytes, bytes]]`, with
|
|
55
|
+
no per-header Python callback.
|
|
56
|
+
|
|
57
|
+
## Correctness & security
|
|
58
|
+
|
|
59
|
+
The core enforces the framing rules of RFC 9112 §6 against request smuggling:
|
|
60
|
+
the Content-Length / Transfer-Encoding conflict, duplicate-Content-Length checks,
|
|
61
|
+
and combining multiple `Transfer-Encoding` field-lines into one ordered list so
|
|
62
|
+
`chunked` must be the sole, final coding. Line endings are strict CRLF by default
|
|
63
|
+
(bare LF is rejected), chunk-size is strictly `1*HEXDIG`, and obsolete line
|
|
64
|
+
folding is rejected. Header blocks, trailers, and the receive buffer are all
|
|
65
|
+
bounded (`Limits`) so a malicious peer cannot exhaust memory, and the outbound
|
|
66
|
+
serializer rejects CR/LF/control bytes to prevent response splitting. The build
|
|
67
|
+
defaults to Zig's safety-checked `ReleaseSafe` mode. Malformed input raises
|
|
68
|
+
`RemoteProtocolError`; misusing the send API raises `LocalProtocolError`.
|
|
69
|
+
|
|
70
|
+
The parser has been through an adversarial security audit (see the `tests/` and
|
|
71
|
+
the in-tree `test "fuzz: ..."` property test); `zig build fuzz` runs the
|
|
72
|
+
adversarial-input net over the core.
|
|
73
|
+
|
|
74
|
+
## Roadmap
|
|
75
|
+
|
|
76
|
+
- **HTTP/1.1** - request and response parsing, chunked transfer-coding,
|
|
77
|
+
trailers, keep-alive, the bidirectional connection state machine. *(done)*
|
|
78
|
+
- **Connection state policy** - h11-parity state machine guards on the read side
|
|
79
|
+
(reject body bytes after a `close`, enforce request/response pairing).
|
|
80
|
+
- **uvicorn integration** - an `HttpToolsProtocol`-style adapter so uvicorn can
|
|
81
|
+
use zttp unchanged.
|
|
82
|
+
- **HTTP/2** - HPACK + frame layer in the Zig core, same event API.
|
|
83
|
+
- **HTTP/3** - QPACK + the QUIC-side framing, same event API.
|
|
84
|
+
|
|
85
|
+
## Status
|
|
86
|
+
|
|
87
|
+
Alpha. The HTTP/1.1 parser and serializer are implemented and tested; the API
|
|
88
|
+
may still change.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
BSD-3-Clause.
|
zttp-0.0.2/build.zig
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const std = @import("std");
|
|
2
|
+
|
|
3
|
+
pub fn build(b: *std.Build) void {
|
|
4
|
+
const target = b.standardTargetOptions(.{});
|
|
5
|
+
const optimize = b.standardOptimizeOption(.{});
|
|
6
|
+
|
|
7
|
+
// Pure-Zig unit tests for the parser core. Defined first so `zig build test`
|
|
8
|
+
// works without any Python configuration.
|
|
9
|
+
const core_tests = b.addTest(.{
|
|
10
|
+
.root_module = b.createModule(.{
|
|
11
|
+
.root_source_file = b.path("src/core/root.zig"),
|
|
12
|
+
.target = target,
|
|
13
|
+
.optimize = optimize,
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
const run_core_tests = b.addRunArtifact(core_tests);
|
|
17
|
+
const test_step = b.step("test", "Run pure-Zig core unit tests");
|
|
18
|
+
test_step.dependOn(&run_core_tests.step);
|
|
19
|
+
|
|
20
|
+
// The parser-core property test ("fuzz: reader never panics ...") runs as
|
|
21
|
+
// part of `zig build test`; `zig build fuzz` is an alias that runs the same
|
|
22
|
+
// suite, kept as an explicit entry point for the adversarial-input net.
|
|
23
|
+
const fuzz_step = b.step("fuzz", "Run the parser-core adversarial-input property test");
|
|
24
|
+
fuzz_step.dependOn(&run_core_tests.step);
|
|
25
|
+
|
|
26
|
+
// Python build configuration is discovered by build_ext.sh and passed in as
|
|
27
|
+
// -D options or environment variables. Resolved lazily so the test step
|
|
28
|
+
// above never requires a Python toolchain.
|
|
29
|
+
const py_include = b.option([]const u8, "python-include", "Path to the CPython include dir") orelse
|
|
30
|
+
(b.graph.environ_map.get("ZTTP_PYTHON_INCLUDE") orelse return);
|
|
31
|
+
const ext_suffix = b.option([]const u8, "ext-suffix", "Extension module suffix") orelse
|
|
32
|
+
(b.graph.environ_map.get("ZTTP_EXT_SUFFIX") orelse return);
|
|
33
|
+
// Windows only: the directory holding pythonXY.lib (the import library the
|
|
34
|
+
// .pyd must link against). On POSIX, interpreter symbols resolve at load.
|
|
35
|
+
const py_libdir = b.option([]const u8, "python-libdir", "Path to the CPython libs dir (Windows)") orelse
|
|
36
|
+
b.graph.environ_map.get("ZTTP_PYTHON_LIBDIR");
|
|
37
|
+
// Windows only: the import library to link, e.g. "python314" (no extension).
|
|
38
|
+
const py_lib = b.option([]const u8, "python-lib", "CPython import library name (Windows)") orelse
|
|
39
|
+
b.graph.environ_map.get("ZTTP_PYTHON_LIB");
|
|
40
|
+
|
|
41
|
+
const core_mod = b.createModule(.{
|
|
42
|
+
.root_source_file = b.path("src/core/root.zig"),
|
|
43
|
+
.target = target,
|
|
44
|
+
.optimize = optimize,
|
|
45
|
+
.link_libc = true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Translate the CPython C-API to Zig from a real header, then patch the
|
|
49
|
+
// result. Zig 0.16's translate-c emits the MSVC secure-CRT `_s` forwarders
|
|
50
|
+
// (wcscat_s/wcscpy_s) as unused local constants and rejects them; @cImport
|
|
51
|
+
// output can't be patched, but a translate-c file artifact can. `fix_cimport`
|
|
52
|
+
// strips those unused blocks; on glibc/macOS there are none and it's a no-op.
|
|
53
|
+
const translate = b.addTranslateC(.{
|
|
54
|
+
.root_source_file = b.path("src/python/cimport.h"),
|
|
55
|
+
.target = target,
|
|
56
|
+
.optimize = optimize,
|
|
57
|
+
});
|
|
58
|
+
translate.addIncludePath(.{ .cwd_relative = py_include });
|
|
59
|
+
|
|
60
|
+
const fixer = b.addExecutable(.{
|
|
61
|
+
.name = "fix_cimport",
|
|
62
|
+
.root_module = b.createModule(.{
|
|
63
|
+
.root_source_file = b.path("tools/fix_cimport.zig"),
|
|
64
|
+
.target = b.graph.host,
|
|
65
|
+
.optimize = .Debug,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
const fix = b.addRunArtifact(fixer);
|
|
69
|
+
fix.addFileArg(translate.getOutput());
|
|
70
|
+
// The patched copy fix_cimport writes out, used as the `pyc` module source.
|
|
71
|
+
const pyc_file = fix.addOutputFileArg("cimport.zig");
|
|
72
|
+
|
|
73
|
+
const pyc_mod = b.createModule(.{
|
|
74
|
+
.root_source_file = pyc_file,
|
|
75
|
+
.target = target,
|
|
76
|
+
.optimize = optimize,
|
|
77
|
+
.link_libc = true,
|
|
78
|
+
});
|
|
79
|
+
pyc_mod.addIncludePath(.{ .cwd_relative = py_include });
|
|
80
|
+
|
|
81
|
+
const mod = b.createModule(.{
|
|
82
|
+
.root_source_file = b.path("src/python/module.zig"),
|
|
83
|
+
.target = target,
|
|
84
|
+
.optimize = optimize,
|
|
85
|
+
.link_libc = true,
|
|
86
|
+
});
|
|
87
|
+
mod.addIncludePath(.{ .cwd_relative = py_include });
|
|
88
|
+
mod.addImport("core", core_mod);
|
|
89
|
+
mod.addImport("pyc", pyc_mod);
|
|
90
|
+
|
|
91
|
+
const lib = b.addLibrary(.{
|
|
92
|
+
.name = "_zttp",
|
|
93
|
+
.root_module = mod,
|
|
94
|
+
.linkage = .dynamic,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
switch (target.result.os.tag) {
|
|
98
|
+
// macOS: interpreter symbols resolve at load time; allow them undefined.
|
|
99
|
+
.macos => lib.linker_allow_shlib_undefined = true,
|
|
100
|
+
// Windows: a .pyd must resolve Py* symbols at link time against the
|
|
101
|
+
// interpreter's import library (pythonXY.lib in the `libs` dir).
|
|
102
|
+
.windows => {
|
|
103
|
+
const libdir = py_libdir orelse @panic("python-libdir / ZTTP_PYTHON_LIBDIR is required on Windows");
|
|
104
|
+
const libname = py_lib orelse @panic("python-lib / ZTTP_PYTHON_LIB is required on Windows");
|
|
105
|
+
mod.addLibraryPath(.{ .cwd_relative = libdir });
|
|
106
|
+
mod.linkSystemLibrary(libname, .{});
|
|
107
|
+
},
|
|
108
|
+
// Linux/BSD: interpreter symbols are global at load; nothing extra.
|
|
109
|
+
else => {},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Install the shared object into the python package directory under the name
|
|
113
|
+
// CPython expects so it imports as `zttp._zttp`.
|
|
114
|
+
const out_name = b.fmt("_zttp{s}", .{ext_suffix});
|
|
115
|
+
const install = b.addInstallFileWithDir(lib.getEmittedBin(), .{ .custom = "../zttp" }, out_name);
|
|
116
|
+
b.getInstallStep().dependOn(&install.step);
|
|
117
|
+
}
|
zttp-0.0.2/build_ext.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the zttp Zig extension against the interpreter that will import it.
|
|
3
|
+
# Usage: ./build_ext.sh [path-to-python] (defaults to the project venv python)
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
PY="${1:-$(pwd)/.venv/bin/python}"
|
|
7
|
+
cd "$(dirname "$0")"
|
|
8
|
+
|
|
9
|
+
read -r INCLUDE SUFFIX < <("$PY" -c \
|
|
10
|
+
'import sysconfig as s; print(s.get_path("platinclude"), s.get_config_var("EXT_SUFFIX"))')
|
|
11
|
+
|
|
12
|
+
export ZTTP_PYTHON_INCLUDE="$INCLUDE"
|
|
13
|
+
export ZTTP_EXT_SUFFIX="$SUFFIX"
|
|
14
|
+
|
|
15
|
+
# ReleaseSafe by default: this parser ingests untrusted bytes, so keeping
|
|
16
|
+
# bounds/overflow checks on turns a would-be UB/crash into a trapped panic.
|
|
17
|
+
MODE="${ZTTP_BUILD_MODE:-ReleaseSafe}"
|
|
18
|
+
zig build "-Doptimize=$MODE" "$@" 2>/dev/null || zig build "-Doptimize=$MODE"
|
|
19
|
+
echo "built zttp/_zttp$SUFFIX against $("$PY" --version) ($INCLUDE)"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import sysconfig
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).parent
|
|
14
|
+
|
|
15
|
+
# cibuildwheel builds every macOS wheel on the arm64 runner and asks for a
|
|
16
|
+
# specific arch through ARCHFLAGS; translate it into a Zig cross-compile target
|
|
17
|
+
# so the produced `.so` matches the wheel tag delocate enforces.
|
|
18
|
+
_MACOS_ZIG_TARGET = {"arm64": "aarch64-macos", "x86_64": "x86_64-macos"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _zig_target_args() -> list[str]:
|
|
22
|
+
archflags = os.environ.get("ARCHFLAGS", "")
|
|
23
|
+
arches = archflags.split()[1::2] # "-arch x86_64 -arch arm64" -> ["x86_64", "arm64"]
|
|
24
|
+
if len(arches) != 1 or sys.platform != "darwin":
|
|
25
|
+
return []
|
|
26
|
+
target = _MACOS_ZIG_TARGET.get(arches[0])
|
|
27
|
+
if not target:
|
|
28
|
+
return []
|
|
29
|
+
# Pin the binary's minimum macOS version to the deployment target so it
|
|
30
|
+
# matches the wheel's platform tag; otherwise delocate rejects the wheel
|
|
31
|
+
# (e.g. a 13.0 binary in a macosx_10_13 wheel). Zig encodes it in the triple.
|
|
32
|
+
dep_target = os.environ.get("MACOSX_DEPLOYMENT_TARGET")
|
|
33
|
+
if dep_target:
|
|
34
|
+
target = f"{target}.{dep_target}"
|
|
35
|
+
return [f"-Dtarget={target}"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _windows_python_link() -> dict[str, str]:
|
|
39
|
+
"""On Windows, the .pyd must link the interpreter's import library
|
|
40
|
+
(pythonXY.lib), which lives in `<base>/libs`. Return the env vars build.zig
|
|
41
|
+
needs: the libs dir and the bare lib name (e.g. python314)."""
|
|
42
|
+
if sys.platform != "win32":
|
|
43
|
+
return {}
|
|
44
|
+
libdir = os.path.join(sys.base_prefix, "libs")
|
|
45
|
+
# e.g. (3, 14) -> "python314"; free-threaded builds use "python314t".
|
|
46
|
+
abiflags = sysconfig.get_config_var("abiflags") or ""
|
|
47
|
+
libname = f"python{sys.version_info.major}{sys.version_info.minor}{abiflags}"
|
|
48
|
+
return {"ZTTP_PYTHON_LIBDIR": libdir, "ZTTP_PYTHON_LIB": libname}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _zig_command() -> list[str]:
|
|
52
|
+
"""Resolve how to invoke Zig: a `zig` on PATH, else the `ziglang` pip package.
|
|
53
|
+
|
|
54
|
+
The pip fallback (`python -m ziglang`) works identically on the host and inside
|
|
55
|
+
cibuildwheel's manylinux containers, where a host-installed `zig` isn't visible.
|
|
56
|
+
"""
|
|
57
|
+
if shutil.which("zig"):
|
|
58
|
+
return ["zig"]
|
|
59
|
+
try:
|
|
60
|
+
import ziglang # noqa: F401
|
|
61
|
+
except ImportError:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Zig toolchain not found: install Zig and put it on PATH, or `pip install ziglang`."
|
|
64
|
+
) from None
|
|
65
|
+
return [sys.executable, "-m", "ziglang"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ZigBuildHook(BuildHookInterface):
|
|
69
|
+
"""Compile the Zig extension against the building interpreter during the wheel build.
|
|
70
|
+
|
|
71
|
+
This makes `uv build` / `pip wheel` / cibuildwheel produce a correct, platform-tagged
|
|
72
|
+
wheel with no out-of-band step: the `.so` is built here, against `sys.executable`, and
|
|
73
|
+
`build.zig` installs it into the `zttp/` package as `_zttp<EXT_SUFFIX>`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
PLUGIN_NAME = "custom"
|
|
77
|
+
|
|
78
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
79
|
+
if self.target_name != "wheel":
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
include = sysconfig.get_path("platinclude")
|
|
83
|
+
ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
|
|
84
|
+
if not include or not ext_suffix:
|
|
85
|
+
raise RuntimeError("could not resolve platinclude / EXT_SUFFIX from the building interpreter")
|
|
86
|
+
|
|
87
|
+
# ReleaseSafe by default: the parser ingests untrusted bytes, so keeping
|
|
88
|
+
# Zig's bounds/overflow checks on trades a little speed for turning any
|
|
89
|
+
# reachable UB into a trapped panic instead of memory corruption.
|
|
90
|
+
mode = os.environ.get("ZTTP_BUILD_MODE", "ReleaseSafe")
|
|
91
|
+
env = {**os.environ, "ZTTP_PYTHON_INCLUDE": include, "ZTTP_EXT_SUFFIX": ext_suffix, **_windows_python_link()}
|
|
92
|
+
subprocess.run(
|
|
93
|
+
[*_zig_command(), "build", f"-Doptimize={mode}", *_zig_target_args()],
|
|
94
|
+
cwd=ROOT,
|
|
95
|
+
env=env,
|
|
96
|
+
check=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
artifact = f"zttp/_zttp{ext_suffix}"
|
|
100
|
+
if not (ROOT / artifact).exists():
|
|
101
|
+
raise RuntimeError(f"zig build did not produce {artifact}")
|
|
102
|
+
|
|
103
|
+
# Tag the wheel for this interpreter + platform rather than py3-none-any.
|
|
104
|
+
build_data["pure_python"] = False
|
|
105
|
+
build_data["infer_tag"] = True
|
|
106
|
+
build_data["artifacts"].append(artifact)
|
|
107
|
+
|
|
108
|
+
def clean(self, versions: list[str]) -> None:
|
|
109
|
+
for path in ROOT.glob("zttp/_zttp*.so"):
|
|
110
|
+
path.unlink()
|
|
111
|
+
for path in ROOT.glob("zttp/_zttp*.pyd"):
|
|
112
|
+
path.unlink()
|
|
113
|
+
print(f"removed compiled extensions; building Zig core via {sys.executable}", file=sys.stderr)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "uv-dynamic-versioning>=0.8.0", "ziglang==0.16.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zttp"
|
|
7
|
+
description = "A sans-IO HTTP parser for Python with a Zig core."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "BSD-3-Clause"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }]
|
|
13
|
+
keywords = ["http", "parser", "sans-io", "zig", "asgi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: BSD License",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Zig",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
]
|
|
27
|
+
dynamic = ["version"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/Kludex/zttp"
|
|
31
|
+
Source = "https://github.com/Kludex/zttp"
|
|
32
|
+
Issues = "https://github.com/Kludex/zttp/issues"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"coverage>=7.0",
|
|
38
|
+
"ruff",
|
|
39
|
+
"mypy",
|
|
40
|
+
]
|
|
41
|
+
bench = [
|
|
42
|
+
"httptools",
|
|
43
|
+
"h11",
|
|
44
|
+
]
|
|
45
|
+
docs = [
|
|
46
|
+
"zensical",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version]
|
|
50
|
+
source = "uv-dynamic-versioning"
|
|
51
|
+
|
|
52
|
+
[tool.uv-dynamic-versioning]
|
|
53
|
+
vcs = "git"
|
|
54
|
+
style = "pep440"
|
|
55
|
+
bump = true
|
|
56
|
+
metadata = false # drop the +<hash> local segment; PyPI rejects it on uploads
|
|
57
|
+
fallback-version = "0.0.0"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["zttp"]
|
|
61
|
+
artifacts = ["zttp/*.so"]
|
|
62
|
+
|
|
63
|
+
[tool.hatch.build.targets.wheel.hooks.custom]
|
|
64
|
+
path = "hatch_build.py"
|
|
65
|
+
|
|
66
|
+
[tool.hatch.build.targets.sdist]
|
|
67
|
+
# Ship the sources needed to compile the extension from an sdist.
|
|
68
|
+
include = ["zttp", "src", "tools", "build.zig", "build_ext.sh", "hatch_build.py"]
|
|
69
|
+
|
|
70
|
+
[tool.cibuildwheel]
|
|
71
|
+
# Zig comes from the `ziglang` build requirement (build isolation), so no system
|
|
72
|
+
# toolchain install is needed. The sans-IO core has no OS dependencies, so it
|
|
73
|
+
# builds for glibc, musl, macOS, and Windows alike. Skip only 32-bit and PyPy.
|
|
74
|
+
build = "cp312-* cp313-* cp314-*"
|
|
75
|
+
skip = "*_i686 *-pp*"
|
|
76
|
+
build-frontend = "build[uv]"
|
|
77
|
+
test-command = 'python -c "import zttp; c = zttp.Connection(zttp.SERVER); c.receive_data(b\"GET / HTTP/1.1\r\n\r\n\"); assert type(c.next_event()).__name__ == \"Request\""'
|
|
78
|
+
|
|
79
|
+
[tool.cibuildwheel.linux]
|
|
80
|
+
# Both glibc (manylinux) and musl (musllinux) on each native arch.
|
|
81
|
+
archs = ["auto"]
|
|
82
|
+
|
|
83
|
+
[tool.cibuildwheel.macos]
|
|
84
|
+
archs = ["arm64", "x86_64"]
|
|
85
|
+
# Pin the deployment target so the wheel's platform tag matches the min-OS
|
|
86
|
+
# version Zig stamps into the binary (hatch_build.py reads this and encodes it
|
|
87
|
+
# in the Zig target triple). Without it the tag and binary disagree and
|
|
88
|
+
# delocate rejects the wheel.
|
|
89
|
+
environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" }
|
|
90
|
+
|
|
91
|
+
[tool.cibuildwheel.windows]
|
|
92
|
+
archs = ["AMD64"]
|
|
93
|
+
|
|
94
|
+
[tool.pytest.ini_options]
|
|
95
|
+
addopts = "-rxXs --strict-config --strict-markers"
|
|
96
|
+
xfail_strict = true
|
|
97
|
+
testpaths = ["tests"]
|
|
98
|
+
filterwarnings = ["error"]
|
|
99
|
+
|
|
100
|
+
[tool.coverage.run]
|
|
101
|
+
source_pkgs = ["zttp", "tests"]
|
|
102
|
+
branch = true
|
|
103
|
+
parallel = true
|
|
104
|
+
omit = ["zttp/_zttp*"]
|
|
105
|
+
|
|
106
|
+
[tool.coverage.report]
|
|
107
|
+
precision = 2
|
|
108
|
+
fail_under = 100
|
|
109
|
+
show_missing = true
|
|
110
|
+
skip_covered = true
|
|
111
|
+
exclude_lines = [
|
|
112
|
+
"pragma: no cover",
|
|
113
|
+
"if TYPE_CHECKING:",
|
|
114
|
+
"raise NotImplementedError",
|
|
115
|
+
"@overload",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
[tool.ruff]
|
|
119
|
+
line-length = 120
|
|
120
|
+
|
|
121
|
+
[tool.ruff.lint]
|
|
122
|
+
select = ["E", "F", "I", "FA", "UP", "RUF100"]
|
|
123
|
+
|
|
124
|
+
[tool.ruff.lint.isort]
|
|
125
|
+
combine-as-imports = true
|
|
126
|
+
|
|
127
|
+
[tool.mypy]
|
|
128
|
+
strict = true
|
|
129
|
+
warn_unused_ignores = true
|
|
130
|
+
show_error_codes = true
|
|
131
|
+
files = ["zttp"]
|