athena-python-docx 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.
docx/table.py ADDED
@@ -0,0 +1,213 @@
1
+ """Table, _Row, _Column, _Cell — python-docx parity.
2
+
3
+ Phase 1 surface:
4
+ Table.rows, Table.columns, Table.cell(row_idx, col_idx)
5
+ Table.add_row(), Table.add_column()
6
+ Table.style (get/set)
7
+ _Cell.text (get/set)
8
+ _Cell.merge(other_cell)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from typing import TYPE_CHECKING
15
+
16
+ from docx._batching import run_sync
17
+ from docx.errors import ValidationError
18
+
19
+ if TYPE_CHECKING:
20
+ from docx.client import Session
21
+
22
+
23
+ def _log_warn(msg: str) -> None:
24
+ print(f"[docx-sdk] WARN: {msg}", file=sys.stderr)
25
+
26
+
27
+ class Table:
28
+ def __init__(self, *, session: "Session", node_id: str) -> None:
29
+ self._session: "Session" = session
30
+ self._node_id: str = node_id
31
+
32
+ def _fresh_node_id(self) -> str:
33
+ """Re-find this table by its position. Superdoc rotates nodeIds
34
+ after style/option changes (see superdoc_write_utils.py:942-948).
35
+ """
36
+ find_result: dict = run_sync(
37
+ self._session.doc.find({"type": "table"}),
38
+ )
39
+ items: list[dict] = find_result.get("items", [])
40
+ for item in items:
41
+ addr: dict = item.get("address", {}) if isinstance(item, dict) else {}
42
+ if addr.get("nodeId") == self._node_id:
43
+ return self._node_id
44
+ # Not found by exact id — trust the LAST table as ours, with a warning
45
+ if items:
46
+ last_addr: dict = (
47
+ items[-1].get("address", {}) if isinstance(items[-1], dict) else {}
48
+ )
49
+ new_id: str = str(last_addr.get("nodeId", ""))
50
+ if new_id:
51
+ _log_warn(
52
+ f"Table nodeId rotated: {self._node_id} -> {new_id}",
53
+ )
54
+ self._node_id = new_id
55
+ return self._node_id
56
+
57
+ @property
58
+ def rows(self) -> list["_Row"]:
59
+ nid: str = self._fresh_node_id()
60
+ info_obj: object = run_sync(
61
+ self._session.doc.tables.get({"nodeId": nid}),
62
+ )
63
+ info: dict = info_obj if isinstance(info_obj, dict) else {}
64
+ row_count: int = int(info.get("rows", 0))
65
+ return [_Row(table=self, index=i) for i in range(row_count)]
66
+
67
+ @property
68
+ def columns(self) -> list["_Column"]:
69
+ nid: str = self._fresh_node_id()
70
+ info_obj: object = run_sync(
71
+ self._session.doc.tables.get({"nodeId": nid}),
72
+ )
73
+ info: dict = info_obj if isinstance(info_obj, dict) else {}
74
+ col_count: int = int(info.get("cols", 0))
75
+ return [_Column(table=self, index=j) for j in range(col_count)]
76
+
77
+ def cell(self, row_idx: int, col_idx: int) -> "_Cell":
78
+ return _Cell(table=self, row=row_idx, col=col_idx)
79
+
80
+ def add_row(self) -> "_Row":
81
+ nid: str = self._fresh_node_id()
82
+ row_count: int = len(self.rows)
83
+ if row_count < 1:
84
+ raise ValidationError(
85
+ f"Cannot add row to empty table {nid}",
86
+ )
87
+ run_sync(
88
+ self._session.doc.tables.insert_row(
89
+ {
90
+ "tableNodeId": nid,
91
+ "rowIndex": row_count - 1,
92
+ "position": "below",
93
+ },
94
+ ),
95
+ )
96
+ return _Row(table=self, index=row_count)
97
+
98
+ def add_column(self) -> "_Column":
99
+ nid: str = self._fresh_node_id()
100
+ col_count: int = len(self.columns)
101
+ if col_count < 1:
102
+ raise ValidationError(
103
+ f"Cannot add column to empty table {nid}",
104
+ )
105
+ run_sync(
106
+ self._session.doc.tables.insert_column(
107
+ {
108
+ "tableNodeId": nid,
109
+ "columnIndex": col_count - 1,
110
+ "position": "right",
111
+ },
112
+ ),
113
+ )
114
+ return _Column(table=self, index=col_count)
115
+
116
+ @property
117
+ def style(self) -> str | None:
118
+ nid: str = self._fresh_node_id()
119
+ info_obj: object = run_sync(
120
+ self._session.doc.tables.get({"nodeId": nid}),
121
+ )
122
+ info: dict = info_obj if isinstance(info_obj, dict) else {}
123
+ style_id: str = str(info.get("styleId", ""))
124
+ return style_id or None
125
+
126
+ @style.setter
127
+ def style(self, value: str | None) -> None:
128
+ nid: str = self._fresh_node_id()
129
+ run_sync(
130
+ self._session.doc.tables.set_style(
131
+ {"nodeId": nid, "styleId": value or "TableGrid"},
132
+ ),
133
+ )
134
+
135
+
136
+ class _Row:
137
+ def __init__(self, *, table: Table, index: int) -> None:
138
+ self._table: Table = table
139
+ self._index: int = index
140
+
141
+ @property
142
+ def cells(self) -> list["_Cell"]:
143
+ col_count: int = len(self._table.columns)
144
+ return [
145
+ _Cell(table=self._table, row=self._index, col=j)
146
+ for j in range(col_count)
147
+ ]
148
+
149
+
150
+ class _Column:
151
+ def __init__(self, *, table: Table, index: int) -> None:
152
+ self._table: Table = table
153
+ self._index: int = index
154
+
155
+ @property
156
+ def cells(self) -> list["_Cell"]:
157
+ row_count: int = len(self._table.rows)
158
+ return [
159
+ _Cell(table=self._table, row=i, col=self._index)
160
+ for i in range(row_count)
161
+ ]
162
+
163
+
164
+ class _Cell:
165
+ def __init__(self, *, table: Table, row: int, col: int) -> None:
166
+ self._table: Table = table
167
+ self._row: int = row
168
+ self._col: int = col
169
+
170
+ @property
171
+ def text(self) -> str:
172
+ nid: str = self._table._fresh_node_id()
173
+ cell_obj: object = run_sync(
174
+ self._table._session.doc.tables.get_cell(
175
+ {"tableNodeId": nid, "row": self._row, "col": self._col},
176
+ ),
177
+ )
178
+ if not isinstance(cell_obj, dict):
179
+ return ""
180
+ return str(cell_obj.get("text", ""))
181
+
182
+ @text.setter
183
+ def text(self, value: str) -> None:
184
+ nid: str = self._table._fresh_node_id()
185
+ run_sync(
186
+ self._table._session.doc.tables.update_cell_text(
187
+ {
188
+ "tableNodeId": nid,
189
+ "row": self._row,
190
+ "col": self._col,
191
+ "text": value,
192
+ },
193
+ ),
194
+ )
195
+
196
+ def merge(self, other: "_Cell") -> "_Cell":
197
+ if other._table is not self._table:
198
+ raise ValidationError(
199
+ "Cannot merge cells from different tables.",
200
+ )
201
+ nid: str = self._table._fresh_node_id()
202
+ run_sync(
203
+ self._table._session.doc.tables.merge_cells(
204
+ {
205
+ "tableNodeId": nid,
206
+ "startRow": self._row,
207
+ "startCol": self._col,
208
+ "endRow": other._row,
209
+ "endCol": other._col,
210
+ },
211
+ ),
212
+ )
213
+ return self
docx/text/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Text submodule — Paragraph and Run (mirrors python-docx.text)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from docx.text.paragraph import Paragraph
6
+ from docx.text.run import Run
7
+
8
+ __all__ = ["Paragraph", "Run"]
docx/text/paragraph.py ADDED
@@ -0,0 +1,141 @@
1
+ """Paragraph — mirrors python-docx's Paragraph class.
2
+
3
+ Phase 1 surface:
4
+ .text (get/set)
5
+ .runs -> list[Run] (returns empty list in Phase 1 — Phase 2 will implement)
6
+ .style (get/set)
7
+ .alignment (get/set)
8
+ .add_run(text, bold=None, italic=None, ...) -> Run
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from docx._batching import run_sync
16
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
17
+
18
+ if TYPE_CHECKING:
19
+ from docx.client import Session
20
+ from docx.text.run import Run
21
+
22
+
23
+ class Paragraph:
24
+ """A paragraph block in a Word document."""
25
+
26
+ def __init__(self, *, session: "Session", node_id: str) -> None:
27
+ self._session: "Session" = session
28
+ self._node_id: str = node_id
29
+
30
+ @property
31
+ def text(self) -> str:
32
+ """Return the plain-text content of this paragraph."""
33
+ block_obj: object = run_sync(
34
+ self._session.doc.blocks.get({"nodeId": self._node_id}),
35
+ )
36
+ if not isinstance(block_obj, dict):
37
+ return ""
38
+ return str(block_obj.get("text", ""))
39
+
40
+ @text.setter
41
+ def text(self, value: str) -> None:
42
+ """Replace the entire text content of this paragraph."""
43
+ run_sync(
44
+ self._session.doc.blocks.update_text(
45
+ {"nodeId": self._node_id, "text": value},
46
+ ),
47
+ )
48
+
49
+ @property
50
+ def runs(self) -> list["Run"]:
51
+ """Return the runs in this paragraph.
52
+
53
+ Phase 1: returns an empty list. Superdoc's block model doesn't
54
+ expose a runs accessor on individual paragraphs in the current
55
+ SDK version; iterating them would require a custom JSON walker.
56
+ Phase 2 will implement this.
57
+ """
58
+ return []
59
+
60
+ @property
61
+ def style(self) -> str | None:
62
+ block_obj: object = run_sync(
63
+ self._session.doc.blocks.get({"nodeId": self._node_id}),
64
+ )
65
+ if not isinstance(block_obj, dict):
66
+ return None
67
+ style_id: str = str(block_obj.get("styleId", ""))
68
+ return style_id or None
69
+
70
+ @style.setter
71
+ def style(self, value: str | None) -> None:
72
+ run_sync(
73
+ self._session.doc.blocks.set_style(
74
+ {"nodeId": self._node_id, "styleId": value or "Normal"},
75
+ ),
76
+ )
77
+
78
+ @property
79
+ def alignment(self) -> "WD_ALIGN_PARAGRAPH | None":
80
+ block_obj: object = run_sync(
81
+ self._session.doc.blocks.get({"nodeId": self._node_id}),
82
+ )
83
+ if not isinstance(block_obj, dict):
84
+ return None
85
+ raw: str = str(block_obj.get("alignment", ""))
86
+ return WD_ALIGN_PARAGRAPH.from_superdoc(raw) if raw else None
87
+
88
+ @alignment.setter
89
+ def alignment(self, value: "WD_ALIGN_PARAGRAPH | None") -> None:
90
+ sd_value: str = (
91
+ value.to_superdoc() if value is not None else "left"
92
+ )
93
+ run_sync(
94
+ self._session.doc.format.set_alignment(
95
+ {
96
+ "target": {"kind": "block", "nodeId": self._node_id},
97
+ "value": sd_value,
98
+ },
99
+ ),
100
+ )
101
+
102
+ def add_run(
103
+ self,
104
+ text: str = "",
105
+ style: str | None = None, # noqa: ARG002
106
+ ) -> "Run":
107
+ """Append a run to this paragraph and return it.
108
+
109
+ Strategy: append text at the end of the paragraph, then return
110
+ a Run proxy targeting that character range.
111
+
112
+ Args:
113
+ text: The text content of the new run.
114
+ style: Ignored in Phase 1 (python-docx parity — accepts the
115
+ kwarg but has no-op behavior until Superdoc exposes
116
+ run-style references).
117
+ """
118
+ from docx.text.run import Run
119
+
120
+ # Get current length to compute range start
121
+ block_obj: object = run_sync(
122
+ self._session.doc.blocks.get({"nodeId": self._node_id}),
123
+ )
124
+ current_text: str = (
125
+ str(block_obj.get("text", "")) if isinstance(block_obj, dict) else ""
126
+ )
127
+ range_start: int = len(current_text)
128
+
129
+ # Append text to the block
130
+ run_sync(
131
+ self._session.doc.blocks.append_text(
132
+ {"nodeId": self._node_id, "text": text},
133
+ ),
134
+ )
135
+ range_end: int = range_start + len(text)
136
+
137
+ return Run(
138
+ session=self._session,
139
+ block_id=self._node_id,
140
+ range_=(range_start, range_end),
141
+ )
docx/text/run.py ADDED
@@ -0,0 +1,187 @@
1
+ """Run and Font — matches python-docx's run.Run and text.font.Font."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from docx._batching import run_sync
8
+ from docx.shared import Pt, RGBColor
9
+
10
+ if TYPE_CHECKING:
11
+ from docx.client import Session
12
+
13
+
14
+ class Run:
15
+ """A text run — a contiguous span with uniform formatting."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ session: "Session",
21
+ block_id: str,
22
+ range_: tuple[int, int],
23
+ ) -> None:
24
+ self._session: "Session" = session
25
+ self._block_id: str = block_id
26
+ self._range: tuple[int, int] = range_
27
+
28
+ @property
29
+ def _text_range(self) -> dict:
30
+ return {
31
+ "kind": "text",
32
+ "blockId": self._block_id,
33
+ "range": {"start": self._range[0], "end": self._range[1]},
34
+ }
35
+
36
+ @property
37
+ def text(self) -> str:
38
+ block_obj: object = run_sync(
39
+ self._session.doc.blocks.get({"nodeId": self._block_id}),
40
+ )
41
+ full: str = (
42
+ str(block_obj.get("text", "")) if isinstance(block_obj, dict) else ""
43
+ )
44
+ return full[self._range[0] : self._range[1]]
45
+
46
+ @text.setter
47
+ def text(self, value: str) -> None:
48
+ run_sync(
49
+ self._session.doc.replace(
50
+ {"target": self._text_range, "text": value},
51
+ ),
52
+ )
53
+ new_end: int = self._range[0] + len(value)
54
+ self._range = (self._range[0], new_end)
55
+
56
+ def _apply_inline(self, **kwargs: object) -> None:
57
+ run_sync(
58
+ self._session.doc.format.apply(
59
+ {"target": self._text_range, "inline": kwargs},
60
+ ),
61
+ )
62
+
63
+ @property
64
+ def bold(self) -> bool | None:
65
+ return self._get_inline_bool("bold")
66
+
67
+ @bold.setter
68
+ def bold(self, value: bool | None) -> None:
69
+ if value is None:
70
+ return
71
+ self._apply_inline(bold=value)
72
+
73
+ @property
74
+ def italic(self) -> bool | None:
75
+ return self._get_inline_bool("italic")
76
+
77
+ @italic.setter
78
+ def italic(self, value: bool | None) -> None:
79
+ if value is None:
80
+ return
81
+ self._apply_inline(italic=value)
82
+
83
+ @property
84
+ def underline(self) -> bool | None:
85
+ return self._get_inline_bool("underline")
86
+
87
+ @underline.setter
88
+ def underline(self, value: bool | None) -> None:
89
+ if value is None:
90
+ return
91
+ self._apply_inline(underline=value)
92
+
93
+ @property
94
+ def font(self) -> "Font":
95
+ return Font(self)
96
+
97
+ def _get_inline_bool(self, name: str) -> bool | None:
98
+ block_obj: object = run_sync(
99
+ self._session.doc.blocks.get({"nodeId": self._block_id}),
100
+ )
101
+ if not isinstance(block_obj, dict):
102
+ return None
103
+ inline_obj: object = block_obj.get("inline", {})
104
+ if not isinstance(inline_obj, dict):
105
+ return None
106
+ val: object = inline_obj.get(name)
107
+ return val if isinstance(val, bool) else None
108
+
109
+
110
+ class Font:
111
+ """Font properties of a Run.
112
+
113
+ Accessed via `run.font`; do not instantiate directly.
114
+ """
115
+
116
+ def __init__(self, run: Run) -> None:
117
+ self._run: Run = run
118
+
119
+ def _get_inline(self) -> dict:
120
+ block_obj: object = run_sync(
121
+ self._run._session.doc.blocks.get({"nodeId": self._run._block_id}),
122
+ )
123
+ if not isinstance(block_obj, dict):
124
+ return {}
125
+ inline_obj: object = block_obj.get("inline", {})
126
+ return inline_obj if isinstance(inline_obj, dict) else {}
127
+
128
+ @property
129
+ def name(self) -> str | None:
130
+ inline: dict = self._get_inline()
131
+ value: str = str(inline.get("fontFamily", ""))
132
+ return value or None
133
+
134
+ @name.setter
135
+ def name(self, value: str | None) -> None:
136
+ if value is None:
137
+ return
138
+ self._run._apply_inline(fontFamily=value)
139
+
140
+ @property
141
+ def size(self) -> "Pt | None":
142
+ inline: dict = self._get_inline()
143
+ sz: object = inline.get("fontSize")
144
+ if isinstance(sz, (int, float)):
145
+ return Pt(float(sz))
146
+ return None
147
+
148
+ @size.setter
149
+ def size(self, value: "Pt | int | None") -> None:
150
+ if value is None:
151
+ return
152
+ pt_value: float = (
153
+ float(value.pt) if hasattr(value, "pt") else float(value)
154
+ )
155
+ self._run._apply_inline(fontSize=pt_value)
156
+
157
+ @property
158
+ def color(self) -> "_ColorProxy":
159
+ return _ColorProxy(self._run)
160
+
161
+
162
+ class _ColorProxy:
163
+ """Matches python-docx's `run.font.color` property, which returns a
164
+ ColorFormat object with `.rgb` get/set.
165
+ """
166
+
167
+ def __init__(self, run: Run) -> None:
168
+ self._run: Run = run
169
+
170
+ @property
171
+ def rgb(self) -> "RGBColor | None":
172
+ block_obj: object = run_sync(
173
+ self._run._session.doc.blocks.get({"nodeId": self._run._block_id}),
174
+ )
175
+ if not isinstance(block_obj, dict):
176
+ return None
177
+ inline_obj: object = block_obj.get("inline", {})
178
+ if not isinstance(inline_obj, dict):
179
+ return None
180
+ hex_: str = str(inline_obj.get("color", ""))
181
+ return RGBColor.from_string(hex_) if hex_ else None
182
+
183
+ @rgb.setter
184
+ def rgb(self, value: "RGBColor | None") -> None:
185
+ if value is None:
186
+ return
187
+ self._run._apply_inline(color=str(value))
docx/typing.py ADDED
@@ -0,0 +1,30 @@
1
+ """Internal TypedDicts shared across the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class UserInfo(TypedDict):
9
+ """Identity info passed to AsyncSuperDocClient for activity log attribution."""
10
+
11
+ name: str
12
+ email: str
13
+
14
+
15
+ class TextRange(TypedDict):
16
+ """A Superdoc text range target.
17
+
18
+ Used in format.apply, replace, hyperlinks.wrap, etc.
19
+ """
20
+
21
+ kind: str
22
+ blockId: str
23
+ range: "CharRange"
24
+
25
+
26
+ class CharRange(TypedDict):
27
+ """Start/end character offsets within a block."""
28
+
29
+ start: int
30
+ end: int