Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
Project description
tiptap_python_utils
Python utilities for TipTap JSON content.
tiptap_python_utils parses TipTap documents into typed, immutable Python
nodes, preserves unknown/custom nodes for lossless round trips, and provides
small helpers for traversal, immutable edits, visible text extraction, task
queries, and shared-node synchronization.
- Zero runtime dependencies. Standard library only.
- Python 3.9+. Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
- Lossless round trip. Unknown node kinds and any extra fields are preserved.
- Immutable AST. All mutations return new instances via a fluent selection API.
Install
pip install tiptap_python_utils
Quick Start
from tiptap_python_utils import Content
raw = {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {"id": "p1"},
"content": [{"type": "text", "text": "Old"}],
}
],
}
# Strict-load → descend to the text leaf → write a new value → serialize.
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
Three Ways to Load a Document
| Constructor | When to use | On invalid input |
|---|---|---|
Content.parse(raw) |
Lenient — raw may be None, a string, or a dict |
Returns a Content with root=None |
Content.require(raw) |
Strict — input must be a valid TipTap doc |
Raises TiptapValidationError |
Content.wrap(node) |
Auto-wraps a non-doc node into a doc root |
Raises if the node is not parseable |
Lossless Round Trip
Parsing never silently drops fields. Two mechanisms preserve information:
Node.extrastores top-level keys that aren't part of the known schema (e.g. custom node attributes, vendor-specific keys).Node.presentrecords which structural keys (attrs,content, …) appeared in the raw input, soraw()emits emptyattrs: {}orcontent: []only when they were originally present.- Unknown node kinds become
Unknown(raw_kind="…")rather than being rejected.
from tiptap_python_utils import Content
raw = {"type": "doc", "content": [
{"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
]}
assert Content.require(raw).to_dict() == raw # byte-for-byte
Typed Nodes
Build typed nodes directly and serialize them back to TipTap-compatible JSON:
from tiptap_python_utils import Content, Paragraph, Text
node = Paragraph(id="p1", content=(Text(value="Hello"),))
doc = Content.wrap(node.raw())
Selection and Editing
The fluent selection API is the single home for mutation. Selection methods
return a new Content; the original is never mutated.
Select by id or kind
from tiptap_python_utils import Content, kind
# By id (uses TipTap's id resolution rules under the hood).
content.where_id("p1")
# By TipTap kind.
content.of(kind.PARAGRAPH)
Atomic mutations
# Write an attribute on the selected node.
content.where_id("p1").attr("color", "blue")
# Descend to the first text descendant, then write text or marks.
content.where_id("p1").leaf().text("Updated")
content.where_id("p1").leaf().marks([{"type": "bold"}])
# Replace the whole selected node, or append a child to it.
content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})
.text() and .marks() are strict — they only operate on Text refs. Chain
.leaf() first to descend from a container.
Document-level commands
# Append a node to the document root.
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
# Replace a node by id (the replacement's attrs.id must match).
content.replace_by_id("p1", {
"type": "paragraph",
"attrs": {"id": "p1"},
"content": [{"type": "text", "text": "Replaced"}],
})
Text Extraction
from tiptap_python_utils import Content, text_slices, visible_text, word_count
content = Content.require(raw)
plain_text = visible_text(content)
count = word_count(content)
slices = text_slices(content, context=True)
Tasks
from tiptap_python_utils import Content, has_open_tasks, open_tasks
content = Content.require(raw)
pending = has_open_tasks(content)
items = open_tasks(content)
Each TaskItem exposes derived state as properties:
task = open_tasks(content)[0]
task.task_item_id # canonical id (falls back to local id)
task.is_completed # status / checked interpretation
task.is_linked_copy # True when local id differs from canonical id
task.shared_id # sharedId attr, if any
Shared-Node Synchronization
Content.shared_families() collects canonical bodies grouped by sharedId into
a SharedFamilies value object. Content.sync_shared(families) rewrites every
matching node in the document from those canonical bodies, preserving
per-instance identity (id, sharedId). Both return immutable values — the
original Content is never mutated.
from tiptap_python_utils import Content
# Canonical doc: the source of truth for every shared body.
canonical = Content.require({"type": "doc", "content": [
{
"type": "paragraph",
"attrs": {"id": "p1", "sharedId": "intro"},
"content": [{"type": "text", "text": "Authoritative intro"}],
}
]})
# Doc that mirrors the same sharedId but with a stale body.
target = Content.require({"type": "doc", "content": [
{
"type": "paragraph",
"attrs": {"id": "p1-copy", "sharedId": "intro"},
"content": [{"type": "text", "text": "Stale copy"}],
}
]})
synced = target.sync_shared(canonical.shared_families())
assert synced.has_shared("intro")
Related helpers on Content:
content.where_shared_id(sid)—Selectionover every node with that sharedId.content.has_shared(sid)— quick presence check.node.with_shared_id(sid)— stamp a sharedId onto a node (returns a new node).new_shared_id()— mint a freshshared-…identifier.
Architecture (one paragraph)
The package is layered: contract (key/kind/policy primitives) → model
(immutable AST with a registry of node classes; unknown kinds round-trip via
Unknown) → codec (raw I/O in raw.py, hydration in reader.py, dump in
writer.py) → walk & tree (traversal + path-based replacement on the
immutable tree) → select (fluent Selection — the single home for mutation)
→ content (public facade) → text / tasks / shared (user-facing
workflows built on Content). All nodes are @dataclass(frozen=True);
mutations return new instances.
Public API
Common imports are available from the package root:
from tiptap_python_utils import (
Content,
Paragraph,
SharedFamilies,
TaskItem,
Text,
has_open_tasks,
kind,
new_shared_id,
open_tasks,
text_slices,
visible_text,
word_count,
)
Stability
The project is pre-1.0; minor versions may include breaking changes. See CHANGELOG.md for what changed and when.
Development
python -m venv .venv
. .venv/bin/activate
python -m pip install -e ".[dev]"
pytest -q
Build and validate a release artifact:
python -m build
python -m twine check dist/*
Contributing
Issues and pull requests are welcome. Please read CONTRIBUTING.md for the local setup and release checklist, and open an issue at github.com/tugkanpilka/tiptap-python-utils/issues before larger changes so we can align on the approach.
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tiptap_python_utils-0.3.0.tar.gz.
File metadata
- Download URL: tiptap_python_utils-0.3.0.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc0a3d8dcbd5cb1cdf176d1304c16342601fb653a0ba3c7e186511d0ebd59d90
|
|
| MD5 |
cf31ea3f548fd9b20533b183a72e0de0
|
|
| BLAKE2b-256 |
47656ff7ec219bf68699e2dcb5344c56d459c4faa1d85b78036c34b2fd141e7e
|
Provenance
The following attestation bundles were made for tiptap_python_utils-0.3.0.tar.gz:
Publisher:
publish.yml on tugkanpilka/tiptap-python-utils
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tiptap_python_utils-0.3.0.tar.gz -
Subject digest:
fc0a3d8dcbd5cb1cdf176d1304c16342601fb653a0ba3c7e186511d0ebd59d90 - Sigstore transparency entry: 1657861258
- Sigstore integration time:
-
Permalink:
tugkanpilka/tiptap-python-utils@7fe95f1eb616d7ff2f5d947ddfb8bd9314e6a7e0 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/tugkanpilka
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7fe95f1eb616d7ff2f5d947ddfb8bd9314e6a7e0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file tiptap_python_utils-0.3.0-py3-none-any.whl.
File metadata
- Download URL: tiptap_python_utils-0.3.0-py3-none-any.whl
- Upload date:
- Size: 27.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efff3580e9c7a5ce827d5749c40aaa2e6322d8873f6393c7617e416f1d374f0f
|
|
| MD5 |
f9fc765c383f7906249e8b6cb8bbca58
|
|
| BLAKE2b-256 |
5bc747d6c81b8e0ba8219461a97b574b2b9c77880e61abbacb05e4657e0e4b41
|
Provenance
The following attestation bundles were made for tiptap_python_utils-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on tugkanpilka/tiptap-python-utils
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tiptap_python_utils-0.3.0-py3-none-any.whl -
Subject digest:
efff3580e9c7a5ce827d5749c40aaa2e6322d8873f6393c7617e416f1d374f0f - Sigstore transparency entry: 1657861488
- Sigstore integration time:
-
Permalink:
tugkanpilka/tiptap-python-utils@7fe95f1eb616d7ff2f5d947ddfb8bd9314e6a7e0 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/tugkanpilka
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7fe95f1eb616d7ff2f5d947ddfb8bd9314e6a7e0 -
Trigger Event:
push
-
Statement type: