tiptap-python-utils 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiptap_python_utils-0.2.0/PKG-INFO +315 -0
- tiptap_python_utils-0.2.0/README.md +264 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/pyproject.toml +2 -1
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/__init__.py +0 -3
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/codec/__init__.py +5 -4
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/codec/json.py → tiptap_python_utils-0.2.0/src/tiptap_python_utils/codec/raw.py +8 -46
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/codec/reader.py +42 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/codec/writer.py +16 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/content.py +12 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/__init__.py +49 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/base.py +136 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/nodes.py +127 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/payload.py +81 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/registry.py +57 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/select/selection.py +133 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/shared/__init__.py +4 -10
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/families.py +47 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/fingerprint.py +21 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/identity.py +48 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/sync.py +63 -0
- tiptap_python_utils-0.2.0/src/tiptap_python_utils.egg-info/PKG-INFO +315 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/SOURCES.txt +14 -4
- tiptap_python_utils-0.2.0/tests/test_codec_raw.py +90 -0
- tiptap_python_utils-0.2.0/tests/test_compat_imports.py +129 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_content.py +163 -3
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_filter.py +1 -2
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_mutations.py +15 -22
- tiptap_python_utils-0.2.0/tests/test_public_api.py +77 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_traverser.py +1 -1
- tiptap_python_utils-0.1.0/PKG-INFO +0 -176
- tiptap_python_utils-0.1.0/README.md +0 -126
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/__init__.py +0 -12
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/commands.py +0 -163
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/model/__init__.py +0 -345
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/select/selection.py +0 -72
- tiptap_python_utils-0.1.0/src/tiptap_python_utils/shared/service.py +0 -147
- tiptap_python_utils-0.1.0/src/tiptap_python_utils.egg-info/PKG-INFO +0 -176
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/LICENSE +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/MANIFEST.in +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/setup.cfg +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/key.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/kind.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/policy.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/exceptions.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/py.typed +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/select/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tasks/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tasks/query.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/text/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/text/extract.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tree/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tree/path.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/types.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/walk/__init__.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/walk/traversal.py +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/dependency_links.txt +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/requires.txt +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/top_level.txt +0 -0
- {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_extract.py +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tiptap_python_utils
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
|
|
5
|
+
Author: tiptap_python_utils contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 tiptap_python_utils contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/tugkanpilka/tiptap-python-utils
|
|
29
|
+
Project-URL: Repository, https://github.com/tugkanpilka/tiptap-python-utils
|
|
30
|
+
Project-URL: Issues, https://github.com/tugkanpilka/tiptap-python-utils/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/tugkanpilka/tiptap-python-utils/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: tiptap,prosemirror,json,ast,editor
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Typing :: Typed
|
|
43
|
+
Requires-Python: >=3.9
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
License-File: LICENSE
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
48
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
49
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# tiptap_python_utils
|
|
53
|
+
|
|
54
|
+
[](https://pypi.org/project/tiptap_python_utils/)
|
|
55
|
+
[](https://pypi.org/project/tiptap_python_utils/)
|
|
56
|
+
[](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
|
|
57
|
+
[](LICENSE)
|
|
58
|
+
|
|
59
|
+
Python utilities for [TipTap](https://tiptap.dev) JSON content.
|
|
60
|
+
|
|
61
|
+
`tiptap_python_utils` parses TipTap documents into typed, immutable Python
|
|
62
|
+
nodes, preserves unknown/custom nodes for lossless round trips, and provides
|
|
63
|
+
small helpers for traversal, immutable edits, visible text extraction, task
|
|
64
|
+
queries, and shared-node synchronization.
|
|
65
|
+
|
|
66
|
+
- **Zero runtime dependencies.** Standard library only.
|
|
67
|
+
- **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
|
|
68
|
+
- **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
|
|
69
|
+
- **Immutable AST.** All mutations return new instances via a fluent selection API.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install tiptap_python_utils
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick Start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from tiptap_python_utils import Content
|
|
81
|
+
|
|
82
|
+
raw = {
|
|
83
|
+
"type": "doc",
|
|
84
|
+
"content": [
|
|
85
|
+
{
|
|
86
|
+
"type": "paragraph",
|
|
87
|
+
"attrs": {"id": "p1"},
|
|
88
|
+
"content": [{"type": "text", "text": "Old"}],
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Strict-load → descend to the text leaf → write a new value → serialize.
|
|
94
|
+
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Three Ways to Load a Document
|
|
98
|
+
|
|
99
|
+
| Constructor | When to use | On invalid input |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
|
|
102
|
+
| `Content.require(raw)` | Strict — input must be a valid TipTap `doc` | Raises `TiptapValidationError` |
|
|
103
|
+
| `Content.wrap(node)` | Auto-wraps a non-doc node into a `doc` root | Raises if the node is not parseable |
|
|
104
|
+
|
|
105
|
+
## Lossless Round Trip
|
|
106
|
+
|
|
107
|
+
Parsing never silently drops fields. Two mechanisms preserve information:
|
|
108
|
+
|
|
109
|
+
- `Node.extra` stores top-level keys that aren't part of the known schema
|
|
110
|
+
(e.g. custom node attributes, vendor-specific keys).
|
|
111
|
+
- `Node.present` records which structural keys (`attrs`, `content`, …) appeared
|
|
112
|
+
in the raw input, so `raw()` emits empty `attrs: {}` or `content: []` only
|
|
113
|
+
when they were originally present.
|
|
114
|
+
- Unknown node kinds become `Unknown(raw_kind="…")` rather than being rejected.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from tiptap_python_utils import Content
|
|
118
|
+
|
|
119
|
+
raw = {"type": "doc", "content": [
|
|
120
|
+
{"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
|
|
121
|
+
]}
|
|
122
|
+
|
|
123
|
+
assert Content.require(raw).to_dict() == raw # byte-for-byte
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Typed Nodes
|
|
127
|
+
|
|
128
|
+
Build typed nodes directly and serialize them back to TipTap-compatible JSON:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from tiptap_python_utils import Content, Paragraph, Text
|
|
132
|
+
|
|
133
|
+
node = Paragraph(id="p1", content=(Text(value="Hello"),))
|
|
134
|
+
doc = Content.wrap(node.raw())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Selection and Editing
|
|
138
|
+
|
|
139
|
+
The fluent selection API is the single home for mutation. Selection methods
|
|
140
|
+
return a new `Content`; the original is never mutated.
|
|
141
|
+
|
|
142
|
+
### Select by id or kind
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from tiptap_python_utils import Content, kind
|
|
146
|
+
|
|
147
|
+
# By id (uses TipTap's id resolution rules under the hood).
|
|
148
|
+
content.where_id("p1")
|
|
149
|
+
|
|
150
|
+
# By TipTap kind.
|
|
151
|
+
content.of(kind.PARAGRAPH)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Atomic mutations
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
# Write an attribute on the selected node.
|
|
158
|
+
content.where_id("p1").attr("color", "blue")
|
|
159
|
+
|
|
160
|
+
# Descend to the first text descendant, then write text or marks.
|
|
161
|
+
content.where_id("p1").leaf().text("Updated")
|
|
162
|
+
content.where_id("p1").leaf().marks([{"type": "bold"}])
|
|
163
|
+
|
|
164
|
+
# Replace the whole selected node, or append a child to it.
|
|
165
|
+
content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
|
|
166
|
+
content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`.text()` and `.marks()` are strict — they only operate on `Text` refs. Chain
|
|
170
|
+
`.leaf()` first to descend from a container.
|
|
171
|
+
|
|
172
|
+
### Document-level commands
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# Append a node to the document root.
|
|
176
|
+
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
|
|
177
|
+
|
|
178
|
+
# Replace a node by id (the replacement's attrs.id must match).
|
|
179
|
+
content.replace_by_id("p1", {
|
|
180
|
+
"type": "paragraph",
|
|
181
|
+
"attrs": {"id": "p1"},
|
|
182
|
+
"content": [{"type": "text", "text": "Replaced"}],
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Text Extraction
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from tiptap_python_utils import Content, text_slices, visible_text, word_count
|
|
190
|
+
|
|
191
|
+
content = Content.require(raw)
|
|
192
|
+
|
|
193
|
+
plain_text = visible_text(content)
|
|
194
|
+
count = word_count(content)
|
|
195
|
+
slices = text_slices(content, context=True)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Tasks
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from tiptap_python_utils import Content, has_open_tasks, open_tasks
|
|
202
|
+
|
|
203
|
+
content = Content.require(raw)
|
|
204
|
+
|
|
205
|
+
pending = has_open_tasks(content)
|
|
206
|
+
items = open_tasks(content)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Each `TaskItem` exposes derived state as properties:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
task = open_tasks(content)[0]
|
|
213
|
+
|
|
214
|
+
task.task_item_id # canonical id (falls back to local id)
|
|
215
|
+
task.is_completed # status / checked interpretation
|
|
216
|
+
task.is_linked_copy # True when local id differs from canonical id
|
|
217
|
+
task.shared_id # sharedId attr, if any
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Shared-Node Synchronization
|
|
221
|
+
|
|
222
|
+
`shared_families` collects canonical bodies grouped by `sharedId`;
|
|
223
|
+
`sync_shared` rewrites every matching node in a document using those canonical
|
|
224
|
+
bodies while preserving per-instance identity (`id`, `sharedId`).
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from tiptap_python_utils import shared_families, sync_shared
|
|
228
|
+
|
|
229
|
+
# Canonical doc: the source of truth for every shared body.
|
|
230
|
+
canonical = {"type": "doc", "content": [
|
|
231
|
+
{
|
|
232
|
+
"type": "paragraph",
|
|
233
|
+
"attrs": {"id": "p1", "sharedId": "intro"},
|
|
234
|
+
"content": [{"type": "text", "text": "Authoritative intro"}],
|
|
235
|
+
}
|
|
236
|
+
]}
|
|
237
|
+
|
|
238
|
+
# Doc that mirrors the same sharedId but with a stale body.
|
|
239
|
+
target = {"type": "doc", "content": [
|
|
240
|
+
{
|
|
241
|
+
"type": "paragraph",
|
|
242
|
+
"attrs": {"id": "p1-copy", "sharedId": "intro"},
|
|
243
|
+
"content": [{"type": "text", "text": "Stale copy"}],
|
|
244
|
+
}
|
|
245
|
+
]}
|
|
246
|
+
|
|
247
|
+
families = shared_families(canonical)
|
|
248
|
+
synced_json, changed = sync_shared(target, families)
|
|
249
|
+
assert changed is True
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Architecture (one paragraph)
|
|
253
|
+
|
|
254
|
+
The package is layered: `contract` (key/kind/policy primitives) → `model`
|
|
255
|
+
(immutable AST with a registry of node classes; unknown kinds round-trip via
|
|
256
|
+
`Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
|
|
257
|
+
`writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
|
|
258
|
+
immutable tree) → `select` (fluent `Selection` — the single home for mutation)
|
|
259
|
+
→ `content` (public facade) → `text` / `tasks` / `shared` (user-facing
|
|
260
|
+
workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
|
|
261
|
+
mutations return new instances.
|
|
262
|
+
|
|
263
|
+
## Public API
|
|
264
|
+
|
|
265
|
+
Common imports are available from the package root:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from tiptap_python_utils import (
|
|
269
|
+
Content,
|
|
270
|
+
Paragraph,
|
|
271
|
+
TaskItem,
|
|
272
|
+
Text,
|
|
273
|
+
has_open_tasks,
|
|
274
|
+
kind,
|
|
275
|
+
open_tasks,
|
|
276
|
+
shared_families,
|
|
277
|
+
sync_shared,
|
|
278
|
+
text_slices,
|
|
279
|
+
visible_text,
|
|
280
|
+
word_count,
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Stability
|
|
285
|
+
|
|
286
|
+
The project is pre-1.0; minor versions may include breaking changes. See
|
|
287
|
+
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
python -m venv .venv
|
|
293
|
+
. .venv/bin/activate
|
|
294
|
+
python -m pip install -e ".[dev]"
|
|
295
|
+
pytest -q
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Build and validate a release artifact:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
python -m build
|
|
302
|
+
python -m twine check dist/*
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Contributing
|
|
306
|
+
|
|
307
|
+
Issues and pull requests are welcome. Please read
|
|
308
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
|
|
309
|
+
and open an issue at
|
|
310
|
+
[github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
|
|
311
|
+
before larger changes so we can align on the approach.
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# tiptap_python_utils
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/tiptap_python_utils/)
|
|
4
|
+
[](https://pypi.org/project/tiptap_python_utils/)
|
|
5
|
+
[](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Python utilities for [TipTap](https://tiptap.dev) JSON content.
|
|
9
|
+
|
|
10
|
+
`tiptap_python_utils` parses TipTap documents into typed, immutable Python
|
|
11
|
+
nodes, preserves unknown/custom nodes for lossless round trips, and provides
|
|
12
|
+
small helpers for traversal, immutable edits, visible text extraction, task
|
|
13
|
+
queries, and shared-node synchronization.
|
|
14
|
+
|
|
15
|
+
- **Zero runtime dependencies.** Standard library only.
|
|
16
|
+
- **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
|
|
17
|
+
- **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
|
|
18
|
+
- **Immutable AST.** All mutations return new instances via a fluent selection API.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install tiptap_python_utils
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from tiptap_python_utils import Content
|
|
30
|
+
|
|
31
|
+
raw = {
|
|
32
|
+
"type": "doc",
|
|
33
|
+
"content": [
|
|
34
|
+
{
|
|
35
|
+
"type": "paragraph",
|
|
36
|
+
"attrs": {"id": "p1"},
|
|
37
|
+
"content": [{"type": "text", "text": "Old"}],
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Strict-load → descend to the text leaf → write a new value → serialize.
|
|
43
|
+
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Three Ways to Load a Document
|
|
47
|
+
|
|
48
|
+
| Constructor | When to use | On invalid input |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
|
|
51
|
+
| `Content.require(raw)` | Strict — input must be a valid TipTap `doc` | Raises `TiptapValidationError` |
|
|
52
|
+
| `Content.wrap(node)` | Auto-wraps a non-doc node into a `doc` root | Raises if the node is not parseable |
|
|
53
|
+
|
|
54
|
+
## Lossless Round Trip
|
|
55
|
+
|
|
56
|
+
Parsing never silently drops fields. Two mechanisms preserve information:
|
|
57
|
+
|
|
58
|
+
- `Node.extra` stores top-level keys that aren't part of the known schema
|
|
59
|
+
(e.g. custom node attributes, vendor-specific keys).
|
|
60
|
+
- `Node.present` records which structural keys (`attrs`, `content`, …) appeared
|
|
61
|
+
in the raw input, so `raw()` emits empty `attrs: {}` or `content: []` only
|
|
62
|
+
when they were originally present.
|
|
63
|
+
- Unknown node kinds become `Unknown(raw_kind="…")` rather than being rejected.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from tiptap_python_utils import Content
|
|
67
|
+
|
|
68
|
+
raw = {"type": "doc", "content": [
|
|
69
|
+
{"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
|
|
70
|
+
]}
|
|
71
|
+
|
|
72
|
+
assert Content.require(raw).to_dict() == raw # byte-for-byte
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Typed Nodes
|
|
76
|
+
|
|
77
|
+
Build typed nodes directly and serialize them back to TipTap-compatible JSON:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from tiptap_python_utils import Content, Paragraph, Text
|
|
81
|
+
|
|
82
|
+
node = Paragraph(id="p1", content=(Text(value="Hello"),))
|
|
83
|
+
doc = Content.wrap(node.raw())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Selection and Editing
|
|
87
|
+
|
|
88
|
+
The fluent selection API is the single home for mutation. Selection methods
|
|
89
|
+
return a new `Content`; the original is never mutated.
|
|
90
|
+
|
|
91
|
+
### Select by id or kind
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from tiptap_python_utils import Content, kind
|
|
95
|
+
|
|
96
|
+
# By id (uses TipTap's id resolution rules under the hood).
|
|
97
|
+
content.where_id("p1")
|
|
98
|
+
|
|
99
|
+
# By TipTap kind.
|
|
100
|
+
content.of(kind.PARAGRAPH)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Atomic mutations
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# Write an attribute on the selected node.
|
|
107
|
+
content.where_id("p1").attr("color", "blue")
|
|
108
|
+
|
|
109
|
+
# Descend to the first text descendant, then write text or marks.
|
|
110
|
+
content.where_id("p1").leaf().text("Updated")
|
|
111
|
+
content.where_id("p1").leaf().marks([{"type": "bold"}])
|
|
112
|
+
|
|
113
|
+
# Replace the whole selected node, or append a child to it.
|
|
114
|
+
content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
|
|
115
|
+
content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`.text()` and `.marks()` are strict — they only operate on `Text` refs. Chain
|
|
119
|
+
`.leaf()` first to descend from a container.
|
|
120
|
+
|
|
121
|
+
### Document-level commands
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# Append a node to the document root.
|
|
125
|
+
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
|
|
126
|
+
|
|
127
|
+
# Replace a node by id (the replacement's attrs.id must match).
|
|
128
|
+
content.replace_by_id("p1", {
|
|
129
|
+
"type": "paragraph",
|
|
130
|
+
"attrs": {"id": "p1"},
|
|
131
|
+
"content": [{"type": "text", "text": "Replaced"}],
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Text Extraction
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from tiptap_python_utils import Content, text_slices, visible_text, word_count
|
|
139
|
+
|
|
140
|
+
content = Content.require(raw)
|
|
141
|
+
|
|
142
|
+
plain_text = visible_text(content)
|
|
143
|
+
count = word_count(content)
|
|
144
|
+
slices = text_slices(content, context=True)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Tasks
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from tiptap_python_utils import Content, has_open_tasks, open_tasks
|
|
151
|
+
|
|
152
|
+
content = Content.require(raw)
|
|
153
|
+
|
|
154
|
+
pending = has_open_tasks(content)
|
|
155
|
+
items = open_tasks(content)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each `TaskItem` exposes derived state as properties:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
task = open_tasks(content)[0]
|
|
162
|
+
|
|
163
|
+
task.task_item_id # canonical id (falls back to local id)
|
|
164
|
+
task.is_completed # status / checked interpretation
|
|
165
|
+
task.is_linked_copy # True when local id differs from canonical id
|
|
166
|
+
task.shared_id # sharedId attr, if any
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Shared-Node Synchronization
|
|
170
|
+
|
|
171
|
+
`shared_families` collects canonical bodies grouped by `sharedId`;
|
|
172
|
+
`sync_shared` rewrites every matching node in a document using those canonical
|
|
173
|
+
bodies while preserving per-instance identity (`id`, `sharedId`).
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from tiptap_python_utils import shared_families, sync_shared
|
|
177
|
+
|
|
178
|
+
# Canonical doc: the source of truth for every shared body.
|
|
179
|
+
canonical = {"type": "doc", "content": [
|
|
180
|
+
{
|
|
181
|
+
"type": "paragraph",
|
|
182
|
+
"attrs": {"id": "p1", "sharedId": "intro"},
|
|
183
|
+
"content": [{"type": "text", "text": "Authoritative intro"}],
|
|
184
|
+
}
|
|
185
|
+
]}
|
|
186
|
+
|
|
187
|
+
# Doc that mirrors the same sharedId but with a stale body.
|
|
188
|
+
target = {"type": "doc", "content": [
|
|
189
|
+
{
|
|
190
|
+
"type": "paragraph",
|
|
191
|
+
"attrs": {"id": "p1-copy", "sharedId": "intro"},
|
|
192
|
+
"content": [{"type": "text", "text": "Stale copy"}],
|
|
193
|
+
}
|
|
194
|
+
]}
|
|
195
|
+
|
|
196
|
+
families = shared_families(canonical)
|
|
197
|
+
synced_json, changed = sync_shared(target, families)
|
|
198
|
+
assert changed is True
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Architecture (one paragraph)
|
|
202
|
+
|
|
203
|
+
The package is layered: `contract` (key/kind/policy primitives) → `model`
|
|
204
|
+
(immutable AST with a registry of node classes; unknown kinds round-trip via
|
|
205
|
+
`Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
|
|
206
|
+
`writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
|
|
207
|
+
immutable tree) → `select` (fluent `Selection` — the single home for mutation)
|
|
208
|
+
→ `content` (public facade) → `text` / `tasks` / `shared` (user-facing
|
|
209
|
+
workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
|
|
210
|
+
mutations return new instances.
|
|
211
|
+
|
|
212
|
+
## Public API
|
|
213
|
+
|
|
214
|
+
Common imports are available from the package root:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from tiptap_python_utils import (
|
|
218
|
+
Content,
|
|
219
|
+
Paragraph,
|
|
220
|
+
TaskItem,
|
|
221
|
+
Text,
|
|
222
|
+
has_open_tasks,
|
|
223
|
+
kind,
|
|
224
|
+
open_tasks,
|
|
225
|
+
shared_families,
|
|
226
|
+
sync_shared,
|
|
227
|
+
text_slices,
|
|
228
|
+
visible_text,
|
|
229
|
+
word_count,
|
|
230
|
+
)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Stability
|
|
234
|
+
|
|
235
|
+
The project is pre-1.0; minor versions may include breaking changes. See
|
|
236
|
+
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
python -m venv .venv
|
|
242
|
+
. .venv/bin/activate
|
|
243
|
+
python -m pip install -e ".[dev]"
|
|
244
|
+
pytest -q
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Build and validate a release artifact:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
python -m build
|
|
251
|
+
python -m twine check dist/*
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Contributing
|
|
255
|
+
|
|
256
|
+
Issues and pull requests are welcome. Please read
|
|
257
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
|
|
258
|
+
and open an issue at
|
|
259
|
+
[github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
|
|
260
|
+
before larger changes so we can align on the approach.
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tiptap_python_utils"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python utilities for parsing, traversing, editing, and serializing TipTap JSON content."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -22,6 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: 3.10",
|
|
23
23
|
"Programming Language :: Python :: 3.11",
|
|
24
24
|
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
25
26
|
"Typing :: Typed",
|
|
26
27
|
]
|
|
27
28
|
dependencies = []
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from .contract import key, kind
|
|
4
4
|
from .content import Content
|
|
5
|
-
from .edit import append_node, replace_node
|
|
6
5
|
from .exceptions import TiptapValidationError
|
|
7
6
|
from .model import (
|
|
8
7
|
Blockquote,
|
|
@@ -60,7 +59,6 @@ __all__ = [
|
|
|
60
59
|
"TiptapValidationError",
|
|
61
60
|
"Unknown",
|
|
62
61
|
"Walker",
|
|
63
|
-
"append_node",
|
|
64
62
|
"content_id",
|
|
65
63
|
"fingerprint_shared",
|
|
66
64
|
"has_open_tasks",
|
|
@@ -72,7 +70,6 @@ __all__ = [
|
|
|
72
70
|
"node_id",
|
|
73
71
|
"open_tasks",
|
|
74
72
|
"registry",
|
|
75
|
-
"replace_node",
|
|
76
73
|
"shared_families",
|
|
77
74
|
"shared_id",
|
|
78
75
|
"stamp_shared",
|
{tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/codec/__init__.py
RENAMED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
"""Raw JSON codec exports."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
4
|
-
dump,
|
|
5
|
-
dumps,
|
|
3
|
+
from .raw import (
|
|
6
4
|
normalize_text,
|
|
7
5
|
parse_raw,
|
|
8
6
|
raw_node_id,
|
|
9
7
|
raw_text,
|
|
8
|
+
require_object,
|
|
9
|
+
)
|
|
10
|
+
from .reader import (
|
|
10
11
|
read_children,
|
|
11
12
|
read_doc,
|
|
12
13
|
read_node,
|
|
13
14
|
read_node_input,
|
|
14
|
-
require_object,
|
|
15
15
|
)
|
|
16
|
+
from .writer import dump, dumps
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"dump",
|