carve (Python binding)
Native Python bindings for the Carve markup language. This package is a thin PyO3 binding over the Rust implementation carve-rs, so the parser is not reimplemented in Python: every conversion delegates to the same engine the Rust CLI and WASM builds use. Output is byte-identical to carve-rs for the same input.
This unlocks the Python docs / data ecosystem (MkDocs, Sphinx, Pelican, Jupyter/nbconvert) for Carve.
Install
Wheels are abi3 (abi3-py38), so a single wheel covers CPython 3.8+.
From a built wheel:
pip install carve-lang
From source (needs a Rust toolchain, 1.75+):
pip install maturin
maturin develop --release # build + install into the active venv
# or
maturin build --release # produce a wheel under target/wheels/
Usage
import carve
print(carve.__version__)
# Core conversion (no extensions)
html = carve.to_html("# Hello *world*")
# -> '<section id="Hello-world">\n <h1>Hello <strong>world</strong></h1>\n</section>'
# Inline emphasis: /italic/ and *bold*
carve.to_html("/italic/ and *bold*")
# Enable opt-in extensions by name
html = carve.to_html(source, extensions=["math_block", "list_table"])
# Configure enabled extensions (the mapping is keyword-only)
html = carve.to_html(
source,
extensions=["heading-permalinks", "tabs"],
extension_options={
"heading-permalinks": {"aria_label": "Back to text", "lowercase_ids": True},
"tabs": {"mode": "aria"},
},
)
# Dedicated explicit-list variant
html = carve.to_html_with_extensions(source, ["autolink"])
# Map `:name:` symbols to their values
carve.to_html("Ship it :rocket:", symbols={"rocket": "🚀"})
# -> '<p>Ship it 🚀</p>'
# Engine-level options are keyword-only. Omitting one preserves its engine default.
carve.to_html("# Mixed Case", lowercase_heading_ids=True)
carve.to_html("Hello @ada", mention_url="https://example.com/{}")
# Other renderers. They take the same engine options and `extension_options`.
carve.to_markdown(source)
carve.to_plain_text(source)
carve.to_ansi(source, lowercase_heading_ids=True)
carve.to_carve(source)
# `strict` turns a writer refusal into a `ValueError` instead of returning the
# source unchanged.
carve.to_carve(source, strict=True)
# Migration returns canonical Carve plus a machine-readable loss report.
carve.from_html('<p>Hello <strong>world</strong></p>')
carve.from_markdown('*em* and **strong**')
# Discover supported extension names
carve.extensions()
Passing an unknown extension name raises ValueError.
extension_options only accepts enabled extension names and their documented
option keys; unknown names, unknown keys, invalid enum values and options for an
extension that takes none are rejected instead of silently using defaults.
The names come from the engine itself, so carve.extensions() is the list this
build actually accepts rather than a list documented here that could fall behind
it. They are kebab-case (math-block, table-of-contents); the snake_case
spellings this binding has always taken (math_block) reach the same
extensions, so nothing written against the older names has to change.
Linting
carve.lint reports constructs that parse and render, but not the way the
author meant. The defect class it catches is the silent one: the document
parses, the renderer emits something, and what the author wrote never reaches
the page.
import carve
for warning in carve.lint("{#orphan .cls}\n\n"):
print(warning["line"], warning["rule"], warning["message"])
1 unattached-block-attribute This block attribute reaches no block: ...
That one is the clearest case - {#id .cls} above a blank line attaches to
nothing, so the id and the class vanish and nothing anywhere says so.
Each warning is a dict:
| Key | What |
|---|---|
line, column |
1-based, for reporting |
rule |
a stable id, shared with carve-js and carve-php - the same trigger reports the same id in every engine |
message |
what degrades, in prose |
start, end |
codepoint offsets into the source, 0-based, end exclusive |
start and end slice the offending text directly:
source = "{#orphan .cls}\n\n"
warning = carve.lint(source)[0]
assert source[warning["start"]:warning["end"]] == "{#orphan .cls}"
They are CODEPOINT offsets, not the byte offsets the Rust API reports. That
conversion is deliberate and tested: a Rust caller slices &str with bytes, but
Python slices by codepoint, so passing them through unconverted mis-slices every
document carrying one non-ASCII character before a warning. The unit follows the
host language - carve-js converts the same positions to UTF-16 for the same
reason.
extensions is the only option accepted, because it is the only one the
engine's linter reads:
carve.lint(source, extensions=["details"])
The parsed AST
carve.parse() returns the document as Python data - the PART 12 exchange
shape, the same tree every Carve
engine publishes, so a consumer written against one implementation reads
another's output.
ast = carve.parse("# Title\n\nBody[^a].\n\n[^a]: note\n")
ast["type"] # "document"
[c["type"] for c in ast["children"]] # ["heading", "paragraph", "footnote"]
ast["children"][0]["pos"] # {"startLine": 1, "startColumn": 1, ...}
carve.parse_json(source) # the same tree, as JSON bytes
The root carries exactly type, children and srcByteLength; frontmatter and
footnote definitions are block nodes inside children, not root fields. Every
node except the root carries pos when the engine could place it - 1-based
lines and columns, 0-based offsets, ends exclusive, counted in Unicode
codepoints, not bytes. A node the engine could not place, such as
reassembled table-cell text, carries no pos at all rather than an invented
one.
The serialization is the engine's own, so this binding publishes byte-identical
output to the carve --json CLI and to every other binding over carve-rs.
Every rendering entry point (to_html, to_html_with_extensions, to_markdown,
to_plain_text, to_ansi, parse and parse_json) also accepts keyword-only
lowercase_heading_ids, positions, sections, source_lines, mention_url,
tag_url, and profile_base_host. Boolean options accept True or False;
URL/host options accept strings. When omitted, each option retains the engine's
own default.
The keyword set is the same on every target so a host does not have to remember
which target takes which. Most of these options describe HTML markup, so on
to_markdown / to_plain_text / to_ansi they have nothing to change;
lowercase_heading_ids does change those three, because all four renderers
resolve </#id> crossrefs through the same heading index.
Includes
carve.render_with_includes() renders a file-backed document with its
{{ path }} includes expanded, contained to a root you name:
import carve
result = carve.render_with_includes(
open("book/main.crv").read(),
"/srv/book", # containment root, absolute
source_path="main.crv", # or absolute; relative is read from the root
)
result["output"] # the rendered HTML
result["dependencies"] # every target touched, for a watcher
result["warnings"] # what degraded, and where
The string entry points (to_html and friends) take no root and expand
nothing - a directive stays literal there, so a caller who never opted in never
touches the filesystem.
The root must be absolute. A relative value names no root: every
canonicalizer resolves one against the process working directory, which is
arbitrary with respect to the document, so include_root=".." would silently
root containment at the parent of wherever the build ran. It raises
ValueError instead. Absolutize it yourself if you have a relative directory
in hand, where the decision is visible.
Nothing resolves outside the root. A ../ climb, a symlink pointing out, and
an absolute path are all refused after canonicalization, so a path that leaves
the root by any spelling is denied rather than read.
Each dependency is a dict:
| Key | What |
|---|---|
id |
the target, relative to the root |
resolved |
the source was read - not that it was merged |
denial |
the refusal class, or None: outside-root, not-found, no-root, include-denied, include-unresolved |
Refused targets are listed too. A host that watched only what it read would never learn that a missing target now exists, so the preview would stay stale at the moment the author fixes it.
Each warning carries rule, message and file (the document it arose in,
root-relative, or None for a root document given no source_path). The
engine reports a containment denial and a missing file alike as
include-unresolved, so a warning shown to a reader cannot be used to probe
the filesystem. suppressed_warnings counts the ones past max_warnings, so a
capped report is never read as a clean one.
target selects the renderer: "html" (default), "markdown", "plain" or
"ansi". extensions, profile, symbols, renderers and the engine
options work as they do on to_html, and an extension applies to an included
child exactly as it does to the parent. The budgets - max_depth, max_bytes,
max_resolver_calls, max_warnings and the per-file max_file_bytes - pass
through to the engine; omit one to keep its default.
Symbols
A :name: symbol renders its literal :name: source unless the name is in the
symbols map passed as the symbols= keyword (supported by to_html and
to_html_with_extensions):
carve.to_html("Ship it :rocket: :shrug:", symbols={"rocket": "🚀"})
# -> '<p>Ship it 🚀 :shrug:</p>' (an unmapped name stays literal)
The leading word-boundary guard is unaffected by an active map: a:b:c,
10:30: and me@example.com never become symbols.
Security: symbol values are TRUSTED RAW output. A mapped value is inserted into the output unescaped - the same trust class as a
rendererscallable.symbols={"b": "<b>x</b>"}emits a real<b>element, not escaped text. This is deliberate (processor configuration is trusted). Never build a symbols map out of untrusted / user-supplied input.
Untrusted input
Carve's normative hardening is always on and needs no argument: dangerous URL
schemes are blanked, event-handler attributes like onclick are dropped, and the
bidi override/isolate characters behind Trojan Source are removed from rendered
text.
Raw passthrough is the deliberate exception. A ```=html block or a
`…`{=html} span renders verbatim by design, so it is the one thing
input you did not author has to switch off:
html = carve.to_html(user_input, safe=True, profile="comment")
safe=True escapes those raw blocks and spans instead of emitting them. It is
HTML-only, because HTML is the only target that can emit live markup:
to_markdown escapes raw HTML, to_plain_text drops it, to_ansi keeps it as
terminal text.
profile restricts which constructs are allowed at all and caps input length -
"full", "article", "comment", "minimal", or None. It applies to every
target, including to_markdown / to_plain_text / to_ansi.
An unknown profile name raises ValueError, and so does a rejection - input
past the profile's max length, or a denied construct when the action is error:
carve.to_html("x" * 20_000, profile="minimal")
# ValueError: Profile violations: 'document' is not allowed: max_length_exceeded (...)
It raises rather than returning something that looks like output: the engine's infallible entry point answers a rejection with an empty string, which a caller cannot tell from a document that legitimately rendered to nothing.
An engine panic raises carve.EnginePanicError, an Exception subclass.
A panic means the engine reached a state it believed impossible, so the document
is not renderable, but the interpreter is unaffected and a host can answer the
request instead of losing the worker:
try:
html = carve.to_html(user_input, safe=True, profile="comment")
except carve.EnginePanicError as panic:
logger.error("carve engine panic: %s", panic)
html = "<p>This document could not be rendered.</p>"
Before it existed, PyO3 raised its own PanicException, which derives from
BaseException, so the except Exception a host actually writes walked past it
(#94). The message carries the panic text and its location in the engine source,
and the usual thread '<unnamed>' panicked at ... report still reaches stderr,
so RUST_BACKTRACE=1 works as before. Treat any input that raises it as an
engine defect worth reporting, not as a rejection: a rejection is a ValueError.
Full recipe, defaults and threat model: Security.
Stored documents and spec versions
carve fmt --stamp (in any Carve engine) records the spec version a document was
last processed under. This binding reads that marker back, so a repository of
stored .crv files can be checked for documents predating a breaking spec
change:
carve.read_stamp(source)
# {'version': '0.1', 'generated_by': 'carve-php 0.1.0'}
carve.needs_review(source) # True when the document predates this engine
An unstamped document answers True: its provenance is unknown, and assuming
it is current is the unsafe direction. Both marker forms are read, and a marker
written by any engine reads the same - the format is the contract, not any one
API.
What a version difference means is the
versioning contract: only
[behavior] changelog entries between the stamped version and yours can require
a document change.
Supported extensions
The string passed in extensions=[...] maps to a carve-rs extension:
| name | effect |
|---|---|
autolink |
turn bare URLs into links |
details |
collapsible <details> blocks |
external_links |
mark external links (rel/target) |
fenced_render |
render fenced blocks of a target language (mermaid) |
fenced_render_chart |
render fenced chart blocks (Chart.js) |
fenced_render_plantuml |
render plantuml/puml blocks (Kroki client) |
fenced_render_graphviz |
render dot/graphviz blocks |
fenced_render_d2 |
render d2 blocks |
fenced_render_wavedrom |
render wavedrom blocks |
fenced_render_vega_lite |
render vega-lite blocks (Vega-Lite) |
fenced_render_abc |
render abc music-notation blocks |
heading_permalinks |
add permalink anchors to headings |
list_table |
build tables from nested lists |
math_block |
fenced math blocks |
spoiler |
spoiler / hidden-content inline |
tab_normalize |
normalize tab indentation |
wikilinks |
[[wiki style]] links |
citations |
citation references |
code-callouts |
numbered callouts in fenced code blocks |
Formatting source
carve.to_carve(source) writes the engine's canonical spelling of a document.
The writer can refuse a tree it cannot spell back, and by default that refusal
comes back as the source as authored - the same value a document that was
already canonical returns, so a caller cannot tell the two apart. Pass
strict=True to get the refusal as a ValueError:
try:
formatted = carve.to_carve(source, strict=True)
except ValueError as refusal:
print(f"left as authored: {refusal}")
A tool that writes the result back to the file wants strict=True: without it,
the one document that could not be formatted is reported as formatted.
Write source from AST JSON
carve.render_ast_json(json_text) validates an interchange tree through the
native engine and writes Carve source. Invalid JSON, unknown node kinds, and
unspellable trees raise ValueError.
import json
import carve
source = carve.render_ast_json(json.dumps({
"type": "document",
"srcByteLength": 0,
"children": [{"type": "paragraph", "children": [
{"type": "text", "value": "*literal text*"}
]}]
}))
Docling conversion is maintained in the separate docling-carve package.
Metadata
Release files for carve-lang 0.1.8
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| carve_lang-0.1.8.tar.gz | 46.2 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| carve_lang-0.1.8-cp38-abi3-win_amd64.whl | CPython 3.8 | abi3 | Windows x86-64 | Details |
| carve_lang-0.1.8-cp38-abi3-musllinux_1_2_x86_64.whl | CPython 3.8 | abi3 | Linux musl 1.2+ x86-64 | Details |
| carve_lang-0.1.8-cp38-abi3-musllinux_1_2_aarch64.whl | CPython 3.8 | abi3 | Linux musl 1.2+ ARM64 | Details |
| carve_lang-0.1.8-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.8 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| carve_lang-0.1.8-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.8 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| carve_lang-0.1.8-cp38-abi3-macosx_11_0_arm64.whl | CPython 3.8 | abi3 | macOS 11.0+ ARM64 | Details |
| carve_lang-0.1.8-cp38-abi3-macosx_10_12_x86_64.whl | CPython 3.8 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 30.3 MB
Release files / carve_lang-0.1.8.tar.gz
| Download URL | carve_lang-0.1.8.tar.gz |
|---|---|
| Size | 46.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
30ff18881b06e61c998b70513299ab1cf81140a72338a42017a2f2911eaedcd6
|
|
BLAKE2b-256 checksum How to use checksums |
8037ac83373b073725601ace69a653396b6b3ef28abce265ae3971c2bf4807f0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-win_amd64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-win_amd64.whl |
|---|---|
| Size | 4.1 MB |
| Tags | CPython 3.8 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
0ba9222eb79f64138fbc1306bbf5564cd31880057d1ed7cb2ad37dfcbb256bc1
|
|
BLAKE2b-256 checksum How to use checksums |
0dceb05206f6178a5d2686da2f8107ae8fc4a3d819d4f6eddb7fcec0d902c4c2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-musllinux_1_2_x86_64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 4.7 MB |
| Tags | CPython 3.8 Linux musl 1.2+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
5cbd0d1ec850d591de775d35b68967c3df479915a5af5be63c64cfe0f524bf4c
|
|
BLAKE2b-256 checksum How to use checksums |
8c79f8040bdac7162d5a8fea85b861e4618ddf1a4551a9762e9372fc7bf06fe8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-musllinux_1_2_aarch64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-musllinux_1_2_aarch64.whl |
|---|---|
| Size | 4.5 MB |
| Tags | CPython 3.8 Linux musl 1.2+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
eba34da40b2fc4118da6da9cc5aad5553d281894307d6b572e12be4d7c58cb64
|
|
BLAKE2b-256 checksum How to use checksums |
8f096a3d34d0a998da07e7957ac4c7ee9e32c84b69a41b1bd5a672cf4b044481
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 4.5 MB |
| Tags | CPython 3.8 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
a9ed66f75bd1d109d3e4a5344d648c9d65dcdfbcf61e08c199313c8526f7fb38
|
|
BLAKE2b-256 checksum How to use checksums |
9112348e1315e5bae486198600b1b1645c9a29954112afca4f33d0508f2ebb0f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 4.3 MB |
| Tags | CPython 3.8 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
b3096b7b697e3e93a24651adb07174c97514874c26d7a892e21205824cf3b702
|
|
BLAKE2b-256 checksum How to use checksums |
7f3e6df85c324a95ec0839c3f7e731f7b23c564a01909b52f92ee780f9cd5a7d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-macosx_11_0_arm64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 3.9 MB |
| Tags | CPython 3.8 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
0f27b028cb51917603a009d35ca7d63603931e0c72da0e6d1807a23283cb768c
|
|
BLAKE2b-256 checksum How to use checksums |
96c4b5a9e2908cf503734e26d0cca3aa313b7a2428a59c5d3b2b4cb5afeff11f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / carve_lang-0.1.8-cp38-abi3-macosx_10_12_x86_64.whl
| Download URL | carve_lang-0.1.8-cp38-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 4.1 MB |
| Tags | CPython 3.8 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
e30ca27059259e40f622bad67de01ba4c3ce933d224a66fbfb5842a3b0dd2ba6
|
|
BLAKE2b-256 checksum How to use checksums |
32ae6cdf4b4fd19955cd1d0dd835d5d3815d07dead00e137cf6613d4692631e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|