tiptap-python-utils 0.1.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.
@@ -0,0 +1,113 @@
1
+ """Visible text extraction for TipTap content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Iterable, Iterator, List, Optional, Tuple
7
+
8
+ from ..content import Content
9
+ from ..contract import kind
10
+ from ..model import CodeBlock, Heading, Node, Paragraph, TaskItem
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class NodeText:
15
+ node_id: str
16
+ text: str
17
+ html_tag: str = "p"
18
+ context: Optional[str] = None
19
+ node_type: Optional[str] = None
20
+
21
+
22
+ def visible_text(content: Content) -> str:
23
+ return content.text
24
+
25
+
26
+ def word_count(content: Content) -> int:
27
+ return content.word_count()
28
+
29
+
30
+ def text_slices(content: Content, *, context: bool = False) -> List[NodeText]:
31
+ nodes = _content_nodes(content)
32
+ if context:
33
+ return list(_apply_heading_context(nodes))
34
+ return list(nodes)
35
+
36
+
37
+ def _content_nodes(content: Content) -> Iterator[NodeText]:
38
+ if content.root is None:
39
+ return
40
+ for ref in content.refs(parseable=True):
41
+ node = ref.node
42
+ node_id = _node_id(node)
43
+ if not node_id or not isinstance(node, (Paragraph, Heading, TaskItem, CodeBlock)):
44
+ continue
45
+ yield NodeText(
46
+ node_id=node_id,
47
+ text=node.text,
48
+ html_tag=_tag(node),
49
+ node_type=node.kind,
50
+ )
51
+
52
+
53
+ def _node_id(node: Node) -> str:
54
+ if isinstance(node, TaskItem):
55
+ return node.task_item_id
56
+ return node.id
57
+
58
+
59
+ def _tag(node: Node) -> str:
60
+ if isinstance(node, Heading):
61
+ return f"h{node.level}"
62
+ if node.kind == kind.TASK_ITEM:
63
+ return "li"
64
+ if node.kind == kind.CODE_BLOCK:
65
+ return "code"
66
+ return "p"
67
+
68
+
69
+ def _apply_heading_context(nodes: Iterable[NodeText]) -> Iterator[NodeText]:
70
+ context_stack: List[Tuple[int, str]] = []
71
+
72
+ for node in nodes:
73
+ if not node.text.strip():
74
+ context_stack.clear()
75
+ yield node
76
+ continue
77
+
78
+ if _is_heading(node.html_tag):
79
+ level = int(node.html_tag[1])
80
+ parent_context = _context_string(context_stack)
81
+ _push_heading(context_stack, level, node.text)
82
+ yield NodeText(
83
+ node_id=node.node_id,
84
+ text=node.text,
85
+ html_tag=node.html_tag,
86
+ context=parent_context,
87
+ node_type=node.node_type,
88
+ )
89
+ continue
90
+
91
+ yield NodeText(
92
+ node_id=node.node_id,
93
+ text=node.text,
94
+ html_tag=node.html_tag,
95
+ context=_context_string(context_stack),
96
+ node_type=node.node_type,
97
+ )
98
+
99
+
100
+ def _is_heading(html_tag: str) -> bool:
101
+ return len(html_tag) == 2 and html_tag[0] == "h" and html_tag[1].isdigit()
102
+
103
+
104
+ def _push_heading(stack: List[Tuple[int, str]], level: int, text: str) -> None:
105
+ while stack and stack[-1][0] >= level:
106
+ stack.pop()
107
+ stack.append((level, text))
108
+
109
+
110
+ def _context_string(stack: List[Tuple[int, str]]) -> Optional[str]:
111
+ if not stack:
112
+ return None
113
+ return " > ".join(text for _, text in stack)
@@ -0,0 +1,5 @@
1
+ """Tree cursor helpers for typed TipTap nodes."""
2
+
3
+ from .path import node_at_path, replace_at_path
4
+
5
+ __all__ = ["node_at_path", "replace_at_path"]
@@ -0,0 +1,36 @@
1
+ """Immutable tree path operations for typed TipTap nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Tuple
7
+
8
+ from ..exceptions import TiptapValidationError
9
+
10
+ from ..contract import key
11
+ from ..model import Node
12
+
13
+
14
+ def node_at_path(node: Node, path: Tuple[int, ...]) -> Node:
15
+ current = node
16
+ for index in path:
17
+ try:
18
+ current = current.content[index]
19
+ except IndexError as exc:
20
+ raise TiptapValidationError(
21
+ "TipTap selection path is no longer valid"
22
+ ) from exc
23
+ return current
24
+
25
+
26
+ def replace_at_path(node: Node, path: Tuple[int, ...], replacement_node: Node) -> Node:
27
+ if not path:
28
+ return replacement_node
29
+
30
+ index = path[0]
31
+ content = list(node.content)
32
+ if index >= len(content):
33
+ raise TiptapValidationError("TipTap selection path is no longer valid")
34
+
35
+ content[index] = replace_at_path(content[index], path[1:], replacement_node)
36
+ return replace(node, content=tuple(content), present=node.present | {key.CONTENT})
@@ -0,0 +1,7 @@
1
+ """Shared type aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Union
6
+
7
+ DocumentContent = Union[str, Mapping[str, Any]]
@@ -0,0 +1,5 @@
1
+ """Traversal exports."""
2
+
3
+ from .traversal import Ref, Walker, selection_id
4
+
5
+ __all__ = ["Ref", "Walker", "selection_id"]
@@ -0,0 +1,88 @@
1
+ """Depth-first TipTap traversal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterator, Optional, Tuple, Type, TypeVar
7
+
8
+ from ..contract import kind, policy
9
+ from ..model import CodeBlock, Heading, ListItem, Node, Paragraph, TaskItem
10
+
11
+ NodeT = TypeVar("NodeT", bound=Node)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Ref:
16
+ node: Node
17
+ path: Tuple[int, ...]
18
+ parent_kind: str
19
+
20
+ @property
21
+ def node_id(self) -> Optional[str]:
22
+ return selection_id(self.node)
23
+
24
+ @property
25
+ def parseable(self) -> bool:
26
+ return not self.path or policy.is_parseable(self.node.kind, self.parent_kind)
27
+
28
+
29
+ class Walker:
30
+ """Depth-first traversal for typed TipTap content."""
31
+
32
+ def __init__(self, root_or_content: Any) -> None:
33
+ self._root = getattr(root_or_content, "root", root_or_content)
34
+
35
+ def walk(self) -> Iterator[Node]:
36
+ if self._root is None:
37
+ return
38
+ yield from _walk(self._root)
39
+
40
+ def of_type(self, node_class: Type[NodeT]) -> list[NodeT]:
41
+ return [node for node in self.walk() if isinstance(node, node_class)]
42
+
43
+ def refs(self, *, parseable: bool = False) -> Iterator[Ref]:
44
+ if self._root is None:
45
+ return
46
+ yield from _refs(
47
+ self._root,
48
+ path=(),
49
+ parent_kind=kind.DOC,
50
+ parseable=parseable,
51
+ )
52
+
53
+
54
+ def selection_id(node: Node) -> Optional[str]:
55
+ attrs_id = policy.node_id(getattr(node, "attrs", {}))
56
+ if attrs_id:
57
+ return attrs_id
58
+ if isinstance(node, TaskItem):
59
+ return node.local_task_item_id or node.task_item_id or None
60
+ if isinstance(node, (Paragraph, Heading, ListItem, CodeBlock)):
61
+ return node.id or None
62
+ return None
63
+
64
+
65
+ def _walk(node: Node) -> Iterator[Node]:
66
+ yield node
67
+ for child in node.content:
68
+ yield from _walk(child)
69
+
70
+
71
+ def _refs(
72
+ node: Node,
73
+ *,
74
+ path: tuple[int, ...],
75
+ parent_kind: str,
76
+ parseable: bool,
77
+ ) -> Iterator[Ref]:
78
+ ref = Ref(node=node, path=path, parent_kind=parent_kind)
79
+ if not parseable or ref.parseable:
80
+ yield ref
81
+
82
+ for index, child in enumerate(node.content):
83
+ yield from _refs(
84
+ child,
85
+ path=path + (index,),
86
+ parent_kind=node.kind,
87
+ parseable=parseable,
88
+ )
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiptap_python_utils
3
+ Version: 0.1.0
4
+ Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
5
+ Author: tiptap_python_utils contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 tiptap_python_utils contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/tugkanpilka/tiptap-python-utils
29
+ Project-URL: Repository, https://github.com/tugkanpilka/tiptap-python-utils
30
+ Project-URL: Issues, https://github.com/tugkanpilka/tiptap-python-utils/issues
31
+ Project-URL: Changelog, https://github.com/tugkanpilka/tiptap-python-utils/blob/main/CHANGELOG.md
32
+ Keywords: tiptap,prosemirror,json,ast,editor
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Typing :: Typed
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Provides-Extra: dev
46
+ Requires-Dist: build>=1.2; extra == "dev"
47
+ Requires-Dist: pytest>=8; extra == "dev"
48
+ Requires-Dist: twine>=5; extra == "dev"
49
+ Dynamic: license-file
50
+
51
+ # tiptap_python_utils
52
+
53
+ Python utilities for TipTap JSON content.
54
+
55
+ `tiptap_python_utils` parses TipTap documents into typed Python nodes, preserves
56
+ unknown/custom nodes for lossless round trips, and provides small helpers for
57
+ traversal, immutable edits, visible text extraction, task queries, and shared
58
+ node synchronization.
59
+
60
+ The package has no runtime dependencies.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install tiptap_python_utils
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ```python
71
+ from tiptap_python_utils import Content, Paragraph, Text, kind
72
+
73
+ raw = {
74
+ "type": "doc",
75
+ "content": [
76
+ {
77
+ "type": "paragraph",
78
+ "attrs": {"id": "p1"},
79
+ "content": [{"type": "text", "text": "Old"}],
80
+ }
81
+ ],
82
+ }
83
+
84
+ updated = Content.require(raw).where_id("p1").text("New").dump()
85
+ ```
86
+
87
+ ## Typed Nodes
88
+
89
+ Build typed nodes directly and serialize them back to TipTap-compatible JSON:
90
+
91
+ ```python
92
+ node = Paragraph(id="p1", content=(Text(value="Hello"),))
93
+ doc = Content.wrap(node.raw())
94
+ ```
95
+
96
+ Unknown/custom node types are preserved as `Unknown` nodes and round-trip without
97
+ dropping extra fields.
98
+
99
+ ## Selection And Editing
100
+
101
+ Select nodes by id or TipTap kind:
102
+
103
+ ```python
104
+ updated = Content.require(raw).of(kind.PARAGRAPH).attr("color", "blue").dump()
105
+ ```
106
+
107
+ Selection methods return updated immutable content:
108
+
109
+ ```python
110
+ updated = (
111
+ Content.require(raw)
112
+ .where_id("p1")
113
+ .text("Updated")
114
+ .attr("data-state", "reviewed")
115
+ .dump()
116
+ )
117
+ ```
118
+
119
+ ## Text Extraction
120
+
121
+ Extract visible text or contextual slices:
122
+
123
+ ```python
124
+ from tiptap_python_utils import Content, text_slices, visible_text, word_count
125
+
126
+ content = Content.require(raw)
127
+
128
+ plain_text = visible_text(content)
129
+ count = word_count(content)
130
+ slices = text_slices(content, context=True)
131
+ ```
132
+
133
+ ## Tasks
134
+
135
+ ```python
136
+ from tiptap_python_utils import Content, has_open_tasks, open_tasks
137
+
138
+ content = Content.require(raw)
139
+
140
+ pending = has_open_tasks(content)
141
+ items = open_tasks(content)
142
+ ```
143
+
144
+ ## Public API
145
+
146
+ Common imports are available from the package root:
147
+
148
+ ```python
149
+ from tiptap_python_utils import (
150
+ Content,
151
+ Paragraph,
152
+ TaskItem,
153
+ Text,
154
+ append_node,
155
+ has_open_tasks,
156
+ kind,
157
+ replace_node,
158
+ shared_families,
159
+ sync_shared,
160
+ text_slices,
161
+ )
162
+ ```
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ python -m pip install -e ".[dev]"
168
+ pytest -q
169
+ ```
170
+
171
+ Build and check a release artifact:
172
+
173
+ ```bash
174
+ python -m build
175
+ python -m twine check dist/*
176
+ ```
@@ -0,0 +1,31 @@
1
+ tiptap_python_utils/__init__.py,sha256=3Zz8DTxEaQxeLqhLVpwXhOaVV1SAKq_2JgwSXbBKySA,1673
2
+ tiptap_python_utils/content.py,sha256=KP-M4lDt7lBsQJTI137pnPmb2fthi2YBD2BgmHfvq1k,4937
3
+ tiptap_python_utils/exceptions.py,sha256=GIP91_6gaz4Vp-uMbmZBFFpnHHvToKYWyYf78Apt0ns,150
4
+ tiptap_python_utils/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ tiptap_python_utils/types.py,sha256=f6ocw9oOTyj_khush3bP5cHS2YpybKS0jQgjzSNVx_k,152
6
+ tiptap_python_utils/codec/__init__.py,sha256=Ip9rhIR2-1R_See3kU3xpATiEgSpz5eZfbQK3OrJjrM,442
7
+ tiptap_python_utils/codec/json.py,sha256=RIxKbG88vGYS57IylJqwNjpNjOLrb8mmkEKcNTA69IY,3176
8
+ tiptap_python_utils/contract/__init__.py,sha256=gJMHPqIl6vkcXAOKNKtZIbs8aQs1abqykEpc5i3abQY,115
9
+ tiptap_python_utils/contract/key.py,sha256=6T4Y-26hESduXmLaNOOpbZ-zTYkHgABvDCHjL6b2VDM,260
10
+ tiptap_python_utils/contract/kind.py,sha256=I-vkJ9mskw07qjbIR5uC1s3z0AvtwA2y3qovkzTsY2Q,297
11
+ tiptap_python_utils/contract/policy.py,sha256=AeJfLv07JWqhuJMakUgz1Y-bMpac28h426bj17hKvwk,1193
12
+ tiptap_python_utils/edit/__init__.py,sha256=8d8K5_v2ka8TnvNghwuyTwBb99OJTIHNyx_i8uHg3cw,251
13
+ tiptap_python_utils/edit/commands.py,sha256=rifp_43vB_ICyI4McYeBmpY3xoY5sB_RFxNOzFbwQAA,5545
14
+ tiptap_python_utils/model/__init__.py,sha256=3m5vTlc2cZTU0atV6xWFvU38bxVjI-xYOR3bQtqEyTM,10320
15
+ tiptap_python_utils/select/__init__.py,sha256=lFavos0E3w2D_keUPDa5xkWhIjkQiee7yNK7X-G-U6o,88
16
+ tiptap_python_utils/select/selection.py,sha256=DWfdGLhMF8HDrBJ1Jiv8XVnPXuexzce7JhzAflynTdw,2409
17
+ tiptap_python_utils/shared/__init__.py,sha256=DL09HcsabBxFXBcvnd7hH3FLT3sR9EZkjurPPQM8U5Q,402
18
+ tiptap_python_utils/shared/service.py,sha256=sWIL32sOGZo8ydzzTGghRTh56-EQGlrZgmEPaxPcIFk,4679
19
+ tiptap_python_utils/tasks/__init__.py,sha256=EXNu9YfJHBjyUZGf5HJIvaW7Ub_aN6fw_CZBNjBW0Og,154
20
+ tiptap_python_utils/tasks/query.py,sha256=59hb0fubBHlfOQekv_KY3DbTEBPFdYh_1Tc9LuFsL9w,464
21
+ tiptap_python_utils/text/__init__.py,sha256=F8PVtbGQlshGAtNLGNhGt8eyOwyE3Z5GC2lZLovxWg4,170
22
+ tiptap_python_utils/text/extract.py,sha256=aluDO3Hp0M7cyLoTtpZAeWHECF5Gh8ApZFdgs_se1aA,3046
23
+ tiptap_python_utils/tree/__init__.py,sha256=YzlXRkQrlRGYmkWbpZ8mutZOmK4BzmiZaBqUtFv8c8c,146
24
+ tiptap_python_utils/tree/path.py,sha256=bHmBFIZ3BV4FVlB79HhSgjeP6qHdNXClB9DFovSe8fs,1067
25
+ tiptap_python_utils/walk/__init__.py,sha256=hKU7DhRY6HVlxCxyWmnZtoobWNx5MCIT0OoErGe4Bp4,120
26
+ tiptap_python_utils/walk/traversal.py,sha256=6GDFmPAuo_t4FeOeV6hqrqvwcTZ8G93ep21-hrFt7S0,2320
27
+ tiptap_python_utils-0.1.0.dist-info/licenses/LICENSE,sha256=pIGeAFdiaTJBpJilmEbR8YQ5agrt7DqbGlTTrzqO6Qo,1089
28
+ tiptap_python_utils-0.1.0.dist-info/METADATA,sha256=JNf4QQALy1Cw400CNm65_NiXfhEFcqyqgUHAIMd1I9k,4895
29
+ tiptap_python_utils-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
30
+ tiptap_python_utils-0.1.0.dist-info/top_level.txt,sha256=t7g66MmK6WqTixs5_ZnXe4j-RvH9thuDRKvxk_8F0AQ,20
31
+ tiptap_python_utils-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tiptap_python_utils contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tiptap_python_utils