cooklang-bindings (Python bindings for Cooklang)
Python bindings for cooklang-rs, the official Rust implementation of the Cooklang recipe markup language.
This is a binding to cooklang-rs, not a fork of it. It contains no parser
logic. Everything under src/cooklang/_generated/ is produced by
UniFFI from the interface upstream already maintains — the same
interface behind their Swift and Kotlin bindings — and the rest is a thin
Pythonic layer over that output.
import cooklang
recipe = cooklang.parse(source)
recipe.title # "Sourdough"
recipe.servings # 4
recipe.metadata # {"title": "Sourdough", "servings": 4, ...}
recipe.method # ("Mix flour.", "Rest it for 12 hours.")
recipe.sections[0].name # "Prep"
recipe.notes[0].text # "Use a warm room."
recipe.ingredients[0].name # "flour"
recipe.ingredients[0].quantity # Quantity(value=500, unit="g", text="500 g")
Full documentation, including guides and a complete API reference, is built
with make docs and configured to deploy to Read the Docs.
Install
pip install cooklang-bindings
The distribution is cooklang-bindings, mirroring the name of the upstream
crate it packages; the import is cooklang.
Wheels ship a prebuilt native library, so installing needs no Rust toolchain.
UniFFI's Python output drives the library through ctypes rather than the
CPython C API, so a wheel carries no Python ABI tag at all — one
py3-none-<platform> wheel per platform covers every supported interpreter.
That is a weaker constraint than abi3, which would still pin a minimum CPython.
Supported platforms
| Platform | Wheel tag | Notes |
|---|---|---|
| Linux x86_64 | py3-none-manylinux_2_28_x86_64 |
glibc 2.28+ (RHEL 8, Debian 10, Ubuntu 18.10+) |
| macOS arm64 | py3-none-macosx_11_0_arm64 |
Apple Silicon, macOS 11+ |
Python 3.10+; built and tested on 3.14. Other platforms must build from source.
Upstream version
These bindings are pinned to cooklang-rs v0.18.7 (crate cooklang-bindings
v0.18.7), included as a git submodule at vendor/cooklang-rs and used
unmodified. The pin is deliberate: reproducible builds matter more here than
tracking tip.
cooklang.UPSTREAM_VERSION reports it at runtime, and CI fails if that
constant and the submodule tag ever disagree.
To move to a new upstream release, bump the submodule to the new tag, update
UPSTREAM_VERSION, and run the test suite — it is written against parser
behaviour, so it will catch a regression in the bump.
Build from source
Requires a Rust toolchain, Python 3.10+, and git.
git clone --recurse-submodules https://github.com/destos/cooklang-bindings.git
cd cooklang-bindings
make generate # build the cdylib, generate the Python bindings
make test # run the suite against the working tree
make wheel # build a wheel for this machine
make wheel-linux builds the manylinux x86_64 wheel in Docker, using the same
recipe as CI. On an Apple Silicon host it runs under emulation — correct, but
slow.
Generated artifacts are not committed. scripts/generate.py recreates them,
and make clean removes them.
API
parse(text, *, scale=1.0) -> Recipe is the entry point. scale multiplies
every quantity, so scale=2.0 doubles the recipe.
Recipe exposes:
| Attribute | Type | |
|---|---|---|
title, description |
str | None |
from metadata |
servings |
int | str | None |
int when the recipe gave a number |
tags |
tuple[str, ...] |
|
metadata |
Mapping[str, Any] |
standard keys under Python names (prep_time), custom keys verbatim |
sections |
tuple[Section, ...] |
one unnamed section for content before any heading |
steps, notes |
tuple[Step, ...] / tuple[Note, ...] |
flattened across sections |
method |
tuple[str, ...] |
just the step text |
ingredients, cookware, timers |
tuple[...] |
every occurrence, in document order |
Step has number, text (markup-free, components rendered inline), and the
ingredients / cookware / timers it uses. Section has name, blocks,
and steps / notes views. Quantity has value (an int for whole numbers,
float, str, or None), unit, and text — upstream's own rendering, which
keeps fractions as 1/2 rather than 0.5. Use text for display and
value/unit for arithmetic.
Timers render inline as their duration rather than their name, because that is
what reads correctly in prose: Boil for ~eggs{3%minutes} becomes
Boil for 3 minutes. A timer written without a duration falls back to its name.
combine_ingredients(ingredients, *, indices=None, aisle=None) totals repeats,
letting upstream do the unit arithmetic: two @salt{2%tsp} and @salt{3%tsp}
mentions become one 5 tsp. Amounts in units that cannot be added stay separate
under the same name. indices totals only a subset; aisle resolves names
through an aisle config first (see below).
All model types are frozen dataclasses holding no FFI objects, so they compare, hash and pickle normally.
Aisle configuration and common names
An aisle config groups ingredient names and their aliases into shopping
categories and gives each group one canonical name. That is what makes totals
across recipes trustworthy: without it, @onions{1} in one recipe and
@brown onion{2} in another are two different ingredients.
config = cooklang.parse_aisle_config("""
[produce]
onion|onions|brown onion
fennel
[dairy]
butter|unsalted butter
""")
config.common_name_for("Brown Onion") # "onion" (case-insensitive, matches aliases)
config.category_for("butter") # "dairy"
config.categories # in config-file order
recipe = cooklang.parse("Add @onions{1} and @brown onion{2}.")
cooklang.combine_ingredients(recipe.ingredients, aisle=config)
# {"onion": (Quantity(value=3, unit=None, text="3"),)}
common_name_for never returns None — an ingredient the config does not list
comes back unchanged, so it is safe to apply across a whole list.
group_by_category(names) buckets names for rendering, keeping config order and
collecting anything unlisted under None rather than dropping it.
apply_common_names(totals) does the same normalisation on totals you already
computed, merging quantities that collapse onto one name.
Shopping lists and the checked log
Two more formats upstream parses. A .shopping-list holds recipe references
(./Breakfast/Pancakes{2}, the ./ marking a reference and the braces scaling
it) and free-hand ingredients (salt{1%tsp}), nested by two-space indents:
shopping = cooklang.parse_shopping_list("./Breakfast/Pancakes{2}\nsalt{1%tsp}\n")
shopping.recipes[0].path # "Breakfast/Pancakes" (the ./ is stripped)
shopping.recipes[0].multiplier # 2.0
shopping.ingredients[0].name # "salt"
shopping.to_text() # round-trips back to the file format
A .shopping-checked file is an append-only log of + name / - name entries.
Replaying it gives the currently-checked set, with later entries winning:
entries = cooklang.parse_checked_log("+ salt\n+ pepper\n- salt\n")
cooklang.checked_names(entries) # ("pepper",)
cooklang.compact_checked_log(entries, ["pepper"]) # drops stale entries
compact_checked_log wants the aggregated ingredient names the user actually
sees. A .shopping-list on disk holds only recipe references, so expand those
first — passing the raw list's own names would discard every entry as stale.
Value helpers
parse_value reads a quantity the way upstream does, and format_value renders
one back. Useful when a user types an amount and you want Cooklang's reading of
it rather than float()'s:
cooklang.parse_value("1 1/2") # 1.5
cooklang.parse_value("1/2 - 3/4") # Range(start=0.5, end=0.75)
cooklang.parse_value("a pinch") # "a pinch" (text, not an error)
cooklang.format_value(0.5) # "1/2"
Upstream coverage
Every function upstream exports is reachable. tests/test_ffi_surface.py holds
a _COVERAGE map naming how each of the 28 is reached, and a test that fails if
upstream adds or removes one.
The four deref_* functions are the only ones with no Python wrapper: the model
resolves every reference eagerly at parse time, so a caller never holds an
unresolved reference to dereference. They are still exercised by the test suite,
and reachable on cooklang._ffi.ffi if you want them.
Errors
Cooklang is forgiving and nearly any text is a valid recipe: malformed metadata
and unclosed markup parse to whatever upstream decides they mean rather than
raising. CooklangError covers an outright parser failure; parse raises
TypeError for non-str input.
Tests
tests/test_defects.py is the acceptance suite. It has one test class per
defect that drove this project, each using the exact input that failed against
the previous parser:
== Section ==glued onto the following step's text.> notelines rendered as numbered steps, angle bracket included.- YAML front matter silently discarded.
- Consecutive
@ingredientlines rendered with their names run together.
Defects 1 and 2 are structurally impossible against this parser — upstream models sections and notes as first-class types rather than reconstructing them from text — but they are tested anyway, because the point is the guarantee, not the mechanism.
tests/test_acceptance.py is the end-to-end check, and tests/test_api.py
covers the Python layer.
make test
Syntax extensions
cooklang-rs parses a superset of canonical Cooklang — aliases,
modifiers, range values and more. These bindings parse canonical Cooklang
only, and there is no way to enable the extensions, because upstream's UniFFI
binding hardcodes CooklangParser::canonical() and parse_recipe takes no
parser-mode argument. Swift and Kotlin share the constraint; it is not
Python-specific.
That matters more than a missing feature normally would, because Cooklang is
forgiving: unrecognised syntax is read as text rather than rejected, so extended
markup lands inside your data instead of raising.
@onion|onions{1} parses to an ingredient literally named onion|onions, and
@&onion{1} to one named &onion. @flour{10 kg} (no %) becomes the text
amount "10 kg" rather than 10 + kg. Canonical mode also uses an empty unit
converter, so 1%kg and 500%g are not converted before totalling.
Supporting the superset needs an upstream change — an exported parse function
taking extension flags. See docs/extensions.md for the detail, and
UPSTREAM_ISSUES.md for a ready-to-file issue draft.
Known upstream gaps
Records used as map keys are unhashable in Python. UniFFI's Python backend
emits records as classes with a generated __eq__ and no __hash__, which
Python turns into __hash__ = None. Upstream returns
HashMap<GroupedQuantityKey, Value>, so the generated converter tries to use an
unhashable object as a dict key and combine_ingredients() and
use_common_names() fail with:
TypeError: cannot use 'GroupedQuantityKey' as a dict key
This is a UniFFI Python-target gap, not an upstream bug: the Rust type derives
Hash + Eq, and Swift and Kotlin get structural hashing for free, which is why
only Python trips over it. cooklang/_ffi.py restores a consistent
__hash__ on the generated class at import, matching upstream's own derive.
It is three lines, it touches no parser logic, and it is covered by
TestCombineIngredients so a future UniFFI release that fixes this upstream
will not break us silently. Draft in
UPSTREAM_ISSUES.md; not yet filed.
No way to enable syntax extensions. parse_recipe hardcodes
CooklangParser::canonical(), so the whole extension set is unreachable and
extended syntax is silently absorbed into ingredient names. This is the one gap
worth raising upstream: it needs an exported parse function that accepts
extension flags. Draft in UPSTREAM_ISSUES.md; not yet
filed.
Ranges are an extension, not canonical. Upstream's
CooklangParser::canonical() has range extensions off, so @onion{1-2} parses
as the text amount "1-2", not a numeric range. The Range type is kept in the
model because the FFI can express it, and a test asserts the current behaviour
so an upstream change surfaces here rather than silently changing a consumer's
types.
Licence
MIT, matching upstream. Distributed wheels contain a compiled copy of the MIT-licensed cooklang-rs. See LICENSE and NOTICE.
Release files for cooklang-bindings 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cooklang_bindings-0.2.1-py3-none-manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.28+ x86-64 | Details |
| cooklang_bindings-0.2.1-py3-none-manylinux_2_28_aarch64.whl | Python 3 | none | Linux glibc 2.28+ ARM64 | Details |
| cooklang_bindings-0.2.1-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
Total release size: 3.7 MB
Release files / cooklang_bindings-0.2.1-py3-none-manylinux_2_28_x86_64.whl
| Download URL | cooklang_bindings-0.2.1-py3-none-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 1.7 MB |
| Tags | Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
5b6e9b070b9b0fbe454caa350bc5315712ed04acb39b4d7f388b6fc4a765898a
|
|
BLAKE2b-256 checksum How to use checksums |
2212a7314c9cee706c057be89a10f36b11b2b35c5744d9c5f73dfcb2b6f05fb6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.
Transparency logRelease files / cooklang_bindings-0.2.1-py3-none-manylinux_2_28_aarch64.whl
| Download URL | cooklang_bindings-0.2.1-py3-none-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 1.4 MB |
| Tags | Linux glibc 2.28+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
8e460f1e1622f1ad48a5734f09e69a3ca25327d32de231587d4901ac2c306d0f
|
|
BLAKE2b-256 checksum How to use checksums |
1af7642bccbb75e4d321e89c31c62318f42ab1421ed92015af1d08d064f96184
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.
Transparency logRelease files / cooklang_bindings-0.2.1-py3-none-macosx_11_0_arm64.whl
| Download URL | cooklang_bindings-0.2.1-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 606.3 kB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
f68b0a60a88a4e22d7fad6e4cef9cf44b83900d5aa72bb6c5c45d9d1d749183c
|
|
BLAKE2b-256 checksum How to use checksums |
ee385267347914238f3d97c1c200127d7d77728d36d9076a66ceb21aa6d97a0e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.
Transparency log