tiptap-python-utils 0.3.0__py3-none-any.whl → 0.5.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.
@@ -21,6 +21,7 @@ from .model import (
21
21
  registry,
22
22
  )
23
23
  from .contract.policy import content_id, is_parseable, node_id, tiptap_id
24
+ from .identity import new_node_id
24
25
  from .select import Selection
25
26
  from .shared import SharedFamilies, fingerprint, new_shared_id
26
27
  from .tasks import has_open_tasks, open_tasks, syncable_tasks
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "is_parseable",
59
60
  "key",
60
61
  "kind",
62
+ "new_node_id",
61
63
  "new_shared_id",
62
64
  "node_id",
63
65
  "open_tasks",
@@ -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",
@@ -2,14 +2,30 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Mapping
5
+ from typing import Any, Dict, Mapping, Optional
6
6
 
7
7
  from ..contract import key, kind
8
8
  from ..exceptions import TiptapValidationError
9
9
  from ..model import ContentTuple, Doc, Node, registry
10
+ from ..model.payload import has_any_identity
10
11
  from .raw import require_object
11
12
 
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
+
13
29
  def read_doc(raw: Mapping[str, Any]) -> Doc | None:
14
30
  """Read a raw TipTap document root."""
15
31
  if raw.get(key.TYPE) != kind.DOC:
@@ -30,6 +46,43 @@ def read_children(raw_children: Any) -> ContentTuple:
30
46
  return tuple(read_node(child) for child in raw_children if isinstance(child, dict))
31
47
 
32
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
+
33
86
  def read_node_input(node_or_raw: Any, *, label: str) -> Node:
34
87
  """Read either a typed node or a raw node payload."""
35
88
  if isinstance(node_or_raw, 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
 
@@ -8,6 +8,8 @@ MARKS = "marks"
8
8
  ID = "id"
9
9
  TIPTAP_ID = "tiptapId"
10
10
  SHARED_ID = "sharedId"
11
+ SHARED = "shared"
12
+ PLACE = "place"
11
13
  LEVEL = "level"
12
14
  CHECKED = "checked"
13
15
  STATUS = "status"
@@ -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))
@@ -52,13 +52,22 @@ class SharedFamilies:
52
52
  return len(self._bodies)
53
53
 
54
54
  def merge(self, target: Node) -> Node:
55
- """Return target rewritten from the canonical body, preserving local id and sharedId."""
55
+ """Return target rewritten from the canonical body.
56
+
57
+ Preserves the target's per-copy identity (local id, sharedId) and its
58
+ per-copy ``place`` discriminator. The family-identical ``shared`` core
59
+ rides along from the canonical body unchanged.
60
+ """
56
61
  canonical = self[target.shared_id]
57
62
  raw = canonical.raw()
58
63
  attrs = dict(raw.get(key.ATTRS, {}))
64
+ attrs.pop(key.PLACE, None)
59
65
  if target.id:
60
66
  attrs[key.ID] = target.id
61
67
  if target.shared_id:
62
68
  attrs[key.SHARED_ID] = target.shared_id
69
+ target_attrs = target.raw().get(key.ATTRS, {})
70
+ if key.PLACE in target_attrs:
71
+ attrs[key.PLACE] = target_attrs[key.PLACE]
63
72
  raw[key.ATTRS] = attrs
64
73
  return codec.read_node(raw)
@@ -13,6 +13,9 @@ def fingerprint(node: Node) -> str:
13
13
  attrs = dict(raw.get(key.ATTRS, {}))
14
14
  attrs.pop(key.ID, None)
15
15
  attrs.pop(key.SHARED_ID, None)
16
+ attrs.pop(key.SHARED, None)
17
+ attrs.pop(key.PLACE, None)
18
+ attrs.pop(key.TASK_CANONICAL_ID, None)
16
19
  if attrs:
17
20
  raw[key.ATTRS] = attrs
18
21
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiptap_python_utils
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
5
5
  Author: tiptap_python_utils contributors
6
6
  License: MIT License
@@ -149,6 +149,22 @@ content.where_id("p1")
149
149
 
150
150
  # By TipTap kind.
151
151
  content.of(kind.PARAGRAPH)
152
+
153
+ # By an arbitrary predicate over every node (and its descendants).
154
+ content.where(lambda node: getattr(node, "level", None) == 1)
155
+ ```
156
+
157
+ ### Generic queries
158
+
159
+ `Selection` carries two predicate primitives that work for any kind, so you
160
+ don't need a bespoke `has_heading_text`-style helper per node type:
161
+
162
+ ```python
163
+ # Narrow a selection further.
164
+ content.of(kind.HEADING).filter(lambda n: n.level == 2)
165
+
166
+ # Existence check (short-circuits).
167
+ content.of(kind.HEADING).any(lambda n: n.text.strip() == "Introduction")
152
168
  ```
153
169
 
154
170
  ### Atomic mutations
@@ -175,6 +191,11 @@ content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "
175
191
  # Append a node to the document root.
176
192
  content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
177
193
 
194
+ # Build-and-append in one call — works for any kind, stamps a fresh id when
195
+ # none is given. Typed fields (e.g. Heading.level) hydrate correctly.
196
+ content.append(kind.HEADING, "New section", attrs={"level": 2})
197
+ content.append(kind.PARAGRAPH, "Body text", node_id="p3")
198
+
178
199
  # Replace a node by id (the replacement's attrs.id must match).
179
200
  content.replace_by_id("p1", {
180
201
  "type": "paragraph",
@@ -281,6 +302,7 @@ from tiptap_python_utils import (
281
302
  Text,
282
303
  has_open_tasks,
283
304
  kind,
305
+ new_node_id,
284
306
  new_shared_id,
285
307
  open_tasks,
286
308
  text_slices,
@@ -1,14 +1,15 @@
1
- tiptap_python_utils/__init__.py,sha256=J78QM5SdfIB3qEeZ0f0ejFJgE1lS8o6VJdCxD5_yCkc,1417
2
- tiptap_python_utils/content.py,sha256=NfTow1rPtTKcBkYTyPA50y0mjmlq57uXnXwFeKgKH_0,6522
1
+ tiptap_python_utils/__init__.py,sha256=OBr4gCjfFWNe8MLv_JDb6_-_8EYRebbCiuAWTkEZIEI,1470
2
+ tiptap_python_utils/content.py,sha256=3PtrEfmXyOMPoHVEH8xxHUYozIab5WlG1EXiPj0DAvs,7048
3
3
  tiptap_python_utils/exceptions.py,sha256=GIP91_6gaz4Vp-uMbmZBFFpnHHvToKYWyYf78Apt0ns,150
4
+ tiptap_python_utils/identity.py,sha256=cXqAECIqft4My575tp-jUjKhv2oTAEQnQdDSVsva6Ps,151
4
5
  tiptap_python_utils/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
6
  tiptap_python_utils/types.py,sha256=f6ocw9oOTyj_khush3bP5cHS2YpybKS0jQgjzSNVx_k,152
6
- tiptap_python_utils/codec/__init__.py,sha256=by1i-NoxvdH-_sBxYRQrm_P5EaOCRDYss7pkcj-zot4,476
7
+ tiptap_python_utils/codec/__init__.py,sha256=kaqQ9OHJg_S0hwLFGS55_Mdhyr2wt9BRMrdYfhIKdHI,510
7
8
  tiptap_python_utils/codec/raw.py,sha256=glQWotPxzOFvPkg3nBBk3GbcM03Cj2yRclG62ckb1k0,2082
8
- tiptap_python_utils/codec/reader.py,sha256=zBJQZ8NX3MuNNhEZbP2POjj9OnkiO64jnGoKDvbqju0,1376
9
+ tiptap_python_utils/codec/reader.py,sha256=LOcGgx8eFOWH_5LyI2ydBfJP54XwJxqFBjOs8zUUYCE,2963
9
10
  tiptap_python_utils/codec/writer.py,sha256=YkOyHkkiufCRFUDtnuGXd6AD4a57mDsDWksn4ZFzyyc,285
10
11
  tiptap_python_utils/contract/__init__.py,sha256=gJMHPqIl6vkcXAOKNKtZIbs8aQs1abqykEpc5i3abQY,115
11
- tiptap_python_utils/contract/key.py,sha256=6T4Y-26hESduXmLaNOOpbZ-zTYkHgABvDCHjL6b2VDM,260
12
+ tiptap_python_utils/contract/key.py,sha256=JV-pBYY_zO9uZ3XWQtjzrTcmhgisVFO2O-zscrUHhPA,294
12
13
  tiptap_python_utils/contract/kind.py,sha256=I-vkJ9mskw07qjbIR5uC1s3z0AvtwA2y3qovkzTsY2Q,297
13
14
  tiptap_python_utils/contract/policy.py,sha256=AeJfLv07JWqhuJMakUgz1Y-bMpac28h426bj17hKvwk,1193
14
15
  tiptap_python_utils/model/__init__.py,sha256=Xv2GP0fCM72H2_Jxo5vvJfStA6ztDavzjYcaV0U0p9g,1049
@@ -17,10 +18,10 @@ tiptap_python_utils/model/nodes.py,sha256=obi8prcwJeJ3MBGZNCdbiGBwiPNJhiKb08b3oW
17
18
  tiptap_python_utils/model/payload.py,sha256=UAWAaXgh50tglwFGBjkWj5ngMHQE35XQR1oHNysHdTw,2417
18
19
  tiptap_python_utils/model/registry.py,sha256=2TbiEvUkVh85W0I0YKjpeVivcvKFCvcO1GtYEzMNu6Q,1168
19
20
  tiptap_python_utils/select/__init__.py,sha256=lFavos0E3w2D_keUPDa5xkWhIjkQiee7yNK7X-G-U6o,88
20
- tiptap_python_utils/select/selection.py,sha256=oTJ5l4o4vr0yKMXcIfhZInzXFqD8X6T9ZhE2WSxpVEY,4999
21
+ tiptap_python_utils/select/selection.py,sha256=j-3x_MgNdp88dlwWKDdDrXkyDoCXleXuvlnVQdWTezM,5432
21
22
  tiptap_python_utils/shared/__init__.py,sha256=XoRaVFjOlCWbnTeTFKw5DdoLqGA6sGdRr0eGpSkFQS8,223
22
- tiptap_python_utils/shared/families.py,sha256=7B39bXpEePfearOl2tzLDuJelo1yG67-anzVlmnidVs,2069
23
- tiptap_python_utils/shared/fingerprint.py,sha256=YUKhUgPXIEFQ_ujUxnh1jPfxT1eilrFP_-hmqXmlkCM,476
23
+ tiptap_python_utils/shared/families.py,sha256=T89HaILztYmEJU_0rWeJYQuTc1uU0oCS1QuYQF_b-wg,2440
24
+ tiptap_python_utils/shared/fingerprint.py,sha256=X0zrxbjM-ATUukXp5YUYBW9XzkeeYxP7szpHeFBsr_8,582
24
25
  tiptap_python_utils/shared/identity.py,sha256=cwUy6CMvlGV15Z8FyY3idDl2tPs9eiIWbwJRsNQgwY0,164
25
26
  tiptap_python_utils/tasks/__init__.py,sha256=EXNu9YfJHBjyUZGf5HJIvaW7Ub_aN6fw_CZBNjBW0Og,154
26
27
  tiptap_python_utils/tasks/query.py,sha256=59hb0fubBHlfOQekv_KY3DbTEBPFdYh_1Tc9LuFsL9w,464
@@ -30,8 +31,8 @@ tiptap_python_utils/tree/__init__.py,sha256=YzlXRkQrlRGYmkWbpZ8mutZOmK4BzmiZaBqU
30
31
  tiptap_python_utils/tree/path.py,sha256=bHmBFIZ3BV4FVlB79HhSgjeP6qHdNXClB9DFovSe8fs,1067
31
32
  tiptap_python_utils/walk/__init__.py,sha256=hKU7DhRY6HVlxCxyWmnZtoobWNx5MCIT0OoErGe4Bp4,120
32
33
  tiptap_python_utils/walk/traversal.py,sha256=6GDFmPAuo_t4FeOeV6hqrqvwcTZ8G93ep21-hrFt7S0,2320
33
- tiptap_python_utils-0.3.0.dist-info/licenses/LICENSE,sha256=pIGeAFdiaTJBpJilmEbR8YQ5agrt7DqbGlTTrzqO6Qo,1089
34
- tiptap_python_utils-0.3.0.dist-info/METADATA,sha256=wuFsehiOaIjsPpYh9FegYN6OeXddbnlnDbIufDlAyPA,10854
35
- tiptap_python_utils-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
36
- tiptap_python_utils-0.3.0.dist-info/top_level.txt,sha256=t7g66MmK6WqTixs5_ZnXe4j-RvH9thuDRKvxk_8F0AQ,20
37
- tiptap_python_utils-0.3.0.dist-info/RECORD,,
34
+ tiptap_python_utils-0.5.0.dist-info/licenses/LICENSE,sha256=pIGeAFdiaTJBpJilmEbR8YQ5agrt7DqbGlTTrzqO6Qo,1089
35
+ tiptap_python_utils-0.5.0.dist-info/METADATA,sha256=Nbe0TNUp5hKT2WjMRmsV2fVkmHqSq3k2dJgm8SiGhqg,11649
36
+ tiptap_python_utils-0.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
37
+ tiptap_python_utils-0.5.0.dist-info/top_level.txt,sha256=t7g66MmK6WqTixs5_ZnXe4j-RvH9thuDRKvxk_8F0AQ,20
38
+ tiptap_python_utils-0.5.0.dist-info/RECORD,,