tiptap-python-utils 0.4.0__tar.gz → 0.6.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.4.0/src/tiptap_python_utils.egg-info → tiptap_python_utils-0.6.0}/PKG-INFO +69 -58
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/README.md +67 -56
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/pyproject.toml +2 -2
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/__init__.py +4 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/__init__.py +2 -0
- tiptap_python_utils-0.6.0/src/tiptap_python_utils/codec/reader.py +95 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/content.py +18 -1
- tiptap_python_utils-0.6.0/src/tiptap_python_utils/identity.py +9 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/select/selection.py +8 -0
- tiptap_python_utils-0.6.0/src/tiptap_python_utils/task.py +55 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0/src/tiptap_python_utils.egg-info}/PKG-INFO +69 -58
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/SOURCES.txt +4 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_compat_imports.py +2 -0
- tiptap_python_utils-0.6.0/tests/test_generic_helpers.py +192 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_public_api.py +2 -0
- tiptap_python_utils-0.6.0/tests/test_task_namespace.py +90 -0
- tiptap_python_utils-0.4.0/src/tiptap_python_utils/codec/reader.py +0 -42
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/LICENSE +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/MANIFEST.in +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/setup.cfg +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/raw.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/writer.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/key.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/kind.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/policy.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/exceptions.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/base.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/nodes.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/payload.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/registry.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/py.typed +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/select/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/families.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/fingerprint.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/identity.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tasks/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tasks/query.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/text/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/text/extract.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tree/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tree/path.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/types.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/walk/__init__.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/walk/traversal.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/dependency_links.txt +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/requires.txt +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/top_level.txt +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_codec_raw.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_content.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_extract.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_filter.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_mutations.py +0 -0
- {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_traverser.py +0 -0
{tiptap_python_utils-0.4.0/src/tiptap_python_utils.egg-info → tiptap_python_utils-0.6.0}/PKG-INFO
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tiptap_python_utils
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python utilities for
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Pure-Python utilities for processing TipTap JSON on the server side. Parse, traverse, edit, and serialize TipTap documents — no JavaScript bridge required. Zero runtime dependencies, Python 3.9+.
|
|
5
5
|
Author: tiptap_python_utils contributors
|
|
6
6
|
License: MIT License
|
|
7
7
|
|
|
@@ -56,23 +56,10 @@ Dynamic: license-file
|
|
|
56
56
|
[](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
|
|
57
57
|
[](LICENSE)
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
```
|
|
59
|
+
TipTap is a JavaScript editor. If your backend is Python and you need to
|
|
60
|
+
process TipTap JSON — extract text, query tasks, sync shared nodes — this
|
|
61
|
+
library does it in pure Python with zero dependencies. No JS bridge, no
|
|
62
|
+
Node.js subprocess.
|
|
76
63
|
|
|
77
64
|
## Quick Start
|
|
78
65
|
|
|
@@ -94,8 +81,24 @@ raw = {
|
|
|
94
81
|
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
95
82
|
```
|
|
96
83
|
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **Zero runtime dependencies.** Standard library only.
|
|
87
|
+
- **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
|
|
88
|
+
- **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
|
|
89
|
+
- **Immutable AST.** All mutations return new instances via a fluent selection API.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install tiptap_python_utils
|
|
95
|
+
```
|
|
96
|
+
|
|
97
97
|
## Three Ways to Load a Document
|
|
98
98
|
|
|
99
|
+
Pick a constructor by how much you trust the input — lenient, strict, or
|
|
100
|
+
auto-wrapping a bare node into a `doc`.
|
|
101
|
+
|
|
99
102
|
| Constructor | When to use | On invalid input |
|
|
100
103
|
|---|---|---|
|
|
101
104
|
| `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
|
|
@@ -104,7 +107,8 @@ updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
|
104
107
|
|
|
105
108
|
## Lossless Round Trip
|
|
106
109
|
|
|
107
|
-
Parsing never silently drops fields
|
|
110
|
+
Parsing never silently drops fields — custom nodes and unknown keys survive a
|
|
111
|
+
parse-then-serialize cycle byte-for-byte. Two mechanisms preserve information:
|
|
108
112
|
|
|
109
113
|
- `Node.extra` stores top-level keys that aren't part of the known schema
|
|
110
114
|
(e.g. custom node attributes, vendor-specific keys).
|
|
@@ -125,7 +129,8 @@ assert Content.require(raw).to_dict() == raw # byte-for-byte
|
|
|
125
129
|
|
|
126
130
|
## Typed Nodes
|
|
127
131
|
|
|
128
|
-
Build typed nodes directly and serialize them back to
|
|
132
|
+
Build typed nodes directly in Python and serialize them back to
|
|
133
|
+
TipTap-compatible JSON.
|
|
129
134
|
|
|
130
135
|
```python
|
|
131
136
|
from tiptap_python_utils import Content, Paragraph, Text
|
|
@@ -136,8 +141,8 @@ doc = Content.wrap(node.raw())
|
|
|
136
141
|
|
|
137
142
|
## Selection and Editing
|
|
138
143
|
|
|
139
|
-
The fluent selection API is the single home for mutation
|
|
140
|
-
|
|
144
|
+
The fluent selection API is the single home for mutation: every method returns
|
|
145
|
+
a new `Content`, so the original is never mutated.
|
|
141
146
|
|
|
142
147
|
### Select by id or kind
|
|
143
148
|
|
|
@@ -149,6 +154,22 @@ content.where_id("p1")
|
|
|
149
154
|
|
|
150
155
|
# By TipTap kind.
|
|
151
156
|
content.of(kind.PARAGRAPH)
|
|
157
|
+
|
|
158
|
+
# By an arbitrary predicate over every node (and its descendants).
|
|
159
|
+
content.where(lambda node: getattr(node, "level", None) == 1)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Generic queries
|
|
163
|
+
|
|
164
|
+
`Selection` carries two predicate primitives that work for any kind, so you
|
|
165
|
+
don't need a bespoke `has_heading_text`-style helper per node type:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
# Narrow a selection further.
|
|
169
|
+
content.of(kind.HEADING).filter(lambda n: n.level == 2)
|
|
170
|
+
|
|
171
|
+
# Existence check (short-circuits).
|
|
172
|
+
content.of(kind.HEADING).any(lambda n: n.text.strip() == "Introduction")
|
|
152
173
|
```
|
|
153
174
|
|
|
154
175
|
### Atomic mutations
|
|
@@ -175,6 +196,11 @@ content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "
|
|
|
175
196
|
# Append a node to the document root.
|
|
176
197
|
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
|
|
177
198
|
|
|
199
|
+
# Build-and-append in one call — works for any kind, stamps a fresh id when
|
|
200
|
+
# none is given. Typed fields (e.g. Heading.level) hydrate correctly.
|
|
201
|
+
content.append(kind.HEADING, "New section", attrs={"level": 2})
|
|
202
|
+
content.append(kind.PARAGRAPH, "Body text", node_id="p3")
|
|
203
|
+
|
|
178
204
|
# Replace a node by id (the replacement's attrs.id must match).
|
|
179
205
|
content.replace_by_id("p1", {
|
|
180
206
|
"type": "paragraph",
|
|
@@ -185,6 +211,9 @@ content.replace_by_id("p1", {
|
|
|
185
211
|
|
|
186
212
|
## Text Extraction
|
|
187
213
|
|
|
214
|
+
Pull the visible plain text out of a document — useful for search indexing,
|
|
215
|
+
word counts, or previews.
|
|
216
|
+
|
|
188
217
|
```python
|
|
189
218
|
from tiptap_python_utils import Content, text_slices, visible_text, word_count
|
|
190
219
|
|
|
@@ -197,6 +226,9 @@ slices = text_slices(content, context=True)
|
|
|
197
226
|
|
|
198
227
|
## Tasks
|
|
199
228
|
|
|
229
|
+
Query task lists in a document — find every task item or check whether any are
|
|
230
|
+
still open.
|
|
231
|
+
|
|
200
232
|
```python
|
|
201
233
|
from tiptap_python_utils import Content, has_open_tasks, open_tasks
|
|
202
234
|
|
|
@@ -219,6 +251,9 @@ task.shared_id # sharedId attr, if any
|
|
|
219
251
|
|
|
220
252
|
## Shared-Node Synchronization
|
|
221
253
|
|
|
254
|
+
Keep copies of the same logical node (linked by `sharedId`) in sync — collect
|
|
255
|
+
canonical bodies, then rewrite every matching node from them.
|
|
256
|
+
|
|
222
257
|
`Content.shared_families()` collects canonical bodies grouped by `sharedId` into
|
|
223
258
|
a `SharedFamilies` value object. `Content.sync_shared(families)` rewrites every
|
|
224
259
|
matching node in the document from those canonical bodies, preserving
|
|
@@ -257,17 +292,6 @@ Related helpers on `Content`:
|
|
|
257
292
|
- `node.with_shared_id(sid)` — stamp a sharedId onto a node (returns a new node).
|
|
258
293
|
- `new_shared_id()` — mint a fresh `shared-…` identifier.
|
|
259
294
|
|
|
260
|
-
## Architecture (one paragraph)
|
|
261
|
-
|
|
262
|
-
The package is layered: `contract` (key/kind/policy primitives) → `model`
|
|
263
|
-
(immutable AST with a registry of node classes; unknown kinds round-trip via
|
|
264
|
-
`Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
|
|
265
|
-
`writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
|
|
266
|
-
immutable tree) → `select` (fluent `Selection` — the single home for mutation)
|
|
267
|
-
→ `content` (public facade) → `text` / `tasks` / `shared` (user-facing
|
|
268
|
-
workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
|
|
269
|
-
mutations return new instances.
|
|
270
|
-
|
|
271
295
|
## Public API
|
|
272
296
|
|
|
273
297
|
Common imports are available from the package root:
|
|
@@ -281,6 +305,7 @@ from tiptap_python_utils import (
|
|
|
281
305
|
Text,
|
|
282
306
|
has_open_tasks,
|
|
283
307
|
kind,
|
|
308
|
+
new_node_id,
|
|
284
309
|
new_shared_id,
|
|
285
310
|
open_tasks,
|
|
286
311
|
text_slices,
|
|
@@ -289,35 +314,21 @@ from tiptap_python_utils import (
|
|
|
289
314
|
)
|
|
290
315
|
```
|
|
291
316
|
|
|
292
|
-
## Stability
|
|
293
|
-
|
|
294
|
-
The project is pre-1.0; minor versions may include breaking changes. See
|
|
295
|
-
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
296
|
-
|
|
297
|
-
## Development
|
|
298
|
-
|
|
299
|
-
```bash
|
|
300
|
-
python -m venv .venv
|
|
301
|
-
. .venv/bin/activate
|
|
302
|
-
python -m pip install -e ".[dev]"
|
|
303
|
-
pytest -q
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
Build and validate a release artifact:
|
|
307
|
-
|
|
308
|
-
```bash
|
|
309
|
-
python -m build
|
|
310
|
-
python -m twine check dist/*
|
|
311
|
-
```
|
|
312
|
-
|
|
313
317
|
## Contributing
|
|
314
318
|
|
|
315
319
|
Issues and pull requests are welcome. Please read
|
|
316
|
-
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup
|
|
317
|
-
and open an issue at
|
|
320
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup, architecture overview,
|
|
321
|
+
and release checklist, and open an issue at
|
|
318
322
|
[github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
|
|
319
|
-
before
|
|
323
|
+
before opening a pull request so we can align on the approach.
|
|
320
324
|
|
|
321
325
|
## License
|
|
322
326
|
|
|
323
327
|
MIT — see [LICENSE](LICENSE).
|
|
328
|
+
|
|
329
|
+
## Stability
|
|
330
|
+
|
|
331
|
+
The project is pre-1.0; minor versions may include breaking changes. See
|
|
332
|
+
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
333
|
+
</content>
|
|
334
|
+
</invoke>
|
|
@@ -5,23 +5,10 @@
|
|
|
5
5
|
[](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
```
|
|
8
|
+
TipTap is a JavaScript editor. If your backend is Python and you need to
|
|
9
|
+
process TipTap JSON — extract text, query tasks, sync shared nodes — this
|
|
10
|
+
library does it in pure Python with zero dependencies. No JS bridge, no
|
|
11
|
+
Node.js subprocess.
|
|
25
12
|
|
|
26
13
|
## Quick Start
|
|
27
14
|
|
|
@@ -43,8 +30,24 @@ raw = {
|
|
|
43
30
|
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
44
31
|
```
|
|
45
32
|
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Zero runtime dependencies.** Standard library only.
|
|
36
|
+
- **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
|
|
37
|
+
- **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
|
|
38
|
+
- **Immutable AST.** All mutations return new instances via a fluent selection API.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install tiptap_python_utils
|
|
44
|
+
```
|
|
45
|
+
|
|
46
46
|
## Three Ways to Load a Document
|
|
47
47
|
|
|
48
|
+
Pick a constructor by how much you trust the input — lenient, strict, or
|
|
49
|
+
auto-wrapping a bare node into a `doc`.
|
|
50
|
+
|
|
48
51
|
| Constructor | When to use | On invalid input |
|
|
49
52
|
|---|---|---|
|
|
50
53
|
| `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
|
|
@@ -53,7 +56,8 @@ updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
|
|
|
53
56
|
|
|
54
57
|
## Lossless Round Trip
|
|
55
58
|
|
|
56
|
-
Parsing never silently drops fields
|
|
59
|
+
Parsing never silently drops fields — custom nodes and unknown keys survive a
|
|
60
|
+
parse-then-serialize cycle byte-for-byte. Two mechanisms preserve information:
|
|
57
61
|
|
|
58
62
|
- `Node.extra` stores top-level keys that aren't part of the known schema
|
|
59
63
|
(e.g. custom node attributes, vendor-specific keys).
|
|
@@ -74,7 +78,8 @@ assert Content.require(raw).to_dict() == raw # byte-for-byte
|
|
|
74
78
|
|
|
75
79
|
## Typed Nodes
|
|
76
80
|
|
|
77
|
-
Build typed nodes directly and serialize them back to
|
|
81
|
+
Build typed nodes directly in Python and serialize them back to
|
|
82
|
+
TipTap-compatible JSON.
|
|
78
83
|
|
|
79
84
|
```python
|
|
80
85
|
from tiptap_python_utils import Content, Paragraph, Text
|
|
@@ -85,8 +90,8 @@ doc = Content.wrap(node.raw())
|
|
|
85
90
|
|
|
86
91
|
## Selection and Editing
|
|
87
92
|
|
|
88
|
-
The fluent selection API is the single home for mutation
|
|
89
|
-
|
|
93
|
+
The fluent selection API is the single home for mutation: every method returns
|
|
94
|
+
a new `Content`, so the original is never mutated.
|
|
90
95
|
|
|
91
96
|
### Select by id or kind
|
|
92
97
|
|
|
@@ -98,6 +103,22 @@ content.where_id("p1")
|
|
|
98
103
|
|
|
99
104
|
# By TipTap kind.
|
|
100
105
|
content.of(kind.PARAGRAPH)
|
|
106
|
+
|
|
107
|
+
# By an arbitrary predicate over every node (and its descendants).
|
|
108
|
+
content.where(lambda node: getattr(node, "level", None) == 1)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Generic queries
|
|
112
|
+
|
|
113
|
+
`Selection` carries two predicate primitives that work for any kind, so you
|
|
114
|
+
don't need a bespoke `has_heading_text`-style helper per node type:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# Narrow a selection further.
|
|
118
|
+
content.of(kind.HEADING).filter(lambda n: n.level == 2)
|
|
119
|
+
|
|
120
|
+
# Existence check (short-circuits).
|
|
121
|
+
content.of(kind.HEADING).any(lambda n: n.text.strip() == "Introduction")
|
|
101
122
|
```
|
|
102
123
|
|
|
103
124
|
### Atomic mutations
|
|
@@ -124,6 +145,11 @@ content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "
|
|
|
124
145
|
# Append a node to the document root.
|
|
125
146
|
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
|
|
126
147
|
|
|
148
|
+
# Build-and-append in one call — works for any kind, stamps a fresh id when
|
|
149
|
+
# none is given. Typed fields (e.g. Heading.level) hydrate correctly.
|
|
150
|
+
content.append(kind.HEADING, "New section", attrs={"level": 2})
|
|
151
|
+
content.append(kind.PARAGRAPH, "Body text", node_id="p3")
|
|
152
|
+
|
|
127
153
|
# Replace a node by id (the replacement's attrs.id must match).
|
|
128
154
|
content.replace_by_id("p1", {
|
|
129
155
|
"type": "paragraph",
|
|
@@ -134,6 +160,9 @@ content.replace_by_id("p1", {
|
|
|
134
160
|
|
|
135
161
|
## Text Extraction
|
|
136
162
|
|
|
163
|
+
Pull the visible plain text out of a document — useful for search indexing,
|
|
164
|
+
word counts, or previews.
|
|
165
|
+
|
|
137
166
|
```python
|
|
138
167
|
from tiptap_python_utils import Content, text_slices, visible_text, word_count
|
|
139
168
|
|
|
@@ -146,6 +175,9 @@ slices = text_slices(content, context=True)
|
|
|
146
175
|
|
|
147
176
|
## Tasks
|
|
148
177
|
|
|
178
|
+
Query task lists in a document — find every task item or check whether any are
|
|
179
|
+
still open.
|
|
180
|
+
|
|
149
181
|
```python
|
|
150
182
|
from tiptap_python_utils import Content, has_open_tasks, open_tasks
|
|
151
183
|
|
|
@@ -168,6 +200,9 @@ task.shared_id # sharedId attr, if any
|
|
|
168
200
|
|
|
169
201
|
## Shared-Node Synchronization
|
|
170
202
|
|
|
203
|
+
Keep copies of the same logical node (linked by `sharedId`) in sync — collect
|
|
204
|
+
canonical bodies, then rewrite every matching node from them.
|
|
205
|
+
|
|
171
206
|
`Content.shared_families()` collects canonical bodies grouped by `sharedId` into
|
|
172
207
|
a `SharedFamilies` value object. `Content.sync_shared(families)` rewrites every
|
|
173
208
|
matching node in the document from those canonical bodies, preserving
|
|
@@ -206,17 +241,6 @@ Related helpers on `Content`:
|
|
|
206
241
|
- `node.with_shared_id(sid)` — stamp a sharedId onto a node (returns a new node).
|
|
207
242
|
- `new_shared_id()` — mint a fresh `shared-…` identifier.
|
|
208
243
|
|
|
209
|
-
## Architecture (one paragraph)
|
|
210
|
-
|
|
211
|
-
The package is layered: `contract` (key/kind/policy primitives) → `model`
|
|
212
|
-
(immutable AST with a registry of node classes; unknown kinds round-trip via
|
|
213
|
-
`Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
|
|
214
|
-
`writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
|
|
215
|
-
immutable tree) → `select` (fluent `Selection` — the single home for mutation)
|
|
216
|
-
→ `content` (public facade) → `text` / `tasks` / `shared` (user-facing
|
|
217
|
-
workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
|
|
218
|
-
mutations return new instances.
|
|
219
|
-
|
|
220
244
|
## Public API
|
|
221
245
|
|
|
222
246
|
Common imports are available from the package root:
|
|
@@ -230,6 +254,7 @@ from tiptap_python_utils import (
|
|
|
230
254
|
Text,
|
|
231
255
|
has_open_tasks,
|
|
232
256
|
kind,
|
|
257
|
+
new_node_id,
|
|
233
258
|
new_shared_id,
|
|
234
259
|
open_tasks,
|
|
235
260
|
text_slices,
|
|
@@ -238,35 +263,21 @@ from tiptap_python_utils import (
|
|
|
238
263
|
)
|
|
239
264
|
```
|
|
240
265
|
|
|
241
|
-
## Stability
|
|
242
|
-
|
|
243
|
-
The project is pre-1.0; minor versions may include breaking changes. See
|
|
244
|
-
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
245
|
-
|
|
246
|
-
## Development
|
|
247
|
-
|
|
248
|
-
```bash
|
|
249
|
-
python -m venv .venv
|
|
250
|
-
. .venv/bin/activate
|
|
251
|
-
python -m pip install -e ".[dev]"
|
|
252
|
-
pytest -q
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Build and validate a release artifact:
|
|
256
|
-
|
|
257
|
-
```bash
|
|
258
|
-
python -m build
|
|
259
|
-
python -m twine check dist/*
|
|
260
|
-
```
|
|
261
|
-
|
|
262
266
|
## Contributing
|
|
263
267
|
|
|
264
268
|
Issues and pull requests are welcome. Please read
|
|
265
|
-
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup
|
|
266
|
-
and open an issue at
|
|
269
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup, architecture overview,
|
|
270
|
+
and release checklist, and open an issue at
|
|
267
271
|
[github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
|
|
268
|
-
before
|
|
272
|
+
before opening a pull request so we can align on the approach.
|
|
269
273
|
|
|
270
274
|
## License
|
|
271
275
|
|
|
272
276
|
MIT — see [LICENSE](LICENSE).
|
|
277
|
+
|
|
278
|
+
## Stability
|
|
279
|
+
|
|
280
|
+
The project is pre-1.0; minor versions may include breaking changes. See
|
|
281
|
+
[CHANGELOG.md](CHANGELOG.md) for what changed and when.
|
|
282
|
+
</content>
|
|
283
|
+
</invoke>
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tiptap_python_utils"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Python utilities for
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "Pure-Python utilities for processing TipTap JSON on the server side. Parse, traverse, edit, and serialize TipTap documents — no JavaScript bridge required. Zero runtime dependencies, Python 3.9+."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { file = "LICENSE" }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Public API for TipTap JSON utilities."""
|
|
2
2
|
|
|
3
|
+
from . import task
|
|
3
4
|
from .contract import key, kind
|
|
4
5
|
from .content import Content
|
|
5
6
|
from .exceptions import TiptapValidationError
|
|
@@ -21,6 +22,7 @@ from .model import (
|
|
|
21
22
|
registry,
|
|
22
23
|
)
|
|
23
24
|
from .contract.policy import content_id, is_parseable, node_id, tiptap_id
|
|
25
|
+
from .identity import new_node_id
|
|
24
26
|
from .select import Selection
|
|
25
27
|
from .shared import SharedFamilies, fingerprint, new_shared_id
|
|
26
28
|
from .tasks import has_open_tasks, open_tasks, syncable_tasks
|
|
@@ -58,11 +60,13 @@ __all__ = [
|
|
|
58
60
|
"is_parseable",
|
|
59
61
|
"key",
|
|
60
62
|
"kind",
|
|
63
|
+
"new_node_id",
|
|
61
64
|
"new_shared_id",
|
|
62
65
|
"node_id",
|
|
63
66
|
"open_tasks",
|
|
64
67
|
"registry",
|
|
65
68
|
"syncable_tasks",
|
|
69
|
+
"task",
|
|
66
70
|
"text_slices",
|
|
67
71
|
"tiptap_id",
|
|
68
72
|
"visible_text",
|
{tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/__init__.py
RENAMED
|
@@ -8,6 +8,7 @@ from .raw import (
|
|
|
8
8
|
require_object,
|
|
9
9
|
)
|
|
10
10
|
from .reader import (
|
|
11
|
+
build_node,
|
|
11
12
|
read_children,
|
|
12
13
|
read_doc,
|
|
13
14
|
read_node,
|
|
@@ -16,6 +17,7 @@ from .reader import (
|
|
|
16
17
|
from .writer import dump, dumps
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
20
|
+
"build_node",
|
|
19
21
|
"dump",
|
|
20
22
|
"dumps",
|
|
21
23
|
"normalize_text",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Raw TipTap JSON → typed AST hydration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from ..contract import key, kind
|
|
8
|
+
from ..exceptions import TiptapValidationError
|
|
9
|
+
from ..model import ContentTuple, Doc, Node, registry
|
|
10
|
+
from ..model.payload import has_any_identity
|
|
11
|
+
from .raw import require_object
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Kinds that hold block/container children, never inline text directly.
|
|
15
|
+
_TEXT_REJECTING_KINDS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
kind.DOC,
|
|
18
|
+
kind.BULLET_LIST,
|
|
19
|
+
kind.ORDERED_LIST,
|
|
20
|
+
kind.TASK_LIST,
|
|
21
|
+
kind.LIST_ITEM,
|
|
22
|
+
kind.TASK_ITEM,
|
|
23
|
+
kind.BLOCKQUOTE,
|
|
24
|
+
kind.TABLE_CELL,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_doc(raw: Mapping[str, Any]) -> Doc | None:
|
|
30
|
+
"""Read a raw TipTap document root."""
|
|
31
|
+
if raw.get(key.TYPE) != kind.DOC:
|
|
32
|
+
return None
|
|
33
|
+
parsed = read_node(raw)
|
|
34
|
+
return parsed if isinstance(parsed, Doc) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_node(raw: Mapping[str, Any]) -> Node:
|
|
38
|
+
"""Read a raw TipTap node by delegating to the registry."""
|
|
39
|
+
children = read_children(raw.get(key.CONTENT, []))
|
|
40
|
+
return registry.read(raw, children)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_children(raw_children: Any) -> ContentTuple:
|
|
44
|
+
if not isinstance(raw_children, list):
|
|
45
|
+
return ()
|
|
46
|
+
return tuple(read_node(child) for child in raw_children if isinstance(child, dict))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_node(
|
|
50
|
+
node_kind: str,
|
|
51
|
+
text: str = "",
|
|
52
|
+
*,
|
|
53
|
+
attrs: Optional[Dict[str, Any]] = None,
|
|
54
|
+
node_id: Optional[str] = None,
|
|
55
|
+
) -> Node:
|
|
56
|
+
"""Build any typed node from (kind, text, attrs) via the registry.
|
|
57
|
+
|
|
58
|
+
Constructs a minimal raw payload and hydrates it through ``read_node`` so
|
|
59
|
+
subclass-typed fields (e.g. ``Heading.level``) and ``present`` semantics
|
|
60
|
+
are derived the same way as when parsing real JSON. Pure: ``node_id`` is
|
|
61
|
+
only stamped when ``attrs`` carries no identity of its own.
|
|
62
|
+
"""
|
|
63
|
+
merged = dict(attrs or {})
|
|
64
|
+
if node_id is not None and not has_any_identity(merged):
|
|
65
|
+
merged[key.ID] = node_id
|
|
66
|
+
|
|
67
|
+
raw: Dict[str, Any] = {key.TYPE: node_kind}
|
|
68
|
+
if merged:
|
|
69
|
+
raw[key.ATTRS] = merged
|
|
70
|
+
if text:
|
|
71
|
+
_attach_text(raw, node_kind, text)
|
|
72
|
+
return read_node(raw)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _attach_text(raw: Dict[str, Any], node_kind: str, text: str) -> None:
|
|
76
|
+
if node_kind == kind.TEXT:
|
|
77
|
+
raw[key.TEXT] = text
|
|
78
|
+
elif node_kind in _TEXT_REJECTING_KINDS:
|
|
79
|
+
raise TiptapValidationError(
|
|
80
|
+
f"Node kind '{node_kind}' cannot hold inline text content"
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
raw[key.CONTENT] = [{key.TYPE: kind.TEXT, key.TEXT: text}]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_node_input(node_or_raw: Any, *, label: str) -> Node:
|
|
87
|
+
"""Read either a typed node or a raw node payload."""
|
|
88
|
+
if isinstance(node_or_raw, Node):
|
|
89
|
+
return node_or_raw
|
|
90
|
+
|
|
91
|
+
parsed = require_object(node_or_raw, label=label)
|
|
92
|
+
node = read_doc(parsed) if parsed.get(key.TYPE) == kind.DOC else read_node(parsed)
|
|
93
|
+
if node is None:
|
|
94
|
+
raise TiptapValidationError(f"{label} must be a valid TipTap node")
|
|
95
|
+
return node
|
|
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from copy import deepcopy
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
-
from typing import Any, Dict, Iterator, Optional
|
|
8
|
+
from typing import Any, Callable, Dict, Iterator, Optional
|
|
9
9
|
|
|
10
10
|
from .exceptions import TiptapValidationError
|
|
11
11
|
from .types import DocumentContent
|
|
12
12
|
|
|
13
13
|
from . import codec
|
|
14
14
|
from .contract import key, kind, policy
|
|
15
|
+
from .identity import new_node_id
|
|
15
16
|
from .model import (
|
|
16
17
|
Blockquote,
|
|
17
18
|
BulletList,
|
|
@@ -118,6 +119,9 @@ class Content:
|
|
|
118
119
|
tuple(ref for ref in self.refs() if ref.node.kind == node_kind),
|
|
119
120
|
)
|
|
120
121
|
|
|
122
|
+
def where(self, pred: Callable[[Node], bool]) -> Selection:
|
|
123
|
+
return Selection(self, tuple(self.refs())).filter(pred)
|
|
124
|
+
|
|
121
125
|
def where_shared_id(self, shared_id: str) -> Selection:
|
|
122
126
|
return Selection(
|
|
123
127
|
self,
|
|
@@ -148,6 +152,19 @@ class Content:
|
|
|
148
152
|
return self
|
|
149
153
|
return Selection(self, refs).transform(families.merge)
|
|
150
154
|
|
|
155
|
+
def append(
|
|
156
|
+
self,
|
|
157
|
+
node_kind: str,
|
|
158
|
+
text: str = "",
|
|
159
|
+
*,
|
|
160
|
+
attrs: Optional[Dict[str, Any]] = None,
|
|
161
|
+
node_id: Optional[str] = None,
|
|
162
|
+
) -> "Content":
|
|
163
|
+
node = codec.build_node(
|
|
164
|
+
node_kind, text, attrs=attrs, node_id=node_id or new_node_id()
|
|
165
|
+
)
|
|
166
|
+
return self.append_root(node)
|
|
167
|
+
|
|
151
168
|
def append_root(self, node_or_raw: Any) -> "Content":
|
|
152
169
|
return self.of(kind.DOC).append(node_or_raw)
|
|
153
170
|
|
{tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/select/selection.py
RENAMED
|
@@ -43,6 +43,14 @@ class Selection:
|
|
|
43
43
|
leaves = tuple(leaf for ref in self._refs for leaf in _first_text_descendant(ref))
|
|
44
44
|
return Selection(self._content, leaves)
|
|
45
45
|
|
|
46
|
+
def filter(self, pred: Callable[[Node], bool]) -> "Selection":
|
|
47
|
+
"""Narrow the selection to refs whose node matches ``pred``."""
|
|
48
|
+
return Selection(self._content, tuple(r for r in self._refs if pred(r.node)))
|
|
49
|
+
|
|
50
|
+
def any(self, pred: Callable[[Node], bool] = lambda _node: True) -> bool:
|
|
51
|
+
"""True if any selected node matches ``pred`` (short-circuits)."""
|
|
52
|
+
return any(pred(r.node) for r in self._refs)
|
|
53
|
+
|
|
46
54
|
def text(self, value: str) -> "Content":
|
|
47
55
|
self._require_text_only("text")
|
|
48
56
|
return self._apply(lambda node: node.with_text(value))
|