py-cr v0.1.0
py-cr (temporary name)
A framework for writing Python extension modules in Crystal: compile Crystal code into a CPython-importable .so, the way PyO3 does it for Rust and nimpy for Nim. The framework is library-agnostic: any Crystal code can be exposed, and src/demo.cr is just one example module used as the framework's test surface.
Install
From the GitHub release (Linux x86_64):
- CPython 3.11-3.14:
pip install <regular wheel URL from the release> - Free-threaded CPython (3.13t/3.14t):
pip install <cp313.cp314-cp313t.cp314t wheel URL>
The wheels bundle their own Boehm GC; no system Crystal or libgc required.
The annotation style
require "./pycr"
@[Pycr::PyClass("mymodule.Greeter")]
class Greeter < Pycr::PyObject
@[Pycr::PyNew]
def initialize(greetings : Int32 = 0)
@greetings = greetings
end
@[Pycr::PyMethod]
def greet(word : String = "hi") : Int32
@greetings += 1
end
@[Pycr::PyAttr] # read-only unless a matching setter exists
def greetings : Int32
@greetings
end
@[Pycr::PyRepr]
def describe : String
"Greeter(#{@greetings})"
end
end
Pycr.pyinit "mymodule" do
Pycr.pyfunction def greet_everyone(names : Array(String)) : String
"Hello, #{names.join(" and ")}!"
end
end
pyinit generates the PyInit_<name> fun, registers every subclass of Pycr::PyObject (both styles — there are no class lists to maintain), and collects the module's functions. pyfunction also accepts a Python-side name override: Pycr.pyfunction is_big, def big?(n : Int64) : Bool.
The block style
Pycr.pyclass Counter, "mymodule.Counter" do
pynew def initialize(count : Int32 = 0)
@count = count
end
pymethod def increment(amount : Int32 = 1) : Int32
@count += amount
end
pyattr count : Int32 # read/write: emits a property and getset
pyrepr def describe : String
"Counter(count=#{@count})"
end
end
What the boundary does
- Conversions:
String,Bool,Int32,Int64,Float64,Nil(None),Bytesin and out,Array(T)in and out,Hash(K, V)in and out,Tupleout,NamedTupleto dict,Pycr::Callable(any Python callable, invocable from Crystal, storable: owned throughPycr::PyRef— release callables you do not store, and the decref is deterministic; the PyRef finalizer is the safety net otherwise; keyword invocation viacall(name: value), positional and keyword cannot be mixed), and rawPy::Objectas an escape hatch. Argument types come from the Crystal signatures; wrong types raise PythonTypeErrors. - Keyword arguments: every declared argument of a pyfunction, pymethod or pynew can also be passed by keyword (
ParseTupleAndKeywords; trailing defaults become optional positionals). - Exceptions: marshalled at the boundary —
ArgumentError→ValueError,TypeCastError→TypeError,KeyError→KeyError,DivisionByZeroError→ZeroDivisionError, unmapped→RuntimeError— and C-API failures pass Python's own error through untouched. - Iteration, len and subscripts:
pyiter def each : Iterator(T)/@[Pycr::PyIter]make a class iterable from Python — eachiter()call yields an independent, lazy iterator; items convert through the iterator's element type.pylen def size : Int32/@[Pycr::PyLen]wirelen().pygetitem def [](i : Int32) : T,pysetitem def []=(i : Int32, v : T)andpycontains def has?(v : T) : Bool(or thePyGetItem/PySetItem/PyContainsannotations) wireobj[i],obj[i] = vandv in obj; deletion raisesNotImplementedError, and Crystal errors map normally (IndexErroretc.).pygetter def name : Tdefines a read-only attribute from a method;pycompare def cmp(other : T, op : Int32) : Boolwires all six comparisons (<<===!=>>=, withNotImplementedfallback for foreign types);pyadd/pysub/pymulwire+-*. Exposed instances convert back into Python objects, so plainpyfunctions can be factories (counter_from(9)returns a realCounter). Classes with bothpygetitem(integer key) andpylenalso support Python slices —wc[0:2],wc[::2],wc[::-1]— with endpoints normalized by CPython's ownPySlice_Unpack/AdjustIndices. Iterators keep the owner alive mid-iteration and survive GC cycles. - Strings and reprs:
str()on any exposed class calls its Crystalto_s(overrideto_sto customize);repr()comes frompyrepr/@[Pycr::PyRepr]. - Ownership: Python-side instances hold a pinned pointer to the Crystal object; the pin registry (the only thing Boehm can see) keeps it alive, and
tp_deallocunpins. NUL-terminated strings that CPython keeps beyond a call (capsule names, method tables) are rooted the same way. - Scheduler: Crystal's fiber scheduler, event loop and IO work on the importing thread (
sleep,spawn, channels, sockets, files). Park fibers underPycr.release_gilso other Python threads keep running. Foreign Python threads cannot enter the scheduler - they raise a cleanRuntimeError(same as stock Crystal 1.21 user threads); compute-only calls work everywhere.Pycr::Bridge.run { ... }lifts that restriction for opted-in functions: execution contexts are pluggable, soPycr::AdoptingContextenrolls the calling thread (public EC setters, single-fiber Isolated semantics - suspend blocks the thread in its own event loop) and the block runs with the GIL released. Nospawninside bridge blocks (it routes to the default EC; use blocking IO). Seenotes/scheduler-spike.md. - Threads and GC: automatic Boehm collection is disabled; every Python thread registers itself with Boehm on entry and unregisters at exit, and all collections go through one mutex-serialized entry point triggered by an allocation-debt counter. Concurrent collectors, allocators and GIL-released sections coexist (see
notes/boehm-vs-cpython-threads.mdfor why each piece is load-bearing).Pycr.release_gil { ... }drops the GIL around long Crystal work. - Waiting: use
Pycr.sleep_seconds, never Crystal'ssleep— the fiber scheduler cannot start inside CPython (see notes).
Status
| Layer | Status |
|---|---|
CPython dlopens a Crystal-built .so and runs PyInit_* |
working, tested |
Crystal calls back into libpython (symbols resolve from the host process, no -lpython) |
working, tested |
| Typed conversions (see above) | working, tested |
| Exception marshalling with mapping table | working, tested |
pyinit/pyfunction/pyclass DSL + annotation style, kwargs, name overrides, pyattr |
working, tested |
| GIL release around long work | working, tested (ticker thread runs ~7M iterations during nap) |
| Crystal scheduler (sleep, fibers, channels, IO) on the importing thread, parks under GIL release | working, tested |
Iteration protocol: pyiter/pylen (block + annotation styles), lazy, concurrent-safe |
working, tested |
Subscript protocols: pygetitem/pysetitem/pycontains (block + annotation styles) |
working, tested |
Slices over pygetitem + pylen (PySlice_Unpack/AdjustIndices) |
working, tested |
| Scheduler bridge: foreign-thread scheduler access via AdoptingContext (sleep/IO/exceptions from any thread, GIL released) | working, tested |
Factories (exposed instances from pyfunctions), pygetter, pycompare, pyadd/pysub/pymul, PyRef storage |
working, tested |
| Boehm GC under CPython threading: registration at entry, unregistration at exit, serialized collections | working, tested (concurrent collectors + allocators + spinner + napper) |
| Pin/unpin registry for cross-runtime ownership | working, tested |
Known limitations
- Scheduler access is thread-affine by default: foreign Python threads must go through
Pycr::Bridge.runfor scheduler work (sleep, blocking IO); the block runs GIL-released and must not touch Python objects. Direct scheduler entry from a foreign thread without adoption raises a cleanRuntimeError.Pycr.sleep_secondsis the scheduler-free wait. Pycr.heap_sizetakes libgc's internal lock: do not call it from one thread while another collects.- Macro-emitted annotations lose their arguments in Crystal 1.21, so the block DSL passes names as macro arguments instead of emitting
@[PyClass]annotations. - No packaging story yet (per-CPython-version wheels, abi3), no buffers/bytes conversions, no Python-side subclassing of exposed classes.
Build and test
./build.sh
python3 test/test_pycr.py
The full suite passes on CPython 3.14 and 3.11 with the same binary: the module leaves the Py* symbols undefined and the loading interpreter resolves them, and every struct/constant the bindings mirror by hand was verified identical in 3.11's and 3.14's headers (typeslots, PyMethodDef, PyGetSetDef, PYTHON_API_VERSION 1013, Py_TPFLAGS_DEFAULT). New CPython minors should be checked against that list before being trusted.
build.sh compiles src/demo.cr (swap in your own module file) and exists because crystal build --link-arg=-shared cannot work on Linux right now: Crystal mangles some symbols with @, and lld, bfd and gold all misread an @ in an exported symbol name as a symbol-version separator. The script emits the program as a single object file, localizes the @-mangled symbols with objcopy, and links the shared object by hand, leaving the Py* symbols undefined so the interpreter resolves them (as C extensions do on Linux).
The demo module is importable as pycr from the repo root.
Example: examples/tartrazine
The tartrazine syntax highlighter wrapped as import tartrazine — 2.2 MB release binary, ~4 ms import, 388 themes, a Lexer class plus highlight()/tokenize()/themes() functions.
Build and benchmark it:
cd examples/tartrazine
./build.sh # NOTE: builds with --release; required for the documented performance
python3 test_tartrazine.py
python3 bench.py # timeit-based; compares against pygments if installed
Release-build performance (timeit best-of-5, per call, real stdlib inputs; pygments 2.18.x for comparison):
| input | lines | tartrazine | pygments | speedup |
|---|---|---|---|---|
| small | 20 | 0.124 ms | 0.283 ms | 2.3x |
| medium | 300 | 0.827 ms | 9.148 ms | 11.1x |
| dataclasses | 1813 | 4.971 ms | 56.336 ms | 11.3x |
| large | 5689 | 16.996 ms | 205.301 ms | 12.1x |
Debug builds are roughly 5x slower — always ship extension modules with --release.
Building from source
The wheel is assembled by packaging/build_wheel.py (see below).
Packaging
The pycr module ships as a self-contained wheel:
./build.sh
python3 packaging/build_wheel.py
pip install dist/pycr-*.whl
The wheel bundles the compiled extension (pycr/pycr.so, linked with $ORIGIN rpath) and libgc.so.1, and carries multi-version tags — one wheel installs on CPython 3.11 through 3.14 (the module resolves Py* symbols from the loading interpreter; both 3.11 and 3.14 are regression-tested against the same binary). Platform is linux_x86_64 (glibc); macOS and musl are future work. Clean-venv installs are verified, including subclassing, factories, and the foreign-thread bridge from the installed package.
CI (.github/workflows/ci.yml) runs the full test battery on CPython 3.11 and 3.14, lint (ameba + format check), and builds the wheel as an artifact on every push.
Roadmap
PyRefas the general storable reference everywhere (works for arbitrary objects via remember/recall-style APIs).- Attributes with getters only in annotation style are done; block style has
pygetter. - Scheduler bridge scale-up: multi-fiber AdoptingContext (spawn inside bridge blocks, currently routed to the default EC per Isolated semantics); context lifecycle on foreign-thread death.
- Packaging: manylinux compliance (vendor bdwgc statically or auditwheel-repair), macOS + musl builds, sdist with a Crystal toolchain fallback.
License
MIT
py-cr
- 1
- 0
- 0
- 0
- 0
- about 5 hours ago
- September 28, 2026
Tue, 29 Sep 2026 14:51:45 GMT