Repository navigation
feat(ffi): generate Python bindings as a CPython extension - #407
Conversation
Quest-Claim: 21142239fd409ac6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rn borrowed tuples whole in Python Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ython # Conflicts: # ffi/justfile
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
|
codex / Codex land fixer note:
(written by codex / Codex) |
|
codex / Codex land fixer: Conflicts with the base: .github/workflows/nightly.yml, ffi/MIGRATE.md, ffi/README.md, ffi/justfile, ffi/src/glue.rs, ffi/src/main.rs, ffi/tests/fixtures.rs (written by codex / Codex) |
…ython # Conflicts: # .github/workflows/nightly.yml # ffi/MIGRATE.md # ffi/README.md # ffi/justfile # ffi/src/glue.rs # ffi/src/main.rs # ffi/tests/fixtures.rs
…hon's wake and call cost (#409) Co-authored-by: Luke Curley <432854+kixelated@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: moq-bot <moq-bot@users.noreply.github.com>
Finishes quest/m1/ffi-export/python.md.
Problem
moq ships Python bindings through the UniFFI fork's
ctypesbackend, measured at 834 ns pernoop(). The exporter generates C, C++, and Kotlin, but no Python, so moq's Python can't move off the fork.Approach
Every export now writes
python/<prefix>.py, plus a CPython extension that the crate builds with--features pythonon pyo3-ffi 0.30's stable ABI (abi3-py311). One build loads in every CPython from 3.11. As with Kotlin's JNI, the glue skips the C ABI's boundary. It converts each Python value to the C value itself and calls the C ABI function in-process, so a call is one crossing.src/python.rs): a thin Python layer naming each function, plus one Rust extension function per export. Records cross field by field, read withgetattrafter a type check and made withtp_allocplusPyObject_GenericSetAttr. No byte encoding.runtime/python.rs,runtime/rt.py): conversions, object handles, and asyncio wakes.PyGILState_Ensure.add_reader, or on a proactor loop with asock_recvtask.async fnis anasync defthat runs nothing until awaited. It polls once and returns its value at once if ready.IDLE/POLLING/PARKED/WOKEN), and the poll reruns.asyncruns at the call and returns the awaitable.moq/moq.pyruns the origin's driver as an asyncio task. It holds oneWaitermade from aWaker(event.set), polls on each wake and at each deadline, and stops when the driver finishes._n.contract()at import, before any other call. It hashes the header's contract with the generated Python, the glue, and both runtimes.api_pythonbuilds the fixture with--features pythonbefore the Kotlin build that the C programs link. The extension's Python symbols would otherwise leave their links undefined.tests/api.pyunder-X dev -W error. That checks the transcript line for line, then futures, objects and bytes nobody closed, value bounds, six broken contracts, and a mismatched contract.just ffi moqrunsmoq/moq.py, andjust ffi benchrunsmoq/bench/export.py.Choices made without asking, and where Python differs from Kotlin
_goes on keywords, onclose, on anEnum'sname/value, and on a method that shares a field's name (moq'sPosition.group). Python has no companion namespace to keep those apart._n.Object, and are freed when their last reference goes, or sooner byclose()orwith. A call after close raisesValueError("Counter is closed"). A call holds the handle, soclose()during an await frees it once the call ends.@dataclass(slots=True), mutable and compared by fields. Plain enums areenum.Enumwith UPPER_SNAKE members. Data enums are a base class with one dataclass per variant, as inEvent.Moved(p)andEvent.Done().Debugisrepr(), andDisplayisstr().bytesis read in place, and any other buffer is copied, as Python may change it while Rust reads without the GIL.memoryviewwith no 1 KiB copy threshold, andrelease()orwithfrees it.bytes.os.fsencodeencodes them. A Unix path that isn't UTF-8 round-trips through surrogateescape, where Kotlin throws.datetime.timedelta, truncated to the microsecond. Anything pasttimedelta.maxreturns astimedelta.max, which passes back asDuration::MAX.sockettuples.(host, port)is IPv4, and(host, port, flowinfo, scope_id)is IPv6.OverflowError.boolis strict.<E>Exception(error), withstr(e)being the error'sDisplay. AStringerror isStringException.awaitraisesRuntimeError. That is the concurrent-poller semantic.__getattr__or a class descriptor.build.rscallingpyo3_build_config::add_extension_module_link_args(), the upstream way to link an extension on macOS.Measured
Under heavy host load (load average 30 to 50 on 24 threads), so these are indicative.
Call overhead:
dropped()takes 122 ns released and 97 ns held.point.offset316 to 362 ns against 311 to 319 ns;rowswith 8 records 2.37 to 2.41 µs against 2.40 to 2.44 µs). abi3 stays.just ffi bench, three runs ofmoq/bench/export.pyagainst the same build's C, C++, and Kotlin, with BoltFFI's Python frombench/ffi/README.mdfor comparison (its numbers come from a different slice and host):track.is_closed())group.sequence())track.info())await yield_once())The woken read is an
ensure_future, asleep(0), a write, and the wake through the loop. asyncio alone spends about 9 µs of it, and the socket pair about 5 µs.Generated size for moq:
moq.pymoq.pywithout docstringsThe glue's Rust source is 1,815 KiB and ships compiled.
The export covers moq's crates whole, with every doc comment, rather than moq-ffi's hand-written surface.
import moqtakes about 100 ms with cached bytecode, half of itasyncio's own import.Impact
python/<prefix>.py. The export crate gains apythonfeature (pyo3-ffi, plus pyo3-build-config for its build script) and abuild.rs._-prefixed names, is an error that takes arename(MIGRATE.md).api-fixturegainsdoubled_ttl,corner,corner_later, andlog_later, so the Python can check durations at their bounds, borrowed tuples, and a future polled during its free.python3, and theuniffishell inherits it.Alternatives
ctypesorcffiover the C ABI: ruled out by the Plan, at 834 ns pernoop().call_soon_threadsafe: Rust's wake would take the GIL, from any thread and often under Rust's own locks. Rust code run holding the GIL, such as a drop in a dealloc, could then deadlock against it, and every wake would contend for the GIL.Follow-ups
just ffi standinandjust tssharets/target/moq, and pin it to different moq revisions. Running both at once in one worktree (just cibesidejust ffi bench) fails the export with a missing crate.Review
Reviewer: Codex, gpt-6.1-sol at medium effort, through
quest review.cea21c2b, the merge of main after Swift (feat(ffi): generate Swift over the C ABI, as a clang module #406) landed, with conflicts in the ffi README, MIGRATE.md, justfile,src/glue.rs,src/main.rs,tests/fixtures.rs, and the nightly workflow, resolved so both generators coexist: clean.24e4ddf8: buffers other thanbytesread without the GIL (now copied); aclose()during a poll freed the future under it (now deferred until the poll returns); a borrowed tuple's return split into one out per element (now whole, withcornerandcorner_later).b17d4532: Python run during a future's free could poll it with a null pointer (reproduced as an abort; the future now ends before the free releases the GIL, withlog_later).fb66b632: clean.just cipassed oncea21c2b, with api_python and api_swift, andjust ffi moqran all five sessions.🤖 Generated with Claude Code