athena-python-pptx 0.7.1__tar.gz → 0.9.0__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 (86) hide show
  1. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/CHANGELOG.md +62 -0
  2. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/API_PARITY_EXCEPTIONS.md +13 -0
  4. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/__init__.py +1 -1
  5. athena_python_pptx-0.9.0/pptx/_citations.py +54 -0
  6. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/batching.py +11 -2
  7. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/client.py +14 -10
  8. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/commands.py +136 -22
  9. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/__init__.py +49 -16
  10. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/slides.py +141 -18
  11. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/__init__.py +164 -39
  12. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/typing.py +3 -0
  13. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pyproject.toml +1 -1
  14. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/.gitignore +0 -0
  15. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/API_PARITY_REPORT.md +0 -0
  16. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/CLAUDE.md +0 -0
  17. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/DEV-GUIDE.md +0 -0
  18. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PARITY_QUESTIONS.md +0 -0
  19. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PUBLISHING.md +0 -0
  20. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/README.md +0 -0
  21. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/athena-api.json +0 -0
  22. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/athena-api.md +0 -0
  23. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_athena_extension.py +0 -0
  24. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_ptc.py +0 -0
  25. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_references.py +0 -0
  26. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/action.py +0 -0
  27. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/__init__.py +0 -0
  28. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/axis.py +0 -0
  29. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/category.py +0 -0
  30. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/chart.py +0 -0
  31. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/data.py +0 -0
  32. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/datalabel.py +0 -0
  33. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/legend.py +0 -0
  34. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/marker.py +0 -0
  35. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/plot.py +0 -0
  36. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/point.py +0 -0
  37. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/series.py +0 -0
  38. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/xlsx.py +0 -0
  39. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/decorators.py +0 -0
  40. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/__init__.py +0 -0
  41. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/chtfmt.py +0 -0
  42. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/color.py +0 -0
  43. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/effect.py +0 -0
  44. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/fill.py +0 -0
  45. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/line.py +0 -0
  46. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/docgen.py +0 -0
  47. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/__init__.py +0 -0
  48. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/action.py +0 -0
  49. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/chart.py +0 -0
  50. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/dml.py +0 -0
  51. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/lang.py +0 -0
  52. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/shapes.py +0 -0
  53. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/text.py +0 -0
  54. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/errors.py +0 -0
  55. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/exc.py +0 -0
  56. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/media.py +0 -0
  57. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/package.py +0 -0
  58. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/__init__.py +0 -0
  59. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/_base.py +0 -0
  60. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/chart.py +0 -0
  61. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/coreprops.py +0 -0
  62. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/embeddedpackage.py +0 -0
  63. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/image.py +0 -0
  64. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/media.py +0 -0
  65. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/presentation.py +0 -0
  66. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/slide.py +0 -0
  67. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/presentation.py +0 -0
  68. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/autoshape.py +0 -0
  69. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/base.py +0 -0
  70. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/connector.py +0 -0
  71. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/freeform.py +0 -0
  72. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/graphfrm.py +0 -0
  73. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/group.py +0 -0
  74. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/picture.py +0 -0
  75. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/placeholder.py +0 -0
  76. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/shapetree.py +0 -0
  77. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shared.py +0 -0
  78. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/slide.py +0 -0
  79. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/spec.py +0 -0
  80. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/table.py +0 -0
  81. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/fonts.py +0 -0
  82. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/layout.py +0 -0
  83. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/text.py +0 -0
  84. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/types.py +0 -0
  85. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/units.py +0 -0
  86. {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/util.py +0 -0
@@ -2,6 +2,68 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.9.0 — eager validation, table overflow diagnostics, and text caps
6
+
7
+ Publishes the SDK changes already deployed with PPTX Studio after 0.8.1.
8
+
9
+ - Invalid buffered commands now raise at the originating SDK call instead of
10
+ waiting until `flush()`. Validation errors include command context such as
11
+ the slide index, shape ID, or name, making the offending edit actionable
12
+ without sacrificing the final send-time validation backstop.
13
+ - `Slide.validate()` now warns when table-cell text is likely to wrap beyond
14
+ the available column width. The offline heuristic accounts for cell margins,
15
+ font size, merged-column spans, digits, and common punctuation.
16
+ - `Font.cap` and the `TextCap` type expose PowerPoint's native `none`, `all`,
17
+ and `small` capitalization modes. The value hydrates from existing decks and
18
+ survives full-frame text re-emission.
19
+
20
+ The public text-capitalization surface makes this a minor release.
21
+
22
+ ## 0.8.1 — version-string fix (re-cut of 0.8.0)
23
+
24
+ Functionally identical to 0.8.0. The 0.8.0 wheel was auto-published from a
25
+ tree where `pptx/__init__.py::__version__` was never bumped off `0.7.1`, so
26
+ `import pptx; pptx.__version__` reported `0.7.1` even though the wheel
27
+ contained the surgical-`SetRunText` + faithful-hydration code. PyPI versions
28
+ are immutable, so 0.8.1 re-cuts the release with the corrected `__version__`
29
+ string. No behavior change vs 0.8.0; use 0.8.1 (the `presentation-exec:v55`
30
+ snapshot bundles it).
31
+
32
+ ## 0.8.0 — Surgical run text + faithful style hydration
33
+
34
+ Closes the "agent edited deck text and the styling vanished" class of bugs
35
+ (font size/weight/color jumping to the renderer's 18pt default after a text
36
+ rewrite).
37
+
38
+ **`run.text = …` is now surgical.** The setter (and the run text helpers
39
+ `upper/lower/capitalize/title/strip/clear/replace`) emits the new
40
+ `SetRunText` command — the server updates just that run's text, leaving its
41
+ style, hyperlink, sibling runs, and paragraph properties untouched — instead
42
+ of re-serializing the entire text frame from local state. Setting text on a
43
+ freshly `add_run()`-appended run appends server-side inheriting the previous
44
+ run's style (continue-typing semantics). Requires a pptx-studio deployment
45
+ that knows `SetRunText`; set `ATHENA_PPTX_LEGACY_RUN_TEXT=1` to restore the
46
+ old whole-frame re-emit against older servers.
47
+
48
+ **Snapshot hydration is now faithful.** `Run` hydration previously mapped
49
+ only 8 style keys (bold/italic/underline/fontSizePt/fontFamily/fontRef/
50
+ colorHex/spacingPt) — any full-frame re-emit (`paragraph.text`,
51
+ `add_paragraph()`, pre-0.8 `run.text`) silently stripped everything else
52
+ from every run in the shape. Now:
53
+
54
+ - `schemeColor`/`brightness` hydrate into the color API —
55
+ `run.font.color.theme_color` / `.brightness` return the ingested values.
56
+ - `strike`/`strikethrough`, `subscript`/`superscript`/`baseline`, and
57
+ `languageId` hydrate into their typed fields.
58
+ - Everything else the server sends (`cap`, `highlightColorHex`,
59
+ `highlightSchemeColor`, future keys) is carried verbatim in
60
+ `Font._extra_style` and reproduced by re-serialization.
61
+
62
+ Pairs with the server-side `applySetText` preservation upgrade (same PR):
63
+ unchanged lines keep their exact runs, rewritten lines keep the replaced
64
+ line's explicit style, and new lines continue the surrounding style instead
65
+ of collapsing to the 18pt default.
66
+
5
67
  ## 0.7.1 — CommandBuffer thread-safety
6
68
 
7
69
  Fixes a data-loss/duplication race in `CommandBuffer`. `self._commands` was
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration
5
5
  Project-URL: Homepage, https://github.com/pptx-studio/python-sdk
6
6
  Project-URL: Documentation, https://docs.pptx-studio.com/sdk/python
@@ -164,6 +164,19 @@ takes the rest of the queued edits down with it.
164
164
 
165
165
  These properties/methods were added because LLM agents (Claude, GPT, etc.) frequently generate code using these patterns. Without them, agent-generated table code fails at runtime.
166
166
 
167
+ ### `Font.cap` — PowerPoint-native capitalization mode
168
+
169
+ The SDK exposes PowerPoint's OOXML run capitalization property as
170
+ `run.font.cap`, accepting `"none"`, `"all"`, or `"small"`. Stock
171
+ python-pptx 1.0.2 does not expose `a:rPr/@cap`, but the Athena SDK needs a
172
+ typed surface so template-authored all-caps and small-caps text survives
173
+ agent edits and full-frame text re-emission.
174
+
175
+ ```python
176
+ run.font.cap = "all"
177
+ assert run.font.cap == "all"
178
+ ```
179
+
167
180
  ### `TableCell` — RGB tuple aliases
168
181
 
169
182
  | Property | Type | Maps to | Why |
@@ -133,7 +133,7 @@ def flush_all() -> None:
133
133
  _active_buffers[:] = alive
134
134
 
135
135
 
136
- __version__ = "0.7.1"
136
+ __version__ = "0.9.0"
137
137
 
138
138
  __all__ = [
139
139
  # Main entry point
@@ -0,0 +1,54 @@
1
+ """Helpers for SDK-native Athena citations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ _ATHENA_SPACES_HOST_SUFFIXES: tuple[str, ...] = (
8
+ "athenaintel.com",
9
+ "athenaintelligence.ai",
10
+ )
11
+
12
+
13
+ def _is_athena_spaces_host(hostname: str | None) -> bool:
14
+ host = (hostname or "").lower()
15
+ return any(
16
+ host == suffix or host.endswith(f".{suffix}")
17
+ for suffix in _ATHENA_SPACES_HOST_SUFFIXES
18
+ )
19
+
20
+
21
+ def normalize_citation_string(citation_string: str) -> str:
22
+ value = citation_string.strip()
23
+ if not value:
24
+ raise ValueError("citation_string must not be empty")
25
+
26
+ parsed = urlparse(value)
27
+ if parsed.scheme in ("http", "https") and parsed.netloc:
28
+ if parsed.path.rstrip("/") == "/dashboard/spaces":
29
+ if not _is_athena_spaces_host(parsed.hostname):
30
+ raise ValueError(
31
+ "absolute Athena Spaces citation URLs must use an Athena host"
32
+ )
33
+ asset_ids = parse_qs(parsed.query).get("asset_ids")
34
+ if not asset_ids or not asset_ids[0]:
35
+ raise ValueError("Athena Spaces citation URLs must include asset_ids")
36
+ return value
37
+
38
+ if not parsed.scheme and value.startswith("/"):
39
+ relative = urlparse(value)
40
+ if relative.netloc:
41
+ raise ValueError(
42
+ "protocol-relative Athena Spaces citation URLs are not allowed; "
43
+ "use an absolute Athena host"
44
+ )
45
+ if relative.path.rstrip("/") == "/dashboard/spaces":
46
+ asset_ids = parse_qs(relative.query).get("asset_ids")
47
+ if asset_ids and asset_ids[0]:
48
+ return value
49
+ raise ValueError("Athena Spaces citation URLs must include asset_ids")
50
+
51
+ raise ValueError(
52
+ "citation_string must be a serialized citation URL, such as "
53
+ "'/dashboard/spaces/?asset_ids=...' or an 'https://...' URL"
54
+ )
@@ -19,7 +19,7 @@ from threading import local
19
19
  from typing import TYPE_CHECKING, Any, Generator, Optional
20
20
 
21
21
  from . import _ptc
22
- from .commands import AnyCommand
22
+ from .commands import AnyCommand, validate_command
23
23
  from .errors import BatchPartialError
24
24
  from .typing import CommandsResponse
25
25
 
@@ -234,12 +234,21 @@ class CommandBuffer:
234
234
 
235
235
  Returns:
236
236
  Response if sent immediately, None if buffered
237
+
238
+ Raises:
239
+ ValidationError: If the command's parameters are invalid. Raised
240
+ here — at the API call that created the command — rather than
241
+ at the eventual ``flush()``, so the traceback points at the
242
+ offending user line and the invalid command is never buffered
243
+ (a flush-time failure would discard the whole pending batch).
237
244
  """
238
245
  # Surface any error captured by the background auto-flush timer
239
246
  # before accepting more work — otherwise the user could continue
240
247
  # queueing commands on top of a partially-failed batch.
241
248
  self._raise_deferred_error()
242
249
 
250
+ validate_command(command)
251
+
243
252
  # PTC begin: emit only for commands in the allow-list. Other
244
253
  # commands still flow through the buffer (and through the batched
245
254
  # HTTP POST), they just don't produce a sub-tool-card. ``emit_end``
@@ -365,7 +374,7 @@ class CommandBuffer:
365
374
  if len(chunks) > 1:
366
375
  try:
367
376
  for command in commands:
368
- command.validate()
377
+ validate_command(command)
369
378
  except Exception:
370
379
  with self._commands_lock:
371
380
  self._commands = commands + self._commands
@@ -37,7 +37,7 @@ from .typing import (
37
37
  ExportStatus,
38
38
  RenderStatus,
39
39
  )
40
- from .commands import AnyCommand
40
+ from .commands import AnyCommand, validate_command
41
41
 
42
42
 
43
43
  SDK_VERSION = "0.1.0"
@@ -129,9 +129,13 @@ class Client:
129
129
  duplicate slides/shapes/cells). This mirrors docx-studio's
130
130
  ``_build_retry_session`` policy:
131
131
 
132
- - **Status-only retries** (``connect``/``read``/``other`` disabled).
133
- A transport drop *after* the server processed the batch must never
134
- trigger an automatic resend.
132
+ - **Connect retries** (``connect=2``) are enabled because a TCP
133
+ connection failure means no data ever reached the server — zero
134
+ risk of double-applying commands. Critical during Kubernetes
135
+ rolling deployments where old pods stop accepting connections.
136
+ - **Read retries** remain disabled (``read=0``): a transport drop
137
+ *after* the server processed the batch must never trigger an
138
+ automatic resend.
135
139
  - **Retry only on transient statuses raised before any commit**
136
140
  (429/502/503/504). ``500`` is intentionally removed — a 500 from
137
141
  ``/commands`` can mean the batch was applied and then
@@ -140,16 +144,14 @@ class Client:
140
144
  concurrent agents don't thunder-herd a recovering pod.
141
145
 
142
146
  Idempotent reads (GET/HEAD) and DELETE remain in ``allowed_methods``
143
- so they still retry on those transient statuses; disabling
144
- connect/read retries only costs a resend on a mid-flight socket drop,
145
- which is the safe trade.
147
+ so they still retry on those transient statuses.
146
148
  """
147
149
  session = requests.Session()
148
150
 
149
151
  retry_kwargs: dict[str, Any] = {
150
152
  "total": None,
151
153
  "status": 3,
152
- "connect": 0,
154
+ "connect": 2,
153
155
  "read": 0,
154
156
  "other": 0,
155
157
  "backoff_factor": 0.5,
@@ -645,9 +647,11 @@ class Client:
645
647
  Returns:
646
648
  Response with applied status, created IDs, and optional snapshot
647
649
  """
648
- # Validate all commands
650
+ # Validate all commands (backstop — CommandBuffer.add validates
651
+ # eagerly at the creating call site; this covers direct client use
652
+ # and commands mutated after they were buffered).
649
653
  for cmd in commands:
650
- cmd.validate()
654
+ validate_command(cmd)
651
655
 
652
656
  payload = {
653
657
  "client": {"type": SDK_CLIENT_TYPE, "version": SDK_VERSION},
@@ -14,7 +14,8 @@ __athena_extension_description__: str = (
14
14
  __athena_extension_since__: str = "0.1.0"
15
15
 
16
16
  from dataclasses import dataclass, field, asdict
17
- from typing import Any, Optional, Union
17
+ from typing import Any, ClassVar, Optional, Union
18
+ from ._citations import normalize_citation_string
18
19
  from .errors import ValidationError
19
20
  from .typing import ShapeId, SlideId, TextRunPath, TextStyle
20
21
 
@@ -38,6 +39,26 @@ class Command:
38
39
  components = snake_str.split("_")
39
40
  return components[0] + "".join(x.title() for x in components[1:])
40
41
 
42
+ # Identifying fields surfaced in validation errors so a failure names the
43
+ # exact command instance ("AddShape(slide_index=4, name='Card 2')") rather
44
+ # than only the bad value.
45
+ _CONTEXT_FIELDS: ClassVar[tuple[str, ...]] = (
46
+ "slide_index",
47
+ "index",
48
+ "shape_id",
49
+ "name",
50
+ )
51
+
52
+ def context_label(self) -> str:
53
+ """Short human-readable identity for this command instance."""
54
+ parts: list[str] = []
55
+ for field_name in self._CONTEXT_FIELDS:
56
+ value = getattr(self, field_name, None)
57
+ if value is not None:
58
+ parts.append(f"{field_name}={value!r}")
59
+ label = type(self).__name__
60
+ return f"{label}({', '.join(parts)})" if parts else label
61
+
41
62
  def validate(self) -> None:
42
63
  """Validate command parameters. Raises ValidationError if invalid."""
43
64
  pass
@@ -48,6 +69,23 @@ class Command:
48
69
  raise NotImplementedError
49
70
 
50
71
 
72
+ def validate_command(command: "Command") -> None:
73
+ """Validate ``command``, prefixing any ValidationError with its identity.
74
+
75
+ The bare per-command ``validate()`` messages name only the bad field/value
76
+ ("height must be >= 0 EMU"); when validation runs away from the creating
77
+ call site (batched flush, multi-command POST) that alone can't be traced
78
+ back to a shape or slide. Every SDK-internal validation path goes through
79
+ this wrapper so the error always carries the command context.
80
+ """
81
+ try:
82
+ command.validate()
83
+ except ValidationError as exc:
84
+ raise ValidationError(
85
+ f"{command.context_label()}: {exc.message}", exc.field
86
+ ) from None
87
+
88
+
51
89
  @dataclass
52
90
  class AddTextBox(Command):
53
91
  """
@@ -193,6 +231,38 @@ class SetRunStyle(Command):
193
231
  raise ValidationError("path must contain 'p' and 'r'", "path")
194
232
 
195
233
 
234
+ @dataclass
235
+ class SetRunText(Command):
236
+ """
237
+ Surgically set the text of a single run.
238
+
239
+ Unlike ``SetText`` (which replaces the whole text frame), this leaves the
240
+ run's style, hyperlink, sibling runs, and paragraph properties untouched
241
+ server-side. When ``path.r`` equals the paragraph's current run count the
242
+ server appends a run inheriting the previous run's style, matching the
243
+ ``add_run()`` + ``run.text = ...`` flow.
244
+
245
+ Args:
246
+ shape_id: ID of the shape containing the text
247
+ path: Path to the text run (paragraph index, run index)
248
+ text: New text content for the run
249
+ """
250
+
251
+ shape_id: ShapeId
252
+ path: TextRunPath
253
+ text: str
254
+
255
+ @property
256
+ def command_type(self) -> str:
257
+ return "SetRunText"
258
+
259
+ def validate(self) -> None:
260
+ if not self.shape_id:
261
+ raise ValidationError("shape_id is required", "shape_id")
262
+ if "p" not in self.path or "r" not in self.path:
263
+ raise ValidationError("path must contain 'p' and 'r'", "path")
264
+
265
+
196
266
  @dataclass
197
267
  class AddSlide(Command):
198
268
  """
@@ -772,6 +842,45 @@ class AddLinkedTable(Command):
772
842
  )
773
843
 
774
844
 
845
+ def _validate_citation_string(citation_string: str) -> None:
846
+ try:
847
+ normalize_citation_string(citation_string)
848
+ except ValueError as exc:
849
+ raise ValidationError(str(exc), "citation_string") from exc
850
+
851
+
852
+ def _validate_citation_source(
853
+ *,
854
+ source_ref: Optional[dict],
855
+ source_anchor: Optional[dict],
856
+ citation_string: Optional[str],
857
+ ) -> None:
858
+ if source_ref is None and citation_string is None:
859
+ raise ValidationError(
860
+ "source_ref or citation_string is required",
861
+ "source_ref",
862
+ )
863
+ if source_ref is not None and citation_string is not None:
864
+ raise ValidationError(
865
+ "source_ref and citation_string are mutually exclusive",
866
+ "citation_string",
867
+ )
868
+ if source_ref is None and source_anchor is not None:
869
+ raise ValidationError(
870
+ "source_anchor requires source_ref; encode the anchor in citation_string instead",
871
+ "source_anchor",
872
+ )
873
+ if source_ref is not None and (
874
+ not isinstance(source_ref, dict) or not source_ref.get("id")
875
+ ):
876
+ raise ValidationError(
877
+ "source_ref must be an AssetReference dict with an 'id' field",
878
+ "source_ref",
879
+ )
880
+ if citation_string is not None:
881
+ _validate_citation_string(citation_string)
882
+
883
+
775
884
  @dataclass
776
885
  class AddSlideCitation(Command):
777
886
  """Attach an Athena source citation to a whole slide.
@@ -793,19 +902,20 @@ class AddSlideCitation(Command):
793
902
  resolves the stable ``slideId`` from it and stores the resulting
794
903
  ``destinationAnchor.slideIndex`` as 1-based (display "Slide N") — do
795
904
  not read this wire value as a 1-based position.
796
- source_ref: Source ``AssetReference`` JSON (``referenced='asset'`` +
797
- ``id``); the asset the slide is sourced from.
905
+ source_ref: Optional source ``AssetReference`` JSON
906
+ (``referenced='asset'`` + ``id``); the asset the slide is sourced
907
+ from. Omit when passing a fully serialized ``citation_string``.
798
908
  source_anchor: Optional ``Anchor`` JSON within the source (e.g. a
799
909
  spreadsheet range); omit to cite the whole asset.
800
910
  display_value: Optional human-readable badge label.
801
- citation_string: Optional pre-serialized source Spaces URL; the
802
- server derives it from ``source_ref`` + ``source_anchor`` when
803
- unset.
911
+ citation_string: Optional pre-serialized source Spaces URL. Prefer
912
+ this for sources/anchors the SDK does not model directly, such as
913
+ video/audio time ranges or external URLs.
804
914
  client_id: Client-provided ID (optional).
805
915
  """
806
916
 
807
917
  slide_index: int
808
- source_ref: dict
918
+ source_ref: Optional[dict] = None
809
919
  source_anchor: Optional[dict] = None
810
920
  display_value: Optional[str] = None
811
921
  citation_string: Optional[str] = None
@@ -818,11 +928,11 @@ class AddSlideCitation(Command):
818
928
  def validate(self) -> None:
819
929
  if self.slide_index < 0:
820
930
  raise ValidationError("slide_index must be non-negative", "slide_index")
821
- if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
822
- raise ValidationError(
823
- "source_ref must be an AssetReference dict with an 'id' field",
824
- "source_ref",
825
- )
931
+ _validate_citation_source(
932
+ source_ref=self.source_ref,
933
+ source_anchor=self.source_anchor,
934
+ citation_string=self.citation_string,
935
+ )
826
936
 
827
937
 
828
938
  @dataclass
@@ -844,16 +954,19 @@ class AddShapeCitation(Command):
844
954
  the resulting ``destinationAnchor.slideIndex`` as 1-based (display
845
955
  "Slide N") — do not read this wire value as a 1-based position.
846
956
  shape_ids: Non-empty list of shape ids on that slide to cite.
847
- source_ref: Source ``AssetReference`` JSON.
957
+ source_ref: Optional source ``AssetReference`` JSON. Omit when passing
958
+ a fully serialized ``citation_string``.
848
959
  source_anchor: Optional ``Anchor`` JSON within the source.
849
960
  display_value: Optional human-readable badge label.
850
- citation_string: Optional pre-serialized source Spaces URL.
961
+ citation_string: Optional pre-serialized source Spaces URL. Prefer
962
+ this for sources/anchors the SDK does not model directly, such as
963
+ video/audio time ranges or external URLs.
851
964
  client_id: Client-provided ID (optional).
852
965
  """
853
966
 
854
967
  slide_index: int
855
968
  shape_ids: list[str]
856
- source_ref: dict
969
+ source_ref: Optional[dict] = None
857
970
  source_anchor: Optional[dict] = None
858
971
  display_value: Optional[str] = None
859
972
  citation_string: Optional[str] = None
@@ -868,11 +981,11 @@ class AddShapeCitation(Command):
868
981
  raise ValidationError("slide_index must be non-negative", "slide_index")
869
982
  if not self.shape_ids:
870
983
  raise ValidationError("shape_ids must be non-empty", "shape_ids")
871
- if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
872
- raise ValidationError(
873
- "source_ref must be an AssetReference dict with an 'id' field",
874
- "source_ref",
875
- )
984
+ _validate_citation_source(
985
+ source_ref=self.source_ref,
986
+ source_anchor=self.source_anchor,
987
+ citation_string=self.citation_string,
988
+ )
876
989
 
877
990
 
878
991
  @dataclass
@@ -940,8 +1053,8 @@ class AddTable(Command):
940
1053
  raise ValidationError("slide_index must be non-negative", "slide_index")
941
1054
  if self.rows < 1 or self.rows > 100:
942
1055
  raise ValidationError("rows must be between 1 and 100", "rows")
943
- if self.cols < 1 or self.cols > 26:
944
- raise ValidationError("cols must be between 1 and 26", "cols")
1056
+ if self.cols < 1 or self.cols > 35:
1057
+ raise ValidationError("cols must be between 1 and 35", "cols")
945
1058
  if self.w_emu < 0:
946
1059
  raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
947
1060
  if self.h_emu < 0:
@@ -3226,6 +3339,7 @@ AnyCommand = Union[
3226
3339
  SetText,
3227
3340
  SetTransform,
3228
3341
  SetRunStyle,
3342
+ SetRunText,
3229
3343
  AddSlide,
3230
3344
  DeleteSlide,
3231
3345
  DeleteShape,
@@ -14,6 +14,7 @@ from urllib.parse import quote, urlparse
14
14
 
15
15
  import requests
16
16
 
17
+ from .._citations import normalize_citation_string
17
18
  from ..commands import (
18
19
  AddTextBox, DeleteShape, SetTransform, AddShape as AddShapeCmd,
19
20
  SetShapeStyle, AddPicture as AddPictureCmd, AddOleObject as AddOleObjectCmd,
@@ -90,25 +91,49 @@ def _is_athena_url(url: str) -> bool:
90
91
 
91
92
  def _build_citation_wire(
92
93
  *,
93
- source: "AssetReference",
94
+ source: "AssetReference | str | None",
94
95
  anchor: Optional["Anchor"],
95
- ) -> tuple[dict, Optional[dict], None]:
96
+ citation_string: Optional[str] = None,
97
+ ) -> tuple[Optional[dict], Optional[dict], Optional[str]]:
96
98
  """Build the ``(source_ref, source_anchor, citation_string)`` wire triple
97
99
  for a citation command (Athena extension).
98
100
 
99
- Type-checks ``source`` and duck-types the optional ``anchor`` exactly as
100
- ``add_linked_ole_object`` does, then serializes both to their canonical
101
- wire dicts. ``citation_string`` is left ``None`` so the studio server
102
- derives the Spaces URL from ``source_ref`` + ``source_anchor`` — keeping a
103
- single canonical serializer (matching agora's output) rather than a second
104
- SDK-side one.
101
+ ``source`` can be a typed ``AssetReference`` for SDK-modeled assets or a
102
+ serialized citation URL for arbitrary sources/anchors. When a string source
103
+ is used, the anchor must already be encoded into that URL.
105
104
  """
106
105
  from .._references import AssetReference
107
106
 
108
- if not isinstance(source, AssetReference):
107
+ if isinstance(source, str):
108
+ if (
109
+ citation_string is not None
110
+ and normalize_citation_string(citation_string)
111
+ != normalize_citation_string(source)
112
+ ):
113
+ raise ValueError("Pass citation_string either positionally or by keyword, not both")
114
+ citation_string = source
115
+ source_ref = None
116
+ elif source is None:
117
+ source_ref = None
118
+ elif isinstance(source, AssetReference):
119
+ if citation_string is not None:
120
+ raise ValueError("Pass either an AssetReference source or citation_string, not both")
121
+ source_ref = source.to_dict()
122
+ else:
109
123
  raise TypeError(
110
- f"source must be an AssetReference; got {type(source).__name__}",
124
+ "source must be an AssetReference, citation string, or None; got "
125
+ f"{type(source).__name__}",
111
126
  )
127
+ normalized_citation_string = (
128
+ normalize_citation_string(citation_string)
129
+ if citation_string is not None
130
+ else None
131
+ )
132
+ if source_ref is None and normalized_citation_string is None:
133
+ raise ValueError("source or citation_string is required")
134
+ if source_ref is None and anchor is not None:
135
+ raise ValueError("anchor requires an AssetReference source; encode the anchor in citation_string instead")
136
+
112
137
  source_anchor: Optional[dict] = None
113
138
  if anchor is not None:
114
139
  if not hasattr(anchor, "to_dict"):
@@ -118,7 +143,7 @@ def _build_citation_wire(
118
143
  f"{type(anchor).__name__}",
119
144
  )
120
145
  source_anchor = anchor.to_dict()
121
- return source.to_dict(), source_anchor, None
146
+ return source_ref, source_anchor, normalized_citation_string
122
147
 
123
148
 
124
149
  def _add_citation_command(buffer: Optional["CommandBuffer"], cmd: Any) -> str:
@@ -6118,10 +6143,11 @@ class Shape:
6118
6143
 
6119
6144
  def add_citation(
6120
6145
  self,
6121
- source: "AssetReference",
6146
+ source: "AssetReference | str | None" = None,
6122
6147
  *,
6123
6148
  anchor: Optional["Anchor"] = None,
6124
6149
  display_value: Optional[str] = None,
6150
+ citation_string: Optional[str] = None,
6125
6151
  ) -> str:
6126
6152
  """Attach an Athena source citation to this shape.
6127
6153
 
@@ -6133,18 +6159,24 @@ class Shape:
6133
6159
  user-authored link.
6134
6160
 
6135
6161
  Args:
6136
- source: Source ``AssetReference`` from ``pptx._references`` — the
6137
- asset this shape is sourced from.
6162
+ source: Source ``AssetReference`` from ``pptx._references`` or a
6163
+ serialized citation URL such as
6164
+ ``"/dashboard/spaces/?asset_ids=..."``.
6138
6165
  anchor: Optional ``Anchor`` within the source (e.g.
6139
6166
  ``SheetRangeAnchor``); omit to cite the whole asset.
6140
6167
  display_value: Optional human-readable badge label.
6168
+ citation_string: Optional serialized citation URL. Prefer this for
6169
+ sources/anchors the SDK does not model directly, such as
6170
+ video/audio time ranges or external URLs.
6141
6171
 
6142
6172
  Returns:
6143
6173
  The created citation id (``citation_<...>``).
6144
6174
 
6145
6175
  Raises:
6146
- TypeError: if ``source`` is not an ``AssetReference`` or ``anchor``
6147
- isn't an Anchor dataclass.
6176
+ TypeError: if ``source`` is not an ``AssetReference``/string/None
6177
+ or ``anchor`` isn't an Anchor dataclass.
6178
+ ValueError: if neither ``source`` nor ``citation_string`` is
6179
+ provided, or a string citation is malformed.
6148
6180
 
6149
6181
  Note (Athena extension):
6150
6182
  This method is NOT part of python-pptx. See
@@ -6155,6 +6187,7 @@ class Shape:
6155
6187
  source_ref, source_anchor, citation_string = _build_citation_wire(
6156
6188
  source=source,
6157
6189
  anchor=anchor,
6190
+ citation_string=citation_string,
6158
6191
  )
6159
6192
  cmd = AddShapeCitationCmd(
6160
6193
  slide_index=self._slide.slide_index,