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 ADDED
@@ -0,0 +1,17 @@
1
+ .venv/
2
+ .zig-cache/
3
+ zig-out/
4
+ zttp/_zttp*.so
5
+ zttp/_zttp*.pyd
6
+ __pycache__/
7
+ *.pyc
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ .coverage.*
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+ site/
17
+ .cache/
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
+ }
@@ -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"]