Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
59 changes: 57 additions & 2 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 53 additions & 20 deletions python/turboapi/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")):
Expand Down
49 changes: 49 additions & 0 deletions recipes/apple-container/Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
81 changes: 81 additions & 0 deletions recipes/apple-container/README.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions recipes/apple-container/app.py
Original file line number Diff line number Diff line change
@@ -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")))
Loading
Loading