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.
Files changed (56) hide show
  1. {tiptap_python_utils-0.4.0/src/tiptap_python_utils.egg-info → tiptap_python_utils-0.6.0}/PKG-INFO +69 -58
  2. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/README.md +67 -56
  3. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/pyproject.toml +2 -2
  4. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/__init__.py +4 -0
  5. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/__init__.py +2 -0
  6. tiptap_python_utils-0.6.0/src/tiptap_python_utils/codec/reader.py +95 -0
  7. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/content.py +18 -1
  8. tiptap_python_utils-0.6.0/src/tiptap_python_utils/identity.py +9 -0
  9. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/select/selection.py +8 -0
  10. tiptap_python_utils-0.6.0/src/tiptap_python_utils/task.py +55 -0
  11. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0/src/tiptap_python_utils.egg-info}/PKG-INFO +69 -58
  12. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/SOURCES.txt +4 -0
  13. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_compat_imports.py +2 -0
  14. tiptap_python_utils-0.6.0/tests/test_generic_helpers.py +192 -0
  15. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_public_api.py +2 -0
  16. tiptap_python_utils-0.6.0/tests/test_task_namespace.py +90 -0
  17. tiptap_python_utils-0.4.0/src/tiptap_python_utils/codec/reader.py +0 -42
  18. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/LICENSE +0 -0
  19. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/MANIFEST.in +0 -0
  20. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/setup.cfg +0 -0
  21. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/raw.py +0 -0
  22. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/codec/writer.py +0 -0
  23. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/__init__.py +0 -0
  24. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/key.py +0 -0
  25. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/kind.py +0 -0
  26. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/contract/policy.py +0 -0
  27. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/exceptions.py +0 -0
  28. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/__init__.py +0 -0
  29. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/base.py +0 -0
  30. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/nodes.py +0 -0
  31. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/payload.py +0 -0
  32. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/model/registry.py +0 -0
  33. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/py.typed +0 -0
  34. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/select/__init__.py +0 -0
  35. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/__init__.py +0 -0
  36. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/families.py +0 -0
  37. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/fingerprint.py +0 -0
  38. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/shared/identity.py +0 -0
  39. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tasks/__init__.py +0 -0
  40. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tasks/query.py +0 -0
  41. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/text/__init__.py +0 -0
  42. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/text/extract.py +0 -0
  43. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tree/__init__.py +0 -0
  44. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/tree/path.py +0 -0
  45. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/types.py +0 -0
  46. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/walk/__init__.py +0 -0
  47. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils/walk/traversal.py +0 -0
  48. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/dependency_links.txt +0 -0
  49. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/requires.txt +0 -0
  50. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/src/tiptap_python_utils.egg-info/top_level.txt +0 -0
  51. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_codec_raw.py +0 -0
  52. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_content.py +0 -0
  53. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_extract.py +0 -0
  54. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_filter.py +0 -0
  55. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_mutations.py +0 -0
  56. {tiptap_python_utils-0.4.0 → tiptap_python_utils-0.6.0}/tests/test_traverser.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiptap_python_utils
3
- Version: 0.4.0
4
- Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
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
  [![CI](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
57
57
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
58
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
- ```
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. Two mechanisms preserve information:
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 TipTap-compatible JSON:
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. Selection methods
140
- return a new `Content`; the original is never mutated.
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 and release checklist,
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 larger changes so we can align on the approach.
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
  [![CI](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
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
- ```
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. Two mechanisms preserve information:
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 TipTap-compatible JSON:
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. Selection methods
89
- return a new `Content`; the original is never mutated.
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 and release checklist,
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 larger changes so we can align on the approach.
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.4.0"
8
- description = "Python utilities for parsing, traversing, editing, and serializing TipTap JSON content."
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",
@@ -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
 
@@ -0,0 +1,9 @@
1
+ """Generic node identity primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import uuid4
6
+
7
+
8
+ def new_node_id() -> str:
9
+ return uuid4().hex
@@ -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))