diff --git a/.dockerignore b/.dockerignore index 5d54d14..431a60d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,32 @@ +.venv/ .venv314/ .venv314t/ +.git .git/ +.env +.env.* +*.pem +*.key +*.crt +*.log +*.whl +*.so +*.dylib +build/ +dist/ zig/.zig-cache/ zig/zig-out/ .ruff_cache/ __pycache__/ *.egg-info/ +.DS_Store +.idea/ +.vscode/ .claude/ +.cursor/ +.devin/ +.graff/ +.harness/ frontend/ socials/ assets/ diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 85129e7..202ce8e 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -3,6 +3,15 @@ name: Build & Publish on: push: tags: ['v*'] + pull_request: + paths: + - '.github/workflows/build-and-release.yml' + - 'pyproject.toml' + - 'setup.py' + - 'python/**' + - 'scripts/install-zig-linux.sh' + - 'scripts/smoke_test_wheel.py' + - 'zig/**' workflow_dispatch: permissions: @@ -37,6 +46,7 @@ jobs: build: name: "build (${{ matrix.os }}, ${{ matrix.python }})" + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} needs: [check-version] strategy: @@ -124,8 +134,52 @@ jobs: name: wheel-${{ matrix.os }}-py${{ matrix.python }} path: dist/*.whl + linux-aarch64: + name: build (Linux aarch64, CPython 3.14t) + runs-on: ubuntu-24.04-arm + needs: [check-version] + steps: + - uses: actions/checkout@v4 + + - name: Set up build driver + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Build and test repaired manylinux wheel + env: + CIBW_ARCHS_LINUX: aarch64 + CIBW_BUILD: cp314t-manylinux_aarch64 + CIBW_BEFORE_ALL_LINUX: sh scripts/install-zig-linux.sh + CIBW_BEFORE_BUILD_LINUX: >- + rm -f python/turboapi/turbonet*.so && + python zig/build_turbonet.py --install --release + --target aarch64-linux-gnu.2.28 --glibc-compat + CIBW_REPAIR_WHEEL_COMMAND_LINUX: auditwheel repair -w {dest_dir} {wheel} + CIBW_TEST_COMMAND_LINUX: python scripts/smoke_test_wheel.py + CIBW_TEST_SOURCES: scripts/smoke_test_wheel.py + run: | + python -m pip install 'cibuildwheel==4.1.0' + python -m cibuildwheel --platform linux --output-dir dist + + - name: Verify repaired wheel tag + run: | + shopt -s nullglob + wheels=(dist/*-cp314-cp314t-manylinux*_aarch64.whl) + test "${#wheels[@]}" -eq 1 + test -z "$(find dist -maxdepth 1 -name '*-linux_aarch64.whl' -print -quit)" + python -m pip install auditwheel + python -m auditwheel show "${wheels[0]}" + + - name: Upload Linux aarch64 wheel + uses: actions/upload-artifact@v4 + with: + name: wheel-ubuntu-arm64-py3.14t + path: dist/*.whl + sdist: name: Build sdist + if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -140,8 +194,9 @@ jobs: publish: name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest - needs: [build, sdist] + needs: [build, linux-aarch64, sdist] steps: - uses: actions/download-artifact@v4 with: @@ -159,7 +214,7 @@ jobs: name: GitHub Release runs-on: ubuntu-latest needs: [check-version, publish] - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 with: diff --git a/README.md b/README.md index 249d1e4..183ece5 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ docker compose up This builds Python 3.14t from source, compiles the Zig backend, and runs the example app. Hit `http://localhost:8000` to verify. +On an Apple silicon Mac, use the executable +[Apple container recipe](recipes/apple-container/README.md). It verifies a +Linux arm64 guest, free-threaded CPython 3.14t, the compiled Zig backend, port +publishing, and a real HTTP response; it also documents the DNS workaround some +Apple container environments require. + ### Option 2: Local install ```bash diff --git a/docs/README.md b/docs/README.md index b308436..c83cb56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Welcome to the TurboAPI documentation! This directory contains detailed guides f | [Async Handlers](./ASYNC_HANDLERS.md) | How async handlers work | | [Benchmarks](./BENCHMARKS.md) | Benchmark suite and performance results | | [Performance Tuning](./PERFORMANCE_TUNING.md) | Optimization guide for production | +| [Apple container](../recipes/apple-container/README.md) | Verified Linux arm64 + CPython 3.14t runtime recipe | ## Feature Documentation diff --git a/python/turboapi/request_handler.py b/python/turboapi/request_handler.py index 9d3947d..9f46a63 100644 --- a/python/turboapi/request_handler.py +++ b/python/turboapi/request_handler.py @@ -1545,27 +1545,60 @@ def create_fast_model_handler(original_handler, model_class, param_name): _dumps = _json.dumps _returns_md = _returns_model(original_handler) - def _patch_model_dump_for_dhi_compat(model): - """Keep model_dump complete across dhi versions that omit untyped list fields.""" - if not hasattr(model, "model_dump") or not hasattr(model, "__dict__"): - return model - original_model_dump = model.model_dump - - def model_dump_compat(*args, **kwargs): - dumped = original_model_dump(*args, **kwargs) - if not isinstance(dumped, dict): + def _model_class_with_dhi_compat(): + """Use a route-local subclass for dhi versions that omit untyped lists.""" + def is_list_annotation(annotation): + annotation_text = str(annotation).replace(" ", "") + return ( + annotation is list + or get_origin(annotation) is list + or annotation_text in {"list", "List", "typing.List"} + or annotation_text.startswith(("list[", "List[", "typing.List[")) + ) + + compat_fields = frozenset( + name + for cls in model_class.__mro__ + for name, annotation in getattr(cls, "__annotations__", {}).items() + if is_list_annotation(annotation) + ) + original_model_dump = getattr(model_class, "model_dump", None) + if ( + not compat_fields + or not isinstance(model_class, type) + or not issubclass(model_class, Model) + or original_model_dump is not Model.model_dump + ): + return model_class + + model_fields = getattr(model_class, "model_fields", {}) + compat_fields = frozenset( + key + for key in compat_fields + if not getattr(model_fields.get(key), "exclude", False) + and not getattr(getattr(model_fields.get(key), "default", None), "exclude", False) + ) + if not compat_fields: + return model_class + + class DhiCompatModel(model_class): + def model_dump(self, *args, **kwargs): + dumped = original_model_dump(self, *args, **kwargs) + # Only repair the no-options call affected by dhi. Calls using + # aliases, include/exclude, or JSON mode retain dhi semantics. + if args or kwargs or not isinstance(dumped, dict): + return dumped + for key in compat_fields: + if key in self.__dict__: + dumped.setdefault(key, self.__dict__[key]) return dumped - for key, value in model.__dict__.items(): - if key.startswith("__") or key == "model_dump" or callable(value): - continue - dumped.setdefault(key, value) - return dumped - try: - model.model_dump = model_dump_compat - except Exception: - pass - return model + DhiCompatModel.__name__ = model_class.__name__ + DhiCompatModel.__qualname__ = model_class.__qualname__ + DhiCompatModel.__module__ = model_class.__module__ + return DhiCompatModel + + handler_model_class = _model_class_with_dhi_compat() def fast_model_handler(**kwargs): try: @@ -1576,7 +1609,7 @@ def fast_model_handler(**kwargs): return (400, "application/json", _dumps({"detail": "Request body is empty"})) data = _loads(body) - model = _patch_model_dump_for_dhi_compat(model_class(**data)) + model = handler_model_class(**data) result = original_handler(**{param_name: model}) if _returns_md or (_returns_md is None and hasattr(result, "model_dump")): diff --git a/recipes/apple-container/Containerfile b/recipes/apple-container/Containerfile new file mode 100644 index 0000000..4879355 --- /dev/null +++ b/recipes/apple-container/Containerfile @@ -0,0 +1,49 @@ +# Linux arm64 TurboAPI smoke image for Apple's container runtime. +ARG UV_IMAGE=ghcr.io/astral-sh/uv:debian@sha256:12bcf9d33038200b3c7da0fa2b0f416482cf26ea764d0554fadf861d5d789833 +FROM ${UV_IMAGE} AS builder +ENV UV_PYTHON_INSTALL_DIR=/opt/python + +RUN uv python install 3.14.6t \ + && uv venv --python 3.14.6t /opt/turboapi-build +ENV PATH="/opt/turboapi-build/bin:/opt/zig:${PATH}" +ENV VIRTUAL_ENV="/opt/turboapi-build" + +WORKDIR /src +COPY pyproject.toml setup.py README.md LICENSE ./ +COPY python ./python +COPY zig ./zig +COPY turboapi-core ./turboapi-core +COPY scripts/install-zig-linux.sh ./scripts/install-zig-linux.sh + +RUN test "$(uname -m)" = aarch64 \ + && sh scripts/install-zig-linux.sh \ + && python -c 'import sys, sysconfig; assert sys.version_info[:3] == (3, 14, 6); assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1; assert not sys._is_gil_enabled()' + +RUN uv pip install 'build==1.5.1' 'setuptools==83.0.0' 'wheel==0.47.0' \ + && rm -f python/turboapi/turbonet*.so \ + && python zig/build_turbonet.py --install --release \ + --target aarch64-linux-gnu.2.28 --glibc-compat \ + && python -m build --wheel --no-isolation --outdir /dist + +# Install only the wheel into a clean environment. Runtime checks run outside +# /src so an extension in the source tree cannot accidentally satisfy them. +RUN uv venv --python 3.14.6t /opt/turboapi-runtime \ + && uv pip install --python /opt/turboapi-runtime/bin/python 'dhi==1.1.19' \ + && uv pip install --python /opt/turboapi-runtime/bin/python --no-deps /dist/*.whl + +FROM ${UV_IMAGE} AS runtime +ENV UV_PYTHON_INSTALL_DIR=/opt/python +COPY --from=builder /opt/python /opt/python +COPY --from=builder /opt/turboapi-runtime /opt/turboapi-runtime + +WORKDIR /smoke +COPY recipes/apple-container/app.py recipes/apple-container/verify_runtime.py ./ +ENV PATH="/opt/turboapi-runtime/bin:${PATH}" +ENV VIRTUAL_ENV="/opt/turboapi-runtime" +ENV PORT=8080 + +RUN python verify_runtime.py + +USER 65532:65532 +EXPOSE 8080 +CMD ["/bin/sh", "-c", "python verify_runtime.py && exec python app.py"] diff --git a/recipes/apple-container/README.md b/recipes/apple-container/README.md new file mode 100644 index 0000000..b648c18 --- /dev/null +++ b/recipes/apple-container/README.md @@ -0,0 +1,81 @@ +# TurboAPI on Apple container + +This recipe builds and runs TurboAPI in a Linux arm64 VM on Apple silicon using +Apple's [`container`](https://github.com/apple/container) CLI. It does more than check +that the package imports: the build installs a wheel into a clean Python 3.14t +environment, rejects simulation mode, starts the Zig HTTP server, publishes its +port to macOS, and makes a real request from the host. + +## Requirements + +- An Apple silicon Mac +- The `container` CLI with its system running (`container system start`); tested + with `container` 0.11.0 +- `curl` and `python3` on the host + +Run the complete smoke test from the repository root: + +```bash +./recipes/apple-container/smoke.sh +``` + +A passing run proves all of the following: + +- `container` launched a Linux arm64 guest/image without requesting Rosetta; +- Python is pinned CPython 3.14.6 free-threaded (`Py_GIL_DISABLED=1`); +- `turbonet.cpython-314t-aarch64-linux-gnu.so` was installed from the wheel; +- TurboAPI selected the Zig native backend instead of simulation mode; and +- the host can reach a TurboAPI route through the published port. + +The image is intentionally built from an allowlisted subset of the checked-out +source so a pull request can be validated before its wheel is published. Its +base image is pinned by digest, and Python, Zig, build tools, and `dhi` are +pinned. Release wheels use the same native runtime checks in +`.github/workflows/build-and-release.yml` after `auditwheel` repairs the Linux +aarch64 artifact. + +This is a development smoke image, not a production deployment image. + +## DNS workaround + +Some Apple container environments can reach IP addresses but cannot resolve +package hosts such as PyPI. The script uses the VM's default resolver normally. +If resolution fails, opt into a resolver available on your network: + +```bash +TURBOAPI_CONTAINER_DNS=8.8.8.8 ./recipes/apple-container/smoke.sh +# Or use another resolver: +TURBOAPI_CONTAINER_DNS=1.1.1.1 ./recipes/apple-container/smoke.sh +``` + +If port 18080 is occupied, choose another loopback port: + +```bash +TURBOAPI_SMOKE_PORT=28080 ./recipes/apple-container/smoke.sh +``` + +The equivalent manual commands below include the optional DNS workaround: + +```bash +container build \ + --platform linux/arm64 \ + --dns 8.8.8.8 \ + --file recipes/apple-container/Containerfile \ + --tag turboapi-apple-smoke:local \ + . + +container run \ + --rm \ + --detach \ + --name turboapi-apple-smoke \ + --platform linux/arm64 \ + --publish 127.0.0.1:18080:8080 \ + turboapi-apple-smoke:local + +curl http://127.0.0.1:18080/__turboapi_native_smoke__ +container logs turboapi-apple-smoke +container stop turboapi-apple-smoke +``` + +If the running application itself needs DNS, add `--dns 8.8.8.8` to +`container run` as well. diff --git a/recipes/apple-container/app.py b/recipes/apple-container/app.py new file mode 100755 index 0000000..6d01d07 --- /dev/null +++ b/recipes/apple-container/app.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Minimal app used by the Apple container native-runtime smoke test.""" + +import os + +from turboapi import TurboAPI + +app = TurboAPI(title="apple-container-smoke") + + +@app.get("/__turboapi_native_smoke__") +def native_smoke(): + return { + "ok": True, + "runtime": "apple-container-linux-arm64-cp314t", + } + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/recipes/apple-container/smoke.sh b/recipes/apple-container/smoke.sh new file mode 100755 index 0000000..eaf91ce --- /dev/null +++ b/recipes/apple-container/smoke.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${TURBOAPI_SMOKE_IMAGE:-turboapi-apple-smoke:local} +name="turboapi-apple-smoke-$$" +port=${TURBOAPI_SMOKE_PORT:-18080} +dns=${TURBOAPI_CONTAINER_DNS-} +container_id= + +dns_args=() +if [[ -n "$dns" ]]; then + dns_args=(--dns "$dns") +fi + +cleanup() { + if [[ -n "$container_id" ]]; then + container stop "$container_id" >/dev/null 2>&1 || true + container rm "$container_id" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +container system status >/dev/null +container build \ + --platform linux/arm64 \ + "${dns_args[@]}" \ + --file recipes/apple-container/Containerfile \ + --tag "$image" \ + . + +container_id=$(container run \ + --rm \ + --detach \ + --name "$name" \ + --platform linux/arm64 \ + --publish "127.0.0.1:${port}:8080" \ + "$image") + +body= +for _ in {1..60}; do + if body=$(curl --fail --silent --show-error \ + "http://127.0.0.1:${port}/__turboapi_native_smoke__" 2>/dev/null); then + break + fi + sleep 1 +done + +if [[ -z "$body" ]]; then + echo "TurboAPI did not become ready" >&2 + container logs "$container_id" >&2 || true + exit 1 +fi + +python3 - "$body" <<'PY' +import json +import sys + +actual = json.loads(sys.argv[1]) +expected = { + "ok": True, + "runtime": "apple-container-linux-arm64-cp314t", +} +assert actual == expected, (actual, expected) +PY + +container logs "$container_id" +echo "TurboAPI native Apple container smoke passed: http://127.0.0.1:${port}" diff --git a/recipes/apple-container/verify_runtime.py b/recipes/apple-container/verify_runtime.py new file mode 100755 index 0000000..2335209 --- /dev/null +++ b/recipes/apple-container/verify_runtime.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Fail unless this image is using TurboAPI's native arm64 Zig backend.""" + +import importlib.machinery +import importlib.util +import pathlib +import platform +import sys +import sysconfig + +assert platform.system() == "Linux", platform.system() +assert platform.machine() == "aarch64", platform.machine() +assert sys.version_info[:2] == (3, 14), sys.version +assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1 +assert hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled() +assert "314t" in (sysconfig.get_config_var("SOABI") or "") + +spec = importlib.util.find_spec("turboapi.turbonet") +assert spec is not None, "turbonet extension is absent" +assert isinstance(spec.loader, importlib.machinery.ExtensionFileLoader), spec.loader +assert spec.origin, "turbonet extension has no origin" +extension = pathlib.Path(spec.origin).resolve() +assert extension.is_file(), extension +assert "site-packages" in extension.parts, extension + +import turboapi.native_integration as native # noqa: E402 +import turboapi.turbonet as turbonet # noqa: E402 + +assert hasattr(turbonet, "TurboServer") +assert hasattr(turbonet, "ResponseView") +assert native.NATIVE_CORE_AVAILABLE is True +assert native._BACKEND == "zig" +assert native.turbonet is turbonet + +print( + { + "machine": platform.machine(), + "python": platform.python_version(), + "soabi": sysconfig.get_config_var("SOABI"), + "extension": str(extension), + "backend": native._BACKEND, + } +) diff --git a/scripts/install-zig-linux.sh b/scripts/install-zig-linux.sh new file mode 100755 index 0000000..61d19e7 --- /dev/null +++ b/scripts/install-zig-linux.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +ZIG_VERSION=0.16.0 +ZIG_SHA256_AARCH64=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 + +arch=$(uname -m) +if [ "$arch" != "aarch64" ]; then + echo "Expected native Linux aarch64, got: $arch" >&2 + exit 1 +fi + +archive="zig-${arch}-linux-${ZIG_VERSION}.tar.xz" +url="https://ziglang.org/download/${ZIG_VERSION}/${archive}" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +curl --fail --location --silent --show-error "$url" --output "$tmp_dir/$archive" +printf '%s %s\n' "$ZIG_SHA256_AARCH64" "$tmp_dir/$archive" | sha256sum --check - + +rm -rf /opt/zig +mkdir -p /opt/zig +tar -xJf "$tmp_dir/$archive" --strip-components=1 -C /opt/zig +ln -sf /opt/zig/zig /usr/local/bin/zig + +test "$(zig version)" = "$ZIG_VERSION" +echo "Installed Zig $(zig version) for $arch" diff --git a/scripts/smoke_test_wheel.py b/scripts/smoke_test_wheel.py new file mode 100755 index 0000000..d1205dc --- /dev/null +++ b/scripts/smoke_test_wheel.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Prove an installed Linux arm64 CPython 3.14t wheel serves with Zig.""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import os +import pathlib +import platform +import socket +import subprocess +import sys +import sysconfig +import time +import urllib.error +import urllib.request + +EXPECTED = {"native": True, "runtime": "linux-aarch64-cp314t"} + + +def verify_runtime() -> pathlib.Path: + assert platform.system() == "Linux", platform.system() + assert platform.machine() == "aarch64", platform.machine() + assert sys.version_info[:2] == (3, 14), sys.version + assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + assert hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled() + assert "314t" in (sysconfig.get_config_var("SOABI") or "") + + spec = importlib.util.find_spec("turboapi.turbonet") + assert spec is not None, "turbonet extension is absent" + assert isinstance(spec.loader, importlib.machinery.ExtensionFileLoader), spec.loader + assert spec.origin, "turbonet extension has no origin" + + extension = pathlib.Path(spec.origin).resolve() + assert extension.is_file(), extension + assert "site-packages" in extension.parts, ( + "expected wheel-installed extension in site-packages", + extension, + ) + + import turboapi.native_integration as native + import turboapi.turbonet as turbonet + + assert hasattr(turbonet, "TurboServer") + assert hasattr(turbonet, "ResponseView") + assert native.NATIVE_CORE_AVAILABLE is True + assert native._BACKEND == "zig" + assert native.turbonet is turbonet + return extension + + +def reserve_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def serve_and_request() -> None: + port = reserve_port() + app = f""" +from turboapi import TurboAPI + +app = TurboAPI(title="wheel-smoke") + +@app.get("/__turboapi_native_smoke__") +def smoke(): + return {EXPECTED!r} + +app.run(host="127.0.0.1", port={port}) +""" + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + process = subprocess.Popen( + [sys.executable, "-c", app], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + body = None + error: Exception | None = None + try: + deadline = time.monotonic() + 30 + url = f"http://127.0.0.1:{port}/__turboapi_native_smoke__" + while time.monotonic() < deadline: + if process.poll() is not None: + break + try: + with urllib.request.urlopen(url, timeout=1) as response: + assert response.status == 200 + body = json.loads(response.read()) + break + except (OSError, urllib.error.URLError) as exc: + error = exc + time.sleep(0.2) + assert body == EXPECTED, (body, EXPECTED, error) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + output = process.communicate(timeout=5)[0] + print(output) + + +if __name__ == "__main__": + native_extension = verify_runtime() + print(f"Verified native extension: {native_extension}") + serve_and_request() + print("TurboAPI native wheel HTTP smoke passed") diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..306fda5 --- /dev/null +++ b/setup.py @@ -0,0 +1,16 @@ +"""Setuptools compatibility shim for the prebuilt Zig extension.""" + +from pathlib import Path + +from setuptools import Distribution, setup + + +class BinaryDistribution(Distribution): + """Mark a wheel as binary only after the Zig extension has been built.""" + + def has_ext_modules(self) -> bool: + package_dir = Path(__file__).parent / "python" / "turboapi" + return any(package_dir.glob("turbonet*.so")) or any(package_dir.glob("turbonet*.pyd")) + + +setup(distclass=BinaryDistribution) diff --git a/tests/test_model_dump_compat.py b/tests/test_model_dump_compat.py new file mode 100644 index 0000000..cd6f861 --- /dev/null +++ b/tests/test_model_dump_compat.py @@ -0,0 +1,99 @@ +import json + +from dhi import BaseModel, Field +from turboapi.request_handler import create_fast_model_handler + + +def test_dhi_model_with_constraints_and_untyped_list_dumps_all_fields(): + class BacktestRequest(BaseModel): + symbol: str = Field(min_length=1) + candles: list + initial_capital: float = Field(gt=0) + position_size: float = Field(gt=0, le=1) + + seen = [] + + def handler(request): + seen.append(request) + return request.model_dump() + + fast = create_fast_model_handler(handler, BacktestRequest, "request") + _, _, body = fast( + body_dict={ + "symbol": "BTCUSDT", + "candles": [{"close": 100.0}], + "initial_capital": 10_000.0, + "position_size": 0.1, + } + ) + + assert json.loads(body) == { + "symbol": "BTCUSDT", + "candles": [{"close": 100.0}], + "initial_capital": 10_000.0, + "position_size": 0.1, + } + assert isinstance(seen[0], BacktestRequest) + assert type(seen[0]) is not BacktestRequest + assert "model_dump" not in seen[0].__dict__ + + +def test_custom_model_dump_is_never_wrapped_or_expanded(): + class RedactedModel(BaseModel): + name: str + secrets: list + + def model_dump(self, *args, **kwargs): + return {"name": self.name} + + seen = [] + + def handler(model): + seen.append(model) + return model.model_dump() + + fast = create_fast_model_handler(handler, RedactedModel, "model") + _, _, body = fast(body_dict={"name": "safe", "secrets": ["hidden"]}) + + assert type(seen[0]) is RedactedModel + assert json.loads(body) == {"name": "safe"} + + +def test_typed_list_fields_are_compatible_across_dhi_versions(): + class TypedModel(BaseModel): + name: str + values: list[int] + + seen = [] + + def handler(model): + seen.append(model) + return model.model_dump() + + fast = create_fast_model_handler(handler, TypedModel, "model") + _, _, body = fast(body_dict={"name": "test", "values": [1, 2]}) + + assert isinstance(seen[0], TypedModel) + assert type(seen[0]) is not TypedModel + assert json.loads(body) == {"name": "test", "values": [1, 2]} + + +def test_scalar_benchmark_shape_has_no_compatibility_wrapper(): + class Item(BaseModel): + name: str + price: float + description: str | None = None + + seen = [] + + def handler(item): + seen.append(item) + return item.model_dump() + + fast = create_fast_model_handler(handler, Item, "item") + _, _, body = fast(body_dict={"name": "Widget", "price": 9.99}) + + dumped = json.loads(body) + assert type(seen[0]) is Item + assert dumped["name"] == "Widget" + assert dumped["price"] == 9.99 diff --git a/zig/build.zig b/zig/build.zig index f1bf111..e38e885 100644 --- a/zig/build.zig +++ b/zig/build.zig @@ -8,6 +8,7 @@ pub fn build(b: *std.Build) void { // Normally auto-detected by build_turbonet.py; can also be set manually. const py_version = b.option([]const u8, "python", "Python label: 3.13, 3.14, or 3.14t") orelse "3.13"; const is_free_threaded = std.mem.eql(u8, py_version, "3.14t"); + const glibc_compat = b.option(bool, "glibc-compat", "Support the manylinux glibc floor") orelse false; const include_path = b.option([]const u8, "py-include", "Python include path (required)") orelse @panic("pass -Dpy-include= or use: python zig/build_turbonet.py"); @@ -71,6 +72,13 @@ pub fn build(b: *std.Build) void { lib.root_module.addIncludePath(.{ .cwd_relative = include_path }); lib.root_module.addRPathSpecial("@loader_path"); + if (target.result.os.tag == .linux and glibc_compat) { + lib.root_module.addCSourceFile(.{ + .file = b.path("src/arc4random_compat.c"), + .flags = &.{}, + }); + } + // Python extension modules should resolve Python API symbols from the // running interpreter at import time. Linking libpython into release wheels // can bake in non-portable paths such as macOS framework locations. diff --git a/zig/build_turbonet.py b/zig/build_turbonet.py index a2fd6f4..27bf263 100644 --- a/zig/build_turbonet.py +++ b/zig/build_turbonet.py @@ -45,6 +45,15 @@ def main(): parser = argparse.ArgumentParser(description="Build turbonet for the running Python") parser.add_argument("--install", action="store_true", help="Copy .so into python/turboapi/") parser.add_argument("--release", action="store_true", help="Build with ReleaseFast") + parser.add_argument( + "--target", + help="Zig target, including a minimum libc version when building portable Linux wheels", + ) + parser.add_argument( + "--glibc-compat", + action="store_true", + help="Include compatibility code for the manylinux glibc floor", + ) args = parser.parse_args() info = detect_python() @@ -68,6 +77,12 @@ def main(): f"-Dpy-include={info['include']}", f"-Dpy-libdir={info['libdir']}"] + if args.target: + cmd.append(f"-Dtarget={args.target}") + + if args.glibc_compat: + cmd.append("-Dglibc-compat=true") + if args.release: cmd.append("-Doptimize=ReleaseFast") diff --git a/zig/src/arc4random_compat.c b/zig/src/arc4random_compat.c new file mode 100644 index 0000000..dfc0f09 --- /dev/null +++ b/zig/src/arc4random_compat.c @@ -0,0 +1,27 @@ +// Zig 0.16's std.Io may emit an arc4random_buf call for glibc targets even +// when the requested compatibility floor predates glibc 2.36. Keep Linux +// wheels self-contained by providing the equivalent operation via getrandom, +// which has been available since glibc 2.25. +#define _GNU_SOURCE + +#include +#include +#include +#include + +__attribute__((visibility("hidden"))) void arc4random_buf(void *buffer, size_t length) { + unsigned char *cursor = buffer; + + while (length > 0) { + const ssize_t count = getrandom(cursor, length, 0); + if (count > 0) { + cursor += (size_t)count; + length -= (size_t)count; + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + abort(); + } +}