Quillmark: Python bindings
Python bindings for Quillmark, a schema-driven document engine.
Maintained by TTQ.
Installation
pip install quillmark
The package is typed: it ships py.typed and stubs for the whole surface.
Quick Start
from quillmark import Quillmark, Quill, Document, OutputFormat
engine = Quillmark() # backend registry + render dispatcher
quill = Quill.from_path("path/to/quill") # portable, declarative config data
markdown = """~~~
$quill: my_quill
$kind: main
title: Hello World
~~~
# Hello
"""
parsed = Document.from_markdown(markdown)
result = engine.render(quill, parsed, OutputFormat.PDF)
result.artifacts[0].save("output.pdf")
API surface
Field I/O flows through quill.writer(doc) and quill.reader(doc), the
schema-bound write/read front doors. Document carries the quill-free surface:
parse, storage, structure, $ext / $seed, and remove_field. Names follow
snake_case, and the shared model (the Document / Card shapes,
Diagnostics, the storage DTO) is identical to @quillmark/wasm's.
Quillmark
engine = Quillmark()
engine.registered_backends() # ['typst', 'acroform'] (order not guaranteed)
engine.render(quill, parsed, OutputFormat.PDF) # ppi=, pages=, regions= optional
# today=datetime.date, default the local date
engine.supported_formats(quill) # [OutputFormat.PDF, ...] (raises if backend unregistered)
Quill
quill = Quill.from_path("path/to/quill") # pure config load: no backend resolved here
quill.backend_id # "typst" (declared backend)
quill.blueprint # auto-generated annotated Markdown blueprint
quill.schema # structured dict of the quill's document schema
quill.metadata # `name`, `version`, `backend`, `author`, `description`, extras sorted
quill.quill_ref # "name@version"
doc = quill.parse(markdown) # the bound door: parse + conform, the primary ingestion path
diags = quill.conform(doc) # the same walk in place on a transported document ([] = at rest)
diags = quill.validate(parsed) # list of validation::* diagnostic dicts ([] = valid)
empty = quill.empty_document() # the empty Document: $quill + $kind: main, nothing committed
seed = quill.seed_document() # starter Document: one card per kind, each its kind's seed
example = quill.example_document() # the quill's made-up filled-in page (example.md), pinned; None without one
main = quill.seed_main() # just the $kind: main card (dict, like doc.main)
card = quill.seed_card("note") # one starter composable card (dict), None if kind undeclared
writer = quill.writer(doc) # schema-bound typed write front door
reader = quill.reader(doc) # schema-bound interpreted read front door
Writer: quill.writer(doc)
Resolves each field's type from the bound quill, so a name the schema does not
declare is a typo (UnknownField), not a fallback. Holds both handles by
reference and owns neither: bind, write, discard.
w = quill.writer(doc)
w.set("title", "On Taro") # typed-commit one field (mismatch raises now)
w.set_all({"title": "T", "author": "A"}) # atomic batch; one diagnostic per bad field
w.revise_body("A **taro** essay.") # body write (edit semantics; a body has no field schema)
w.revise_field("bio", "make it **bold**") # typed *and* anchor-preserving content write (codec by declared type)
w.add_card("quotes", {"author": "Basho"}, "…", at=None) # make + typed commit + insert (at appends/inserts)
w.remove_card(0)
w.set("author", "Issa", card=0) # every verb takes card=: None is main, an int the composable card
Reader: quill.reader(doc)
The read twin of Writer. get reads each field in the values form: every
content leaf in its type tree as its codec's text (a richtext leaf to markdown, a
plaintext leaf to its literal text), everything else as stored, never coerced.
get_content is the same read at the other end of the codec, handing back the
field's Content as a dict whichever lane stored it.
v = quill.reader(doc)
v.get("bio") # richtext → markdown str; scalar → as stored; absent or present-null → None
# undeclared name raises UnknownField; undecodable content raises FieldDecode
v.get_content("bio") # the `Content` dict {text, lines, marks, islands}; absent → None
# a type that is not a content leaf raises FieldNotContent
v.body_markdown() # the main body markdown (quill-free body read)
v.get("author", card=0) # a card field, interpreted by its $kind schema
v.body_markdown(card=0)
doc.card(0)["kind"] # the composable card's $kind
RenderResult / Artifact
result.artifacts # [Artifact, ...]
result.warnings # [Diagnostic, ...]: parse, then quill.validate, then compile
result.format # OutputFormat
artifact.format # OutputFormat
artifact.bytes # bytes
artifact.mime_type # 'application/pdf', 'image/svg+xml', ...
artifact.save("out.pdf")
Document
doc = Document("my_quill") # blank canvas: $quill only, no fields, no cards
doc = Document.from_markdown(markdown)
emitted = doc.to_markdown()
stored = doc.to_stored()
restored = Document.from_stored(stored)
Document.storage_version_of(blob) # raw tag (incl. unknown futures); None when not a DTO
Document.current_storage_version() # what this build writes
Document.markdown_rules() # Quillmark Markdown rules (static text)
Document.quill_ref_hint() # $quill reference grammar (static text)
doc.clone()
doc.equals(other)
doc.card_count
doc.main; doc.cards; doc.body; doc.warnings # total-read snapshots (dicts); body is a content dict
doc.card(0) # one card, same dict shape as main (out of range raises)
doc.seed_overlay("note") # one $seed[kind] overlay, or None
doc.set_quill_ref("other@1.0")
# Structure (quill-free, a card kind is a name, not a schema fact):
doc.insert_card({"kind": "note", "body": "..."}, at=None) # at appends/inserts
doc.insert_card({"kind": "note", # fields → payload items
"payload_items": [{"type": "field", "key": "x", "value": 1}]})
doc.remove_card(0) # returns the Card dict, or None
doc.move_card(2, 0)
doc.remove_field("title") # remove has no lane; card=i targets a composable card
# Out-of-band consumer state (never rendered):
doc.store_ext({"agent": {"pinned": True}}) # whole $ext map; card=i for a composable card
doc.store_ext({**doc.main["ext"], "agent": {"n": 1}}) # one namespace: merge, then store
doc.remove_ext() # every namespace at once
doc.store_seed_overlay("note", {"tag": "T"}) # per-kind $seed overlay; new cards spawn with it
doc.remove_seed_overlay("note")
Setting a field's value is the writer's job (quill.writer(doc).set(...)): a
field write needs the schema, and Document is quill-free. Reading a field's
interpreted value is the reader's (quill.reader(doc).get(...)).
Schema model
A field declares no required: key on FieldSchema: nothing is required.
default: — what an unanswered field renders. With one, the blueprint
renders that value under a type-only # <type> annotation and the render path
uses it when the document omits the field. Without one, the blueprint leaves the
cell empty and an absent field blank-fills.
An unanswered field draws no diagnostic. Partial documents are accepted, and
engine.render(quill, doc) raises only for malformed input.
Error contract
An engine refusal raises QuillmarkError, carrying a non-empty .diagnostics
list of Diagnostic objects. An argument the binding cannot read at all — a
non-finite float, a value with no JSON form, a malformed path sequence —
raises ValueError before the engine is called, described by no diagnostic.
try:
Document.from_markdown(bad_md)
except QuillmarkError as exc:
for d in exc.diagnostics:
print(d.severity, d.code, d.message, d.path)
print(str(d)) # canonical pretty-printed text (matches CLI / WASM)
Mutator failures (invalid field names, kind names, out-of-range indices) carry a
namespaced edit::* code on diagnostics[0]: edit::invalid_field_name,
edit::unknown_field, edit::index_out_of_range, edit::field_coercion_failed,
…. insert_card refuses a card's contents under those same codes.
Route on diagnostics[0].code, never on message text.
Card and page indices count from the front, so a negative one addresses nothing
rather than the last card or page: it takes the same answer as an index past the
end — edit::index_out_of_range where the verb raises, None where
remove_card answers absence.
Each refusal anchors at a rooted document path: main.<field> for a main field,
cards.<kind>[<i>].<field> for a card field, cards[<i>] for a structural
out-of-range op.
Changelog
See the changelog and the GitHub Releases page for release notes and version history.
Development
uv venv
source .venv/bin/activate
uv pip install maturin pytest
maturin develop
pytest
maturin develop builds the extension in the debug profile. Stay in the
activated venv: uv sync, uv run, and pip install -e each sync the project
through maturin's PEP 517 backend, a release build that replaces the debug
.so.
python/quillmark/_quillmark.pyi is hand-written and must track src/. No CI
job gates it, so after changing the surface run the stub against the built
module yourself:
uv pip install mypy
python -m mypy.stubtest --ignore-disjoint-bases quillmark
License
Apache-2.0
Metadata
Release files for quillmark 0.120.0
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 |
|---|---|---|---|---|
| quillmark-0.120.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| quillmark-0.120.0-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
Total release size: 33.3 MB
Release files / quillmark-0.120.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | quillmark-0.120.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 17.3 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
ba142376d96b8bff61ecf9df960a4044e9ed5a6e9b7e6b0bd3793daa315667b7
|
|
BLAKE2b-256 checksum How to use checksums |
76120eac199d6d759c99036c7d12730831a1c5c72513d6d5357951167ba4e55a
|
| 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 30, 2026.
Transparency logRelease files / quillmark-0.120.0-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | quillmark-0.120.0-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 16.0 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
39c15b3d1870c681ff5c538b04a48c3025a79d27071ee1ed0ccfcd54cab82d45
|
|
BLAKE2b-256 checksum How to use checksums |
6e93d69a51c376540804ce48aca4b2c345cd5dd576bc7388de653c91d8abe9b7
|
| 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 30, 2026.
Transparency logSecurity for quillmark 0.120.0
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for quillmark-0.120.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
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 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.