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.
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/CHANGELOG.md +62 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PKG-INFO +1 -1
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/API_PARITY_EXCEPTIONS.md +13 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/__init__.py +1 -1
- athena_python_pptx-0.9.0/pptx/_citations.py +54 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/batching.py +11 -2
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/client.py +14 -10
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/commands.py +136 -22
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/__init__.py +49 -16
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/slides.py +141 -18
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/__init__.py +164 -39
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/typing.py +3 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pyproject.toml +1 -1
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/.gitignore +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/API_PARITY_REPORT.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/CLAUDE.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/DEV-GUIDE.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PARITY_QUESTIONS.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/PUBLISHING.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/README.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/athena-api.json +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/docs/athena-api.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_athena_extension.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_ptc.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/_references.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/action.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/axis.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/category.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/data.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/datalabel.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/legend.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/marker.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/plot.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/point.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/series.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/chart/xlsx.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/decorators.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/chtfmt.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/color.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/effect.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/fill.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/dml/line.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/docgen.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/action.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/dml.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/lang.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/shapes.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/enum/text.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/errors.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/exc.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/media.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/package.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/_base.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/coreprops.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/embeddedpackage.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/image.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/media.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/presentation.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/parts/slide.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/presentation.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/autoshape.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/base.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/connector.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/freeform.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/graphfrm.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/group.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/picture.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/placeholder.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shapes/shapetree.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/shared.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/slide.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/spec.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/table.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/fonts.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/layout.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/text/text.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/types.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.9.0}/pptx/units.py +0 -0
- {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.
|
|
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 |
|
|
@@ -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
|
|
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
|
-
- **
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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":
|
|
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
|
|
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:
|
|
797
|
-
``id``); the asset the slide is sourced
|
|
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
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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:
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 >
|
|
944
|
-
raise ValidationError("cols must be between 1 and
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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``
|
|
6137
|
-
|
|
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
|
|
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,
|