athena-python-docx 0.1.2__tar.gz → 0.1.5__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 (27) hide show
  1. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/PKG-INFO +1 -1
  2. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/__init__.py +1 -1
  3. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/client.py +16 -8
  4. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/document.py +114 -94
  5. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/table.py +77 -63
  6. athena_python_docx-0.1.5/docx/text/paragraph.py +152 -0
  7. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/text/run.py +41 -56
  8. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/pyproject.toml +1 -1
  9. athena_python_docx-0.1.2/docx/text/paragraph.py +0 -141
  10. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/.gitignore +0 -0
  11. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/CLAUDE.md +0 -0
  12. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/README.md +0 -0
  13. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/_batching.py +0 -0
  14. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/api.py +0 -0
  15. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/enum/__init__.py +0 -0
  16. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/enum/table.py +0 -0
  17. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/enum/text.py +0 -0
  18. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/errors.py +0 -0
  19. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/shared.py +0 -0
  20. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/text/__init__.py +0 -0
  21. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/docx/typing.py +0 -0
  22. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/scripts/publish.sh +0 -0
  23. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/tests/__init__.py +0 -0
  24. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/tests/conftest.py +0 -0
  25. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/tests/test_commands.py +0 -0
  26. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/tests/test_python_docx_api_parity.py +0 -0
  27. {athena_python_docx-0.1.2 → athena_python_docx-0.1.5}/tests/test_smoke_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-docx
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: Drop-in replacement for python-docx that connects to Athena's Superdoc/Keryx collaborative document stack
5
5
  Project-URL: Homepage, https://athenaintelligence.ai
6
6
  Author-email: Athena Intelligence <engineering@athenaintelligence.ai>
@@ -6,7 +6,7 @@ See CLAUDE.md for the API parity contract.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.1.2"
9
+ __version__ = "0.1.5"
10
10
 
11
11
  from docx.api import Document
12
12
 
@@ -172,20 +172,28 @@ class Session:
172
172
  )
173
173
  return self._doc_handle
174
174
 
175
- async def save(self, *, in_place: bool = True) -> None:
176
- """Flush pending mutations to Keryx and persist.
177
-
178
- The AsyncSuperDocClient writes over WebSocket; we add a short
179
- flush delay (matches superdoc_write_utils.py:227-237) to
180
- ensure updates land before dispose().
175
+ async def save(self, *, in_place: bool = True) -> None: # noqa: ARG002
176
+ """Ensure pending mutations have flushed to Keryx.
177
+
178
+ In collab mode (our only mode), the Y-Websocket provider streams
179
+ updates to Keryx as they happen — there's no explicit save RPC
180
+ to call. The underlying Superdoc SDK's ``save({"inPlace": True})``
181
+ targets file-backed docs and errors with "this session has no
182
+ source path" for collab sessions. So save() is a thin flush:
183
+ sleep briefly so in-flight WebSocket frames land, then return.
184
+
185
+ The ``in_place`` kwarg is accepted for python-docx parity but
186
+ has no effect in collab mode (there is no alternate target).
181
187
  """
182
188
  if not self._opened:
183
189
  raise SessionError("Cannot save a session that was never opened.")
184
190
  if self._closed:
185
191
  raise DocumentClosedError(f"Session {self._asset_id} is closed.")
186
192
 
187
- await self._doc_handle.save({"inPlace": in_place})
188
- _log_info(f"Saved {self._asset_id}")
193
+ # Short flush — matches superdoc_write_utils.py's 1-second pre-close
194
+ # delay. Keeps a single in-flight frame from being dropped on close.
195
+ await asyncio.sleep(1)
196
+ _log_info(f"Saved (flushed) {self._asset_id}")
189
197
 
190
198
  async def close(self) -> None:
191
199
  """Close the Superdoc session. Idempotent."""
@@ -51,19 +51,37 @@ class Document:
51
51
  def paragraphs(self) -> list["Paragraph"]:
52
52
  """Return all paragraphs in document order.
53
53
 
54
- python-docx returns a live list; we return a snapshot.
55
- For a live-ish list, call this again.
54
+ python-docx returns a live list; we return a snapshot. For a
55
+ live-ish list, call this again.
56
56
  """
57
57
  from docx.text.paragraph import Paragraph
58
58
 
59
59
  self._ensure_open()
60
- blocks: list[dict] = run_sync(
61
- self._session.doc.blocks.list({"type": "paragraph"}),
60
+ # doc.blocks.list takes `nodeTypes: list[str]` (not `type: str`).
61
+ # Include "heading" too so iterating over paragraphs covers both —
62
+ # python-docx does not distinguish headings from paragraphs at the
63
+ # Document.paragraphs level either (they're all Paragraph instances).
64
+ raw: object = run_sync(
65
+ self._session.doc.blocks.list(
66
+ {"nodeTypes": ["paragraph", "heading"], "includeText": True},
67
+ ),
62
68
  )
63
- return [
64
- Paragraph(session=self._session, node_id=b["nodeId"])
65
- for b in blocks
66
- ]
69
+ # Real shape: {"blocks": [{"kind":"block","nodeType":..,"nodeId":..,"text":..}], ...}
70
+ if isinstance(raw, dict):
71
+ blocks_obj: object = raw.get("blocks", [])
72
+ blocks: list = blocks_obj if isinstance(blocks_obj, list) else []
73
+ elif isinstance(raw, list):
74
+ blocks = raw
75
+ else:
76
+ blocks = []
77
+ out: list["Paragraph"] = []
78
+ for b in blocks:
79
+ if not isinstance(b, dict):
80
+ continue
81
+ node_id: str = str(b.get("nodeId", ""))
82
+ if node_id:
83
+ out.append(Paragraph(session=self._session, node_id=node_id))
84
+ return out
67
85
 
68
86
  @property
69
87
  def tables(self) -> list["Table"]:
@@ -101,48 +119,29 @@ class Document:
101
119
 
102
120
  self._ensure_open()
103
121
 
104
- # Superdoc's markdown parser rejects empty input with
105
- # "Markdown produced no content to insert." For an empty paragraph
106
- # (python-docx's `doc.add_paragraph()` with no args), we use the
107
- # text-type insert with a single space and immediately clear it —
108
- # same pattern as `agora/web/api/super_docs/superdoc_write_utils.py`
109
- # around line 1085.
110
- result: dict
111
- node_id: str
112
- if not text and not (style and style.lower().startswith("heading")):
113
- result = run_sync(
114
- self._session.doc.insert(
115
- {"value": " ", "type": "text"},
116
- ),
117
- )
118
- node_id = _extract_inserted_node_id(result, expected_type="paragraph")
119
- if node_id:
120
- # Clear the placeholder space so the paragraph reads as empty
121
- # (matches python-docx semantics: a freshly-added blank paragraph
122
- # has empty `.text`).
123
- run_sync(
124
- self._session.doc.blocks.update_text(
125
- {"nodeId": node_id, "text": ""},
126
- ),
127
- )
128
- else:
129
- md: str
130
- if style and style.lower().startswith("heading"):
131
- try:
132
- level: int = int(style.rsplit(" ", 1)[-1])
133
- except ValueError:
134
- level = 1
135
- md = f"{'#' * level} {text}\n\n"
136
- else:
137
- md = f"{text}\n\n"
138
-
139
- result = run_sync(
140
- self._session.doc.insert(
141
- {"value": md, "type": "markdown"},
142
- ),
143
- )
144
- node_id = _extract_inserted_node_id(result, expected_type="paragraph")
145
-
122
+ # Use Superdoc's canonical primitive `doc.create.paragraph`. It
123
+ # accepts an `at` anchor and a `text` body; an empty `text` creates
124
+ # a truly-empty paragraph (no markdown-parser edge case).
125
+ #
126
+ # For heading styles we still dispatch to add_heading, which uses
127
+ # `doc.create.heading` (also canonical, takes `level`).
128
+ if style and style.lower().startswith("heading"):
129
+ try:
130
+ level: int = int(style.rsplit(" ", 1)[-1])
131
+ except ValueError:
132
+ level = 1
133
+ return self.add_heading(text=text, level=level)
134
+ if style and style.lower() == "title":
135
+ return self.add_heading(text=text, level=0)
136
+
137
+ params: dict = {
138
+ "text": text,
139
+ "at": {"kind": "documentEnd"},
140
+ }
141
+ result: dict = run_sync(
142
+ self._session.doc.create.paragraph(params),
143
+ )
144
+ node_id: str = _extract_inserted_node_id(result, expected_type="paragraph")
146
145
  if not node_id:
147
146
  raise RuntimeError(
148
147
  f"Superdoc did not return a nodeId for add_paragraph: {result!r}",
@@ -154,13 +153,33 @@ class Document:
154
153
  text: str = "",
155
154
  level: int = 1,
156
155
  ) -> "Paragraph":
157
- """Append a heading (convenience over add_paragraph)."""
156
+ """Append a heading via Superdoc's canonical `doc.create.heading`."""
157
+ from docx.text.paragraph import Paragraph
158
+
159
+ self._ensure_open()
158
160
  if not 0 <= level <= 9:
159
161
  raise ValidationError(
160
162
  f"level must be in 0..9; got {level}",
161
163
  )
162
- style: str = "Title" if level == 0 else f"Heading {level}"
163
- return self.add_paragraph(text=text, style=style)
164
+
165
+ # Map level 0 → Title, 1..9 → Heading N. create.heading understands
166
+ # level directly (level=0 means Title in python-docx semantics; Superdoc
167
+ # accepts levels 1..9 for HeadingN and may interpret 0 as Title depending
168
+ # on version — we pass level as-is).
169
+ params: dict = {
170
+ "text": text,
171
+ "level": level if level >= 1 else 1,
172
+ "at": {"kind": "documentEnd"},
173
+ }
174
+ result: dict = run_sync(
175
+ self._session.doc.create.heading(params),
176
+ )
177
+ node_id: str = _extract_inserted_node_id(result, expected_type="paragraph")
178
+ if not node_id:
179
+ raise RuntimeError(
180
+ f"Superdoc did not return a nodeId for add_heading: {result!r}",
181
+ )
182
+ return Paragraph(session=self._session, node_id=node_id)
164
183
 
165
184
  def add_table(
166
185
  self,
@@ -168,7 +187,7 @@ class Document:
168
187
  cols: int,
169
188
  style: str | None = None,
170
189
  ) -> "Table":
171
- """Append a table with the given dimensions."""
190
+ """Append a table with the given dimensions via `doc.create.table`."""
172
191
  from docx.table import Table
173
192
 
174
193
  self._ensure_open()
@@ -177,18 +196,18 @@ class Document:
177
196
  f"rows and cols must be >= 1; got rows={rows} cols={cols}",
178
197
  )
179
198
 
199
+ # The real param is `columns` (not `cols`). `at` is required to
200
+ # position the table; we always append to document end.
180
201
  result: dict = run_sync(
181
202
  self._session.doc.create.table(
182
- {"rows": rows, "cols": cols},
203
+ {
204
+ "rows": rows,
205
+ "columns": cols,
206
+ "at": {"kind": "documentEnd"},
207
+ },
183
208
  ),
184
209
  )
185
- result_data: dict = (
186
- result.get("result", {}) if isinstance(result, dict) else {}
187
- )
188
- table_info: dict = (
189
- result_data.get("table", {}) if isinstance(result_data, dict) else {}
190
- )
191
- node_id: str = str(table_info.get("nodeId", ""))
210
+ node_id: str = _extract_inserted_node_id(result, expected_type="table")
192
211
  if not node_id:
193
212
  raise RuntimeError(
194
213
  f"Superdoc did not return a nodeId for add_table: {result!r}",
@@ -201,9 +220,7 @@ class Document:
201
220
  ),
202
221
  )
203
222
  # Re-fetch nodeId — set_style may rotate it. Prefer matching the
204
- # original id so concurrent table insertions from other editors
205
- # don't cause us to pick up the wrong table. Only fall back to
206
- # last-in-list (with a warning) when the original id is gone.
223
+ # original id; fall back to last-in-list with a warning.
207
224
  refetch: dict = run_sync(
208
225
  self._session.doc.find({"type": "table"}),
209
226
  )
@@ -233,7 +250,7 @@ class Document:
233
250
  width: "Emu | int | None" = None,
234
251
  height: "Emu | int | None" = None,
235
252
  ) -> None:
236
- """Append an inline image at the end of the document."""
253
+ """Append an inline image via `doc.create.image`."""
237
254
  self._ensure_open()
238
255
 
239
256
  image_bytes: bytes
@@ -259,9 +276,6 @@ class Document:
259
276
  b64: str = base64.b64encode(image_bytes).decode("ascii")
260
277
  data_uri: str = f"data:{content_type};base64,{b64}"
261
278
 
262
- # Default 6-inch wide, proportional 4.5-inch tall at 96 DPI
263
- # (python-docx uses image's intrinsic size; we don't have that here,
264
- # so we default to a reasonable 6x4.5 inch rectangle)
265
279
  w_px: float = _emu_to_px(width) if width is not None else 576.0
266
280
  h_px: float = _emu_to_px(height) if height is not None else 432.0
267
281
 
@@ -276,13 +290,16 @@ class Document:
276
290
  )
277
291
 
278
292
  def add_page_break(self) -> None:
279
- """Append a page break at the end of the document."""
293
+ """Append a page break at the end of the document.
294
+
295
+ Uses `doc.create.section_break` with OOXML `breakType="nextPage"`,
296
+ which is the canonical hard page break (what python-docx's
297
+ ``add_page_break()`` produces).
298
+ """
280
299
  self._ensure_open()
281
- # Use form-feed character; Superdoc's markdown parser converts
282
- # this to a <w:br w:type="page"/> in export.
283
300
  run_sync(
284
- self._session.doc.insert(
285
- {"value": "\f", "type": "text"},
301
+ self._session.doc.create.section_break(
302
+ {"at": {"kind": "documentEnd"}, "breakType": "nextPage"},
286
303
  ),
287
304
  )
288
305
 
@@ -333,29 +350,32 @@ class Document:
333
350
  # ---- Module-level helpers ----
334
351
 
335
352
  def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
336
- """Parse Superdoc's insert() response to find the relevant blockId.
337
-
338
- Superdoc rotates response shapes across versions; we probe in order
339
- of "most current" first. Real observed shape (superdoc-sdk 1.6.0.dev29):
340
-
341
- {
342
- "receipt": {
343
- "resolution": {
344
- "target": {"kind": "text", "blockId": "<uuid>", "range": {...}}
345
- }
346
- },
347
- ...
348
- }
353
+ """Parse Superdoc create/insert response to find the new node's blockId.
349
354
 
350
- Older/alternate shapes we also accept:
351
- - top-level ``blockId``
352
- - nested ``result.insertedNodes[].nodeId`` (when typed = expected_type)
353
- - last element of ``result.nodes``
355
+ Real observed shapes (superdoc-sdk 1.6.0.dev29):
356
+
357
+ doc.create.paragraph/heading/table returns
358
+ {"success": True, "<paragraph|heading|table>": {"nodeId": "..."}}
359
+
360
+ doc.insert (markdown/text) returns
361
+ {"receipt": {"resolution": {"target": {"blockId": "..."}}}, ...}
362
+
363
+ We probe create-shape first (nested by expected_type), then insert-shape.
364
+ Returns "" if nothing matches.
354
365
  """
355
366
  if not isinstance(result, dict):
356
367
  return ""
357
368
 
358
- # Shape 1 (current): receipt.resolution.target.blockId
369
+ # Shape 1 doc.create.paragraph/heading/table/image: top-level key
370
+ # named by the result type holds `{kind, nodeType, nodeId}`.
371
+ for key in (expected_type, "paragraph", "heading", "table", "image"):
372
+ obj = result.get(key)
373
+ if isinstance(obj, dict):
374
+ node_id = obj.get("nodeId")
375
+ if isinstance(node_id, str) and node_id:
376
+ return node_id
377
+
378
+ # Shape 2 — doc.insert: receipt.resolution.target.blockId.
359
379
  receipt: object = result.get("receipt")
360
380
  if isinstance(receipt, dict):
361
381
  resolution: object = receipt.get("resolution")
@@ -366,7 +386,7 @@ def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
366
386
  if block_id:
367
387
  return block_id
368
388
 
369
- # Shape 2: top-level blockId or target.blockId
389
+ # Shape 3 top-level blockId or target.blockId.
370
390
  top_block: object = result.get("blockId")
371
391
  if isinstance(top_block, str) and top_block:
372
392
  return top_block
@@ -376,7 +396,7 @@ def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
376
396
  if tb:
377
397
  return tb
378
398
 
379
- # Shape 3 (legacy): nested result.result.insertedNodes[] / nodes[]
399
+ # Shape 4 (legacy): nested result.result.insertedNodes[] / nodes[].
380
400
  data_obj: object = result.get("result", {})
381
401
  data: dict = data_obj if isinstance(data_obj, dict) else {}
382
402
  nodes_obj: object = data.get("insertedNodes", data.get("nodes", []))
@@ -1,11 +1,16 @@
1
1
  """Table, _Row, _Column, _Cell — python-docx parity.
2
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)
3
+ Phase 1 supported:
4
+ Table.rows / columns via doc.tables.get
5
+ Table.cell(r, c) — builds a _Cell proxy (lazy)
6
+ Table.add_row / add_column — via doc.tables.insert_row / insert_column
7
+ Table.style setter — via doc.tables.set_style
8
+ _Cell.text getter — via doc.tables.get_cells (filtered)
9
+ _Cell.merge(other) — via doc.tables.merge_cells
10
+
11
+ Phase 1 NOT supported (NotImplementedError):
12
+ Table.style getter — via doc.tables.getStyles/getProperties
13
+ _Cell.text setter — needs find+replace-in-cell design
9
14
  """
10
15
 
11
16
  from __future__ import annotations
@@ -20,6 +25,12 @@ if TYPE_CHECKING:
20
25
  from docx.client import Session
21
26
 
22
27
 
28
+ _STUB_SUFFIX = (
29
+ " — not yet mapped to a Superdoc SDK primitive; see "
30
+ "docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
31
+ )
32
+
33
+
23
34
  def _log_warn(msg: str) -> None:
24
35
  print(f"[docx-sdk] WARN: {msg}", file=sys.stderr)
25
36
 
@@ -30,26 +41,28 @@ class Table:
30
41
  self._node_id: str = node_id
31
42
 
32
43
  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(
44
+ """Re-find this table by its original nodeId first; warn+last-in-list
45
+ as a last resort (set_style etc. can rotate ids)."""
46
+ find_result: object = run_sync(
37
47
  self._session.doc.find({"type": "table"}),
38
48
  )
39
- items: list[dict] = find_result.get("items", [])
49
+ items_obj: object = (
50
+ find_result.get("items", []) if isinstance(find_result, dict) else []
51
+ )
52
+ items: list = items_obj if isinstance(items_obj, list) else []
40
53
  for item in items:
41
- addr: dict = item.get("address", {}) if isinstance(item, dict) else {}
42
- if addr.get("nodeId") == self._node_id:
54
+ if not isinstance(item, dict):
55
+ continue
56
+ addr = item.get("address", {})
57
+ if isinstance(addr, dict) and addr.get("nodeId") == self._node_id:
43
58
  return self._node_id
44
- # Not found by exact id — trust the LAST table as ours, with a warning
45
59
  if items:
46
- last_addr: dict = (
47
- items[-1].get("address", {}) if isinstance(items[-1], dict) else {}
48
- )
60
+ last = items[-1] if isinstance(items[-1], dict) else {}
61
+ last_addr = last.get("address", {}) if isinstance(last, dict) else {}
49
62
  new_id: str = str(last_addr.get("nodeId", ""))
50
63
  if new_id:
51
64
  _log_warn(
52
- f"Table nodeId rotated: {self._node_id} -> {new_id}",
65
+ f"table nodeId rotated: {self._node_id} -> {new_id}",
53
66
  )
54
67
  self._node_id = new_id
55
68
  return self._node_id
@@ -57,21 +70,29 @@ class Table:
57
70
  @property
58
71
  def rows(self) -> list["_Row"]:
59
72
  nid: str = self._fresh_node_id()
60
- info_obj: object = run_sync(
61
- self._session.doc.tables.get({"nodeId": nid}),
73
+ info: object = run_sync(self._session.doc.tables.get({"nodeId": nid}))
74
+ info_dict: dict = info if isinstance(info, dict) else {}
75
+ # doc.tables.get may return row count at various paths
76
+ row_count: int = int(
77
+ info_dict.get("rows")
78
+ or info_dict.get("rowCount")
79
+ or (info_dict.get("table", {}) or {}).get("rows")
80
+ or 0,
62
81
  )
63
- info: dict = info_obj if isinstance(info_obj, dict) else {}
64
- row_count: int = int(info.get("rows", 0))
65
82
  return [_Row(table=self, index=i) for i in range(row_count)]
66
83
 
67
84
  @property
68
85
  def columns(self) -> list["_Column"]:
69
86
  nid: str = self._fresh_node_id()
70
- info_obj: object = run_sync(
71
- self._session.doc.tables.get({"nodeId": nid}),
87
+ info: object = run_sync(self._session.doc.tables.get({"nodeId": nid}))
88
+ info_dict: dict = info if isinstance(info, dict) else {}
89
+ col_count: int = int(
90
+ info_dict.get("columns")
91
+ or info_dict.get("cols")
92
+ or info_dict.get("columnCount")
93
+ or (info_dict.get("table", {}) or {}).get("columns")
94
+ or 0,
72
95
  )
73
- info: dict = info_obj if isinstance(info_obj, dict) else {}
74
- col_count: int = int(info.get("cols", 0))
75
96
  return [_Column(table=self, index=j) for j in range(col_count)]
76
97
 
77
98
  def cell(self, row_idx: int, col_idx: int) -> "_Cell":
@@ -81,15 +102,15 @@ class Table:
81
102
  nid: str = self._fresh_node_id()
82
103
  row_count: int = len(self.rows)
83
104
  if row_count < 1:
84
- raise ValidationError(
85
- f"Cannot add row to empty table {nid}",
86
- )
105
+ raise ValidationError(f"Cannot add row to empty table {nid}")
106
+ # Real params: nodeId, rowIndex, position, count
87
107
  run_sync(
88
108
  self._session.doc.tables.insert_row(
89
109
  {
90
- "tableNodeId": nid,
110
+ "nodeId": nid,
91
111
  "rowIndex": row_count - 1,
92
112
  "position": "below",
113
+ "count": 1,
93
114
  },
94
115
  ),
95
116
  )
@@ -99,15 +120,14 @@ class Table:
99
120
  nid: str = self._fresh_node_id()
100
121
  col_count: int = len(self.columns)
101
122
  if col_count < 1:
102
- raise ValidationError(
103
- f"Cannot add column to empty table {nid}",
104
- )
123
+ raise ValidationError(f"Cannot add column to empty table {nid}")
105
124
  run_sync(
106
125
  self._session.doc.tables.insert_column(
107
126
  {
108
- "tableNodeId": nid,
127
+ "nodeId": nid,
109
128
  "columnIndex": col_count - 1,
110
129
  "position": "right",
130
+ "count": 1,
111
131
  },
112
132
  ),
113
133
  )
@@ -115,13 +135,7 @@ class Table:
115
135
 
116
136
  @property
117
137
  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
138
+ raise NotImplementedError("Table.style getter" + _STUB_SUFFIX)
125
139
 
126
140
  @style.setter
127
141
  def style(self, value: str | None) -> None:
@@ -170,28 +184,30 @@ class _Cell:
170
184
  @property
171
185
  def text(self) -> str:
172
186
  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},
187
+ result: object = run_sync(
188
+ self._table._session.doc.tables.get_cells(
189
+ {
190
+ "nodeId": nid,
191
+ "rowIndex": self._row,
192
+ "columnIndex": self._col,
193
+ },
176
194
  ),
177
195
  )
178
- if not isinstance(cell_obj, dict):
196
+ if not isinstance(result, dict):
197
+ return ""
198
+ cells_obj: object = result.get("cells", result.get("items", []))
199
+ cells: list = cells_obj if isinstance(cells_obj, list) else []
200
+ if not cells or not isinstance(cells[0], dict):
179
201
  return ""
180
- return str(cell_obj.get("text", ""))
202
+ first = cells[0]
203
+ text: object = first.get("text")
204
+ if isinstance(text, str):
205
+ return text
206
+ return ""
181
207
 
182
208
  @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
- )
209
+ def text(self, value: str) -> None: # noqa: ARG002
210
+ raise NotImplementedError("_Cell.text setter" + _STUB_SUFFIX)
195
211
 
196
212
  def merge(self, other: "_Cell") -> "_Cell":
197
213
  if other._table is not self._table:
@@ -202,11 +218,9 @@ class _Cell:
202
218
  run_sync(
203
219
  self._table._session.doc.tables.merge_cells(
204
220
  {
205
- "tableNodeId": nid,
206
- "startRow": self._row,
207
- "startCol": self._col,
208
- "endRow": other._row,
209
- "endCol": other._col,
221
+ "nodeId": nid,
222
+ "start": {"rowIndex": self._row, "columnIndex": self._col},
223
+ "end": {"rowIndex": other._row, "columnIndex": other._col},
210
224
  },
211
225
  ),
212
226
  )
@@ -0,0 +1,152 @@
1
+ """Paragraph — mirrors python-docx's Paragraph class.
2
+
3
+ Phase 1 supported:
4
+ .text (getter, via doc.get_node_by_id)
5
+
6
+ Phase 1 NOT supported (raises NotImplementedError with a clear message):
7
+ .text setter — Superdoc has no direct block-text replace
8
+ .runs — returns []; iterating post-hoc runs requires
9
+ a node-content walker we haven't built yet
10
+ .style getter — works via get_node_by_id
11
+ .style setter — uses doc.styles.paragraph.set_style
12
+ .alignment get/set — no direct Superdoc equivalent wired yet
13
+ .add_run(text) — works via doc.insert with target at end of block
14
+
15
+ All stubs point at the follow-up design note in CLAUDE.md so agents
16
+ see actionable errors instead of opaque AttributeErrors from the
17
+ underlying SDK.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ from docx._batching import run_sync
25
+
26
+ if TYPE_CHECKING:
27
+ from docx.client import Session
28
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
29
+ from docx.text.run import Run
30
+
31
+
32
+ _STUB_SUFFIX = (
33
+ " — not yet mapped to a Superdoc SDK primitive; see "
34
+ "docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
35
+ )
36
+
37
+
38
+ def _node_text(session: "Session", node_id: str) -> str:
39
+ """Fetch a block's full text via doc.get_node_by_id."""
40
+ info: object = run_sync(
41
+ session.doc.get_node_by_id({"id": node_id}),
42
+ )
43
+ if not isinstance(info, dict):
44
+ return ""
45
+ node: object = info.get("node")
46
+ if isinstance(node, dict):
47
+ text: object = node.get("text")
48
+ if isinstance(text, str):
49
+ return text
50
+ return ""
51
+
52
+
53
+ class Paragraph:
54
+ """A paragraph block in a Word document."""
55
+
56
+ def __init__(self, *, session: "Session", node_id: str) -> None:
57
+ self._session: "Session" = session
58
+ self._node_id: str = node_id
59
+
60
+ @property
61
+ def text(self) -> str:
62
+ """Return the plain-text content of this paragraph."""
63
+ return _node_text(self._session, self._node_id)
64
+
65
+ @text.setter
66
+ def text(self, value: str) -> None: # noqa: ARG002
67
+ raise NotImplementedError(
68
+ "Paragraph.text setter" + _STUB_SUFFIX,
69
+ )
70
+
71
+ @property
72
+ def runs(self) -> list["Run"]:
73
+ """Return the runs in this paragraph.
74
+
75
+ Returns `[]` until the Phase 2 node-content walker lands.
76
+ """
77
+ return []
78
+
79
+ @property
80
+ def style(self) -> str | None:
81
+ info: object = run_sync(
82
+ self._session.doc.get_node_by_id({"id": self._node_id}),
83
+ )
84
+ if not isinstance(info, dict):
85
+ return None
86
+ node: object = info.get("node")
87
+ if not isinstance(node, dict):
88
+ return None
89
+ style_id: object = node.get("styleId") or node.get("style")
90
+ return str(style_id) if style_id else None
91
+
92
+ @style.setter
93
+ def style(self, value: str | None) -> None:
94
+ target: dict = {
95
+ "kind": "block",
96
+ "nodeType": "paragraph",
97
+ "nodeId": self._node_id,
98
+ }
99
+ run_sync(
100
+ self._session.doc.styles.paragraph.set_style(
101
+ {"target": target, "styleId": value or "Normal"},
102
+ ),
103
+ )
104
+
105
+ @property
106
+ def alignment(self) -> "WD_ALIGN_PARAGRAPH | None":
107
+ raise NotImplementedError(
108
+ "Paragraph.alignment getter" + _STUB_SUFFIX,
109
+ )
110
+
111
+ @alignment.setter
112
+ def alignment(self, value: "WD_ALIGN_PARAGRAPH | None") -> None: # noqa: ARG002
113
+ raise NotImplementedError(
114
+ "Paragraph.alignment setter" + _STUB_SUFFIX,
115
+ )
116
+
117
+ def add_run(
118
+ self,
119
+ text: str = "",
120
+ style: str | None = None, # noqa: ARG002
121
+ ) -> "Run":
122
+ """Append a run to this paragraph.
123
+
124
+ Inserts `text` at the END of the block via doc.insert with a
125
+ block-targeted range. Returns a Run proxy that references the
126
+ newly-inserted character span.
127
+ """
128
+ from docx.text.run import Run
129
+
130
+ # Fetch the block's current text to know where "end of block" is.
131
+ current: str = _node_text(self._session, self._node_id)
132
+ start: int = len(current)
133
+
134
+ # Insert at end of the block. The insert params support targeting
135
+ # a block + offset: {blockId, start, end} at top level where start==end
136
+ # means an insertion point (not a range).
137
+ run_sync(
138
+ self._session.doc.insert(
139
+ {
140
+ "value": text,
141
+ "type": "text",
142
+ "blockId": self._node_id,
143
+ "start": start,
144
+ "end": start,
145
+ },
146
+ ),
147
+ )
148
+ return Run(
149
+ session=self._session,
150
+ block_id=self._node_id,
151
+ range_=(start, start + len(text)),
152
+ )
@@ -1,4 +1,15 @@
1
- """Run and Font — matches python-docx's run.Run and text.font.Font."""
1
+ """Run and Font — mirrors python-docx's run.Run and text.font.Font.
2
+
3
+ Phase 1 supported:
4
+ Run.text getter — via doc.get_node_by_id, sliced by range
5
+ Run.text setter — via doc.replace (exists)
6
+ Run.bold / italic / underline setters — via doc.format.apply (exists)
7
+ Font.name / size / color.rgb setters — via doc.format.apply
8
+
9
+ Phase 1 NOT supported (NotImplementedError with a clear message):
10
+ Run.bold / italic / underline getters — needs inline-format reader
11
+ Font.name / size / color.rgb getters — needs inline-format reader
12
+ """
2
13
 
3
14
  from __future__ import annotations
4
15
 
@@ -11,6 +22,26 @@ if TYPE_CHECKING:
11
22
  from docx.client import Session
12
23
 
13
24
 
25
+ _STUB_SUFFIX = (
26
+ " — not yet mapped to a Superdoc SDK primitive; see "
27
+ "docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
28
+ )
29
+
30
+
31
+ def _block_text(session: "Session", block_id: str) -> str:
32
+ info: object = run_sync(
33
+ session.doc.get_node_by_id({"id": block_id}),
34
+ )
35
+ if not isinstance(info, dict):
36
+ return ""
37
+ node: object = info.get("node")
38
+ if isinstance(node, dict):
39
+ text: object = node.get("text")
40
+ if isinstance(text, str):
41
+ return text
42
+ return ""
43
+
44
+
14
45
  class Run:
15
46
  """A text run — a contiguous span with uniform formatting."""
16
47
 
@@ -35,12 +66,7 @@ class Run:
35
66
 
36
67
  @property
37
68
  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
- )
69
+ full: str = _block_text(self._session, self._block_id)
44
70
  return full[self._range[0] : self._range[1]]
45
71
 
46
72
  @text.setter
@@ -62,7 +88,7 @@ class Run:
62
88
 
63
89
  @property
64
90
  def bold(self) -> bool | None:
65
- return self._get_inline_bool("bold")
91
+ raise NotImplementedError("Run.bold getter" + _STUB_SUFFIX)
66
92
 
67
93
  @bold.setter
68
94
  def bold(self, value: bool | None) -> None:
@@ -72,7 +98,7 @@ class Run:
72
98
 
73
99
  @property
74
100
  def italic(self) -> bool | None:
75
- return self._get_inline_bool("italic")
101
+ raise NotImplementedError("Run.italic getter" + _STUB_SUFFIX)
76
102
 
77
103
  @italic.setter
78
104
  def italic(self, value: bool | None) -> None:
@@ -82,7 +108,7 @@ class Run:
82
108
 
83
109
  @property
84
110
  def underline(self) -> bool | None:
85
- return self._get_inline_bool("underline")
111
+ raise NotImplementedError("Run.underline getter" + _STUB_SUFFIX)
86
112
 
87
113
  @underline.setter
88
114
  def underline(self, value: bool | None) -> None:
@@ -94,42 +120,16 @@ class Run:
94
120
  def font(self) -> "Font":
95
121
  return Font(self)
96
122
 
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
123
 
110
124
  class Font:
111
- """Font properties of a Run.
112
-
113
- Accessed via `run.font`; do not instantiate directly.
114
- """
125
+ """Font properties of a Run. Accessed via `run.font`; do not instantiate."""
115
126
 
116
127
  def __init__(self, run: Run) -> None:
117
128
  self._run: Run = run
118
129
 
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
130
  @property
129
131
  def name(self) -> str | None:
130
- inline: dict = self._get_inline()
131
- value: str = str(inline.get("fontFamily", ""))
132
- return value or None
132
+ raise NotImplementedError("Font.name getter" + _STUB_SUFFIX)
133
133
 
134
134
  @name.setter
135
135
  def name(self, value: str | None) -> None:
@@ -139,11 +139,7 @@ class Font:
139
139
 
140
140
  @property
141
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
142
+ raise NotImplementedError("Font.size getter" + _STUB_SUFFIX)
147
143
 
148
144
  @size.setter
149
145
  def size(self, value: "Pt | int | None") -> None:
@@ -160,25 +156,14 @@ class Font:
160
156
 
161
157
 
162
158
  class _ColorProxy:
163
- """Matches python-docx's `run.font.color` property, which returns a
164
- ColorFormat object with `.rgb` get/set.
165
- """
159
+ """Matches python-docx's `run.font.color` has .rgb get/set."""
166
160
 
167
161
  def __init__(self, run: Run) -> None:
168
162
  self._run: Run = run
169
163
 
170
164
  @property
171
165
  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
166
+ raise NotImplementedError("Font.color.rgb getter" + _STUB_SUFFIX)
182
167
 
183
168
  @rgb.setter
184
169
  def rgb(self, value: "RGBColor | None") -> None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-docx"
7
- version = "0.1.2"
7
+ version = "0.1.5"
8
8
  description = "Drop-in replacement for python-docx that connects to Athena's Superdoc/Keryx collaborative document stack"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,141 +0,0 @@
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
- )