tiptap-python-utils 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
@@ -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",
@@ -1,18 +1,19 @@
1
1
  """Raw JSON codec exports."""
2
2
 
3
- from .json import (
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",
@@ -1,17 +1,19 @@
1
- """TipTap raw JSON codec."""
1
+ """Raw TipTap JSON I/O and dict-shaped helpers.
2
+
3
+ This module is intentionally free of any ``..model`` import so that raw
4
+ validation can be exercised without hydrating the typed AST.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
5
9
  import json
6
10
  from copy import deepcopy
7
- from typing import Any, Dict, Mapping, Optional
11
+ from typing import Any, Dict, Iterator, Mapping, Optional
8
12
 
13
+ from ..contract import key, kind, policy
9
14
  from ..exceptions import TiptapValidationError
10
15
  from ..types import DocumentContent
11
16
 
12
- from ..contract import key, kind, policy
13
- from ..model import ContentTuple, Doc, Node, registry
14
-
15
17
 
16
18
  def parse_raw(raw: Optional[DocumentContent]) -> Optional[Dict[str, Any]]:
17
19
  """Leniently parse a raw document payload."""
@@ -45,46 +47,6 @@ def require_object(
45
47
  return parsed
46
48
 
47
49
 
48
- def read_doc(raw: Mapping[str, Any]) -> Doc | None:
49
- """Read a raw TipTap document root."""
50
- if raw.get(key.TYPE) != kind.DOC:
51
- return None
52
- parsed = read_node(raw)
53
- return parsed if isinstance(parsed, Doc) else None
54
-
55
-
56
- def read_node(raw: Mapping[str, Any]) -> Node:
57
- """Read a raw TipTap node by delegating to the registry."""
58
- children = read_children(raw.get(key.CONTENT, []))
59
- return registry.read(raw, children)
60
-
61
-
62
- def read_children(raw_children: Any) -> ContentTuple:
63
- if not isinstance(raw_children, list):
64
- return ()
65
- return tuple(read_node(child) for child in raw_children if isinstance(child, dict))
66
-
67
-
68
- def read_node_input(node_or_raw: Any, *, label: str) -> Node:
69
- """Read either a typed node or a raw node payload."""
70
- if isinstance(node_or_raw, Node):
71
- return node_or_raw
72
-
73
- parsed = require_object(node_or_raw, label=label)
74
- node = read_doc(parsed) if parsed.get(key.TYPE) == kind.DOC else read_node(parsed)
75
- if node is None:
76
- raise TiptapValidationError(f"{label} must be a valid TipTap node")
77
- return node
78
-
79
-
80
- def dump(node: Node) -> Dict[str, Any]:
81
- return node.raw()
82
-
83
-
84
- def dumps(node: Node) -> str:
85
- return json.dumps(dump(node))
86
-
87
-
88
50
  def raw_node_id(node: Mapping[str, Any]) -> str:
89
51
  return policy.content_id(node.get(key.ATTRS, {}))
90
52
 
@@ -97,7 +59,7 @@ def normalize_text(value: str) -> str:
97
59
  return " ".join(value.split())
98
60
 
99
61
 
100
- def _iter_text(node: Mapping[str, Any]):
62
+ def _iter_text(node: Mapping[str, Any]) -> Iterator[str]:
101
63
  if not isinstance(node, dict):
102
64
  return
103
65
 
@@ -0,0 +1,42 @@
1
+ """Raw TipTap JSON → typed AST hydration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from ..contract import key, kind
8
+ from ..exceptions import TiptapValidationError
9
+ from ..model import ContentTuple, Doc, Node, registry
10
+ from .raw import require_object
11
+
12
+
13
+ def read_doc(raw: Mapping[str, Any]) -> Doc | None:
14
+ """Read a raw TipTap document root."""
15
+ if raw.get(key.TYPE) != kind.DOC:
16
+ return None
17
+ parsed = read_node(raw)
18
+ return parsed if isinstance(parsed, Doc) else None
19
+
20
+
21
+ def read_node(raw: Mapping[str, Any]) -> Node:
22
+ """Read a raw TipTap node by delegating to the registry."""
23
+ children = read_children(raw.get(key.CONTENT, []))
24
+ return registry.read(raw, children)
25
+
26
+
27
+ def read_children(raw_children: Any) -> ContentTuple:
28
+ if not isinstance(raw_children, list):
29
+ return ()
30
+ return tuple(read_node(child) for child in raw_children if isinstance(child, dict))
31
+
32
+
33
+ def read_node_input(node_or_raw: Any, *, label: str) -> Node:
34
+ """Read either a typed node or a raw node payload."""
35
+ if isinstance(node_or_raw, Node):
36
+ return node_or_raw
37
+
38
+ parsed = require_object(node_or_raw, label=label)
39
+ node = read_doc(parsed) if parsed.get(key.TYPE) == kind.DOC else read_node(parsed)
40
+ if node is None:
41
+ raise TiptapValidationError(f"{label} must be a valid TipTap node")
42
+ return node
@@ -0,0 +1,16 @@
1
+ """Typed AST → raw TipTap JSON serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict
7
+
8
+ from ..model import Node
9
+
10
+
11
+ def dump(node: Node) -> Dict[str, Any]:
12
+ return node.raw()
13
+
14
+
15
+ def dumps(node: Node) -> str:
16
+ return json.dumps(dump(node))
@@ -117,6 +117,18 @@ class Content:
117
117
  tuple(ref for ref in self.refs() if ref.node.kind == node_kind),
118
118
  )
119
119
 
120
+ def append_root(self, node_or_raw: Any) -> "Content":
121
+ return self.of(kind.DOC).append(node_or_raw)
122
+
123
+ def replace_by_id(self, node_id: str, node_or_raw: Any) -> "Content":
124
+ replacement = codec.read_node_input(node_or_raw, label="Node content")
125
+ replacement_id = replacement.attrs.get(key.ID, "")
126
+ if not replacement_id:
127
+ raise TiptapValidationError("Node content must include attrs.id")
128
+ if replacement_id != node_id:
129
+ raise TiptapValidationError("Node content attrs.id must match path node_id")
130
+ return self.where_id(node_id).replace(replacement)
131
+
120
132
  def dump(self) -> str:
121
133
  return json.dumps(self.to_dict())
122
134
 
@@ -1,345 +1,49 @@
1
- """Typed TipTap AST model."""
1
+ """Typed TipTap AST model.
2
2
 
3
- from __future__ import annotations
3
+ This package initializer is intentionally re-export-only. Implementation
4
+ lives in:
4
5
 
5
- from copy import deepcopy
6
- from dataclasses import dataclass, field, replace
7
- from typing import Any, ClassVar, Dict, Iterator, Mapping, Tuple, TypeVar
6
+ - ``base`` — ``Node``, ``Text``, ``ContentTuple``, ``MarksTuple``, ``NodeT``
7
+ - ``nodes`` — concrete node classes (``Doc``, ``Paragraph``, ``Heading``, …)
8
+ - ``registry`` — ``Registry`` plus the bootstrapped ``registry`` instance
9
+ - ``payload`` — raw-payload extraction helpers used by node readers
10
+ """
8
11
 
9
- from ..contract import key, kind, policy
10
-
11
- ContentTuple = Tuple["Node", ...]
12
- MarksTuple = Tuple[Any, ...]
13
- NodeT = TypeVar("NodeT", bound="Node")
14
-
15
-
16
- @dataclass(frozen=True)
17
- class Node:
18
- """Base typed TipTap node."""
19
-
20
- id: str = ""
21
- content: ContentTuple = field(default_factory=tuple)
22
- attrs: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
23
- extra: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
24
- present: frozenset[str] = field(default_factory=frozenset, compare=False, repr=False)
25
-
26
- kind: ClassVar[str]
27
-
28
- def __post_init__(self) -> None:
29
- if not self.id:
30
- object.__setattr__(self, "id", policy.content_id(self.attrs))
31
-
32
- @classmethod
33
- def read(cls: type[NodeT], raw: Mapping[str, Any], children: ContentTuple) -> NodeT:
34
- return cls(**_payload(raw, children))
35
-
36
- @property
37
- def shared_id(self) -> str | None:
38
- return policy.shared_id(self.attrs)
39
-
40
- @property
41
- def text(self) -> str:
42
- return " ".join(part for part in self.iter_text() if part)
43
-
44
- def iter_text(self) -> Iterator[str]:
45
- for child in self.content:
46
- yield from child.iter_text()
47
-
48
- def raw(self) -> Dict[str, Any]:
49
- raw: Dict[str, Any] = {key.TYPE: self.kind}
50
- raw.update(deepcopy(self.extra))
51
-
52
- attrs = self.raw_attrs()
53
- if attrs or key.ATTRS in self.present:
54
- raw[key.ATTRS] = attrs
55
-
56
- if self.content or key.CONTENT in self.present:
57
- raw[key.CONTENT] = [child.raw() for child in self.content]
58
- return raw
59
-
60
- def raw_attrs(self) -> Dict[str, Any]:
61
- attrs = deepcopy(self.attrs)
62
- if self.id and not _has_any_identity(attrs):
63
- attrs[key.ID] = self.id
64
- return attrs
65
-
66
- def with_text(self, value: str) -> "Node":
67
- if not self.content:
68
- return self
69
- return replace(self, content=(Text(value=value),))
70
-
71
- def with_attr(self, name: str, value: Any) -> "Node":
72
- attrs = deepcopy(self.attrs)
73
- attrs[name] = deepcopy(value)
74
- return replace(self, attrs=attrs, present=self.present | {key.ATTRS})
75
-
76
- def append(self, child: "Node") -> "Node":
77
- return replace(self, content=self.content + (child,), present=self.present | {key.CONTENT})
78
-
79
-
80
- @dataclass(frozen=True)
81
- class Text(Node):
82
- value: str = ""
83
- marks: MarksTuple = ()
84
-
85
- kind: ClassVar[str] = kind.TEXT
86
-
87
- @classmethod
88
- def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Text":
89
- value = raw.get(key.TEXT, "")
90
- marks = raw.get(key.MARKS, [])
91
- return cls(
92
- value=value if isinstance(value, str) else str(value),
93
- marks=tuple(deepcopy(marks)) if isinstance(marks, list) else (),
94
- **_payload(raw, children, extra_keys={key.TEXT, key.MARKS}),
95
- )
96
-
97
- @property
98
- def text(self) -> str:
99
- return self.value.strip()
100
-
101
- def iter_text(self) -> Iterator[str]:
102
- if self.text:
103
- yield self.text
104
-
105
- def raw(self) -> Dict[str, Any]:
106
- raw = super().raw()
107
- if self.value or key.TEXT in self.present:
108
- raw[key.TEXT] = self.value
109
- if self.marks or key.MARKS in self.present:
110
- raw[key.MARKS] = [deepcopy(mark) for mark in self.marks]
111
- return raw
112
-
113
- def with_text(self, value: str) -> "Text":
114
- return replace(self, value=value, present=self.present | {key.TEXT})
115
-
116
-
117
- @dataclass(frozen=True)
118
- class Doc(Node):
119
- kind: ClassVar[str] = kind.DOC
120
-
121
-
122
- @dataclass(frozen=True)
123
- class Paragraph(Node):
124
- kind: ClassVar[str] = kind.PARAGRAPH
125
-
126
-
127
- @dataclass(frozen=True)
128
- class Heading(Node):
129
- level: int = 1
130
-
131
- kind: ClassVar[str] = kind.HEADING
132
-
133
- @classmethod
134
- def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Heading":
135
- return cls(level=_heading_level(raw), **_payload(raw, children))
136
-
137
- def raw_attrs(self) -> Dict[str, Any]:
138
- attrs = super().raw_attrs()
139
- if key.LEVEL in attrs or key.ATTRS not in self.present:
140
- attrs.setdefault(key.LEVEL, self.level)
141
- return attrs
142
-
143
-
144
- @dataclass(frozen=True)
145
- class TaskItem(Node):
146
- task_item_id: str = ""
147
- is_completed: bool = False
148
- local_task_item_id: str = ""
149
- canonical_task_item_id: str = ""
150
- is_linked_copy: bool = False
151
-
152
- kind: ClassVar[str] = kind.TASK_ITEM
153
-
154
- @classmethod
155
- def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "TaskItem":
156
- completed = _task_completion(raw)
157
- local_id = policy.content_id(raw.get(key.ATTRS, {}))
158
- canonical_id = _task_canonical_id(raw, local_id)
159
- return cls(
160
- task_item_id=canonical_id,
161
- is_completed=bool(completed),
162
- local_task_item_id=local_id,
163
- canonical_task_item_id=canonical_id,
164
- is_linked_copy=bool(local_id and canonical_id and local_id != canonical_id),
165
- **_payload(raw, children),
166
- )
167
-
168
- def __post_init__(self) -> None:
169
- super().__post_init__()
170
- if not self.local_task_item_id and self.task_item_id:
171
- object.__setattr__(self, "local_task_item_id", self.task_item_id)
172
- if not self.canonical_task_item_id and self.task_item_id:
173
- object.__setattr__(self, "canonical_task_item_id", self.task_item_id)
174
- if (
175
- not self.is_linked_copy
176
- and self.local_task_item_id
177
- and self.canonical_task_item_id
178
- and self.local_task_item_id != self.canonical_task_item_id
179
- ):
180
- object.__setattr__(self, "is_linked_copy", True)
181
-
182
- @property
183
- def shared_id(self) -> str | None:
184
- return policy.shared_id(self.attrs)
185
-
186
- def raw_attrs(self) -> Dict[str, Any]:
187
- attrs = super().raw_attrs()
188
- if self.local_task_item_id and not _has_any_identity(attrs):
189
- attrs[key.ID] = self.local_task_item_id
190
- if self.canonical_task_item_id and (
191
- key.TASK_CANONICAL_ID in attrs
192
- or self.canonical_task_item_id != self.local_task_item_id
193
- ):
194
- attrs.setdefault(key.TASK_CANONICAL_ID, self.canonical_task_item_id)
195
- if key.CHECKED in attrs or (
196
- key.ATTRS not in self.present and key.STATUS not in self.extra
197
- ):
198
- attrs.setdefault(key.CHECKED, self.is_completed)
199
- return attrs
200
-
201
-
202
- @dataclass(frozen=True)
203
- class ListItem(Node):
204
- kind: ClassVar[str] = kind.LIST_ITEM
205
-
206
-
207
- @dataclass(frozen=True)
208
- class CodeBlock(Node):
209
- kind: ClassVar[str] = kind.CODE_BLOCK
210
-
211
-
212
- @dataclass(frozen=True)
213
- class TaskList(Node):
214
- kind: ClassVar[str] = kind.TASK_LIST
215
-
216
-
217
- @dataclass(frozen=True)
218
- class BulletList(Node):
219
- kind: ClassVar[str] = kind.BULLET_LIST
220
-
221
-
222
- @dataclass(frozen=True)
223
- class OrderedList(Node):
224
- kind: ClassVar[str] = kind.ORDERED_LIST
225
-
226
-
227
- @dataclass(frozen=True)
228
- class Blockquote(Node):
229
- kind: ClassVar[str] = kind.BLOCKQUOTE
230
-
231
-
232
- @dataclass(frozen=True)
233
- class TableCell(Node):
234
- kind: ClassVar[str] = kind.TABLE_CELL
235
-
236
-
237
- @dataclass(frozen=True)
238
- class Unknown(Node):
239
- raw_kind: str = ""
240
-
241
- @classmethod
242
- def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Unknown":
243
- return cls(raw_kind=str(raw.get(key.TYPE, "")), **_payload(raw, children))
244
-
245
- @property
246
- def kind(self) -> str:
247
- return self.raw_kind
248
-
249
-
250
- class Registry:
251
- """Map raw TipTap kinds to typed node classes."""
252
-
253
- def __init__(self) -> None:
254
- self._classes: dict[str, type[Node]] = {}
255
-
256
- def register(self, node_class: type[Node]) -> type[Node]:
257
- self._classes[node_class.kind] = node_class
258
- return node_class
259
-
260
- def read(self, raw: Mapping[str, Any], children: ContentTuple) -> Node:
261
- node_kind = str(raw.get(key.TYPE, ""))
262
- node_class = self._classes.get(node_kind, Unknown)
263
- return node_class.read(raw, children)
264
-
265
-
266
- registry = Registry()
267
- for _node_class in (
12
+ from .base import ContentTuple, MarksTuple, Node, NodeT, Text
13
+ from .nodes import (
14
+ Blockquote,
15
+ BulletList,
16
+ CodeBlock,
268
17
  Doc,
269
- Text,
270
- Paragraph,
271
18
  Heading,
272
- TaskItem,
273
19
  ListItem,
274
- CodeBlock,
275
- TaskList,
276
- BulletList,
277
20
  OrderedList,
278
- Blockquote,
21
+ Paragraph,
279
22
  TableCell,
280
- ):
281
- registry.register(_node_class)
282
-
283
-
284
- def _payload(
285
- raw: Mapping[str, Any],
286
- children: ContentTuple,
287
- *,
288
- extra_keys: set[str] | None = None,
289
- ) -> dict[str, Any]:
290
- present = frozenset(str(name) for name in raw.keys())
291
- known = {key.TYPE, key.ATTRS, key.CONTENT} | (extra_keys or set())
292
- attrs = raw.get(key.ATTRS, {})
293
- return {
294
- "id": policy.content_id(attrs),
295
- "content": children,
296
- "attrs": deepcopy(attrs) if isinstance(attrs, dict) else {},
297
- "extra": {name: deepcopy(value) for name, value in raw.items() if name not in known},
298
- "present": present,
299
- }
300
-
301
-
302
- def _heading_level(raw: Mapping[str, Any]) -> int:
303
- attrs = raw.get(key.ATTRS, {})
304
- level = attrs.get(key.LEVEL, 1) if isinstance(attrs, dict) else 1
305
- return level if isinstance(level, int) and 1 <= level <= 6 else 1
306
-
307
-
308
- def _task_canonical_id(raw: Mapping[str, Any], fallback: str) -> str:
309
- attrs = raw.get(key.ATTRS, {})
310
- if not isinstance(attrs, dict):
311
- return fallback
312
-
313
- canonical_id = attrs.get(key.TASK_CANONICAL_ID)
314
- if isinstance(canonical_id, str):
315
- stripped = canonical_id.strip()
316
- if stripped:
317
- return stripped
318
- if canonical_id is not None:
319
- return str(canonical_id)
320
- return fallback
321
-
322
-
323
- def _task_completion(raw: Mapping[str, Any]) -> bool | None:
324
- status = raw.get(key.STATUS)
325
- if isinstance(status, str) and status.lower() in {"done", "pending"}:
326
- return status.lower() == "done"
327
-
328
- attrs = raw.get(key.ATTRS, {})
329
- if not isinstance(attrs, dict):
330
- attrs = {}
331
-
332
- status = attrs.get(key.STATUS)
333
- if isinstance(status, str) and status.lower() in {"done", "pending"}:
334
- return status.lower() == "done"
335
-
336
- checked = attrs.get(key.CHECKED)
337
- if isinstance(checked, bool):
338
- return checked
339
- if isinstance(checked, str) and checked.strip().lower() in {"true", "false"}:
340
- return checked.strip().lower() == "true"
341
- return None
342
-
343
-
344
- def _has_any_identity(attrs: Dict[str, Any]) -> bool:
345
- return key.ID in attrs or key.TIPTAP_ID in attrs
23
+ TaskItem,
24
+ TaskList,
25
+ Unknown,
26
+ )
27
+ from .registry import Registry, registry
28
+
29
+ __all__ = [
30
+ "Blockquote",
31
+ "BulletList",
32
+ "CodeBlock",
33
+ "ContentTuple",
34
+ "Doc",
35
+ "Heading",
36
+ "ListItem",
37
+ "MarksTuple",
38
+ "Node",
39
+ "NodeT",
40
+ "OrderedList",
41
+ "Paragraph",
42
+ "Registry",
43
+ "TableCell",
44
+ "TaskItem",
45
+ "TaskList",
46
+ "Text",
47
+ "Unknown",
48
+ "registry",
49
+ ]