athena-python-pptx 0.5.0__tar.gz → 0.7.1__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.5.0 → athena_python_pptx-0.7.1}/CHANGELOG.md +86 -0
  2. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/CLAUDE.md +13 -2
  3. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/PKG-INFO +1 -1
  4. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/docs/API_PARITY_EXCEPTIONS.md +110 -55
  5. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/__init__.py +1 -1
  6. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/batching.py +178 -19
  7. athena_python_pptx-0.7.1/pptx/chart/data.py +446 -0
  8. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/client.py +85 -10
  9. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/commands.py +16 -3
  10. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/color.py +35 -45
  11. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/dml.py +10 -11
  12. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/shapes.py +23 -1
  13. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/text.py +22 -8
  14. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/media.py +37 -5
  15. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/presentation.py +17 -6
  16. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/__init__.py +316 -62
  17. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/slides.py +74 -31
  18. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/text/__init__.py +79 -40
  19. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/units.py +39 -20
  20. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pyproject.toml +1 -1
  21. athena_python_pptx-0.5.0/pptx/chart/data.py +0 -266
  22. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/.gitignore +0 -0
  23. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/API_PARITY_REPORT.md +0 -0
  24. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/DEV-GUIDE.md +0 -0
  25. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/PARITY_QUESTIONS.md +0 -0
  26. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/PUBLISHING.md +0 -0
  27. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/README.md +0 -0
  28. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/docs/athena-api.json +0 -0
  29. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/docs/athena-api.md +0 -0
  30. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/_athena_extension.py +0 -0
  31. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/_ptc.py +0 -0
  32. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/_references.py +0 -0
  33. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/action.py +0 -0
  34. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/__init__.py +0 -0
  35. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/axis.py +0 -0
  36. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/category.py +0 -0
  37. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/chart.py +0 -0
  38. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/datalabel.py +0 -0
  39. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/legend.py +0 -0
  40. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/marker.py +0 -0
  41. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/plot.py +0 -0
  42. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/point.py +0 -0
  43. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/series.py +0 -0
  44. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/chart/xlsx.py +0 -0
  45. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/decorators.py +0 -0
  46. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/__init__.py +0 -0
  47. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/chtfmt.py +0 -0
  48. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/effect.py +0 -0
  49. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/fill.py +0 -0
  50. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/dml/line.py +0 -0
  51. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/docgen.py +0 -0
  52. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/__init__.py +0 -0
  53. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/action.py +0 -0
  54. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/chart.py +0 -0
  55. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/enum/lang.py +0 -0
  56. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/errors.py +0 -0
  57. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/exc.py +0 -0
  58. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/package.py +0 -0
  59. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/__init__.py +0 -0
  60. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/_base.py +0 -0
  61. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/chart.py +0 -0
  62. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/coreprops.py +0 -0
  63. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/embeddedpackage.py +0 -0
  64. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/image.py +0 -0
  65. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/media.py +0 -0
  66. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/presentation.py +0 -0
  67. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/parts/slide.py +0 -0
  68. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/autoshape.py +0 -0
  69. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/base.py +0 -0
  70. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/connector.py +0 -0
  71. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/freeform.py +0 -0
  72. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/graphfrm.py +0 -0
  73. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/group.py +0 -0
  74. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/picture.py +0 -0
  75. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/placeholder.py +0 -0
  76. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shapes/shapetree.py +0 -0
  77. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/shared.py +0 -0
  78. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/slide.py +0 -0
  79. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/spec.py +0 -0
  80. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/table.py +0 -0
  81. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/text/fonts.py +0 -0
  82. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/text/layout.py +0 -0
  83. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/text/text.py +0 -0
  84. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/types.py +0 -0
  85. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/typing.py +0 -0
  86. {athena_python_pptx-0.5.0 → athena_python_pptx-0.7.1}/pptx/util.py +0 -0
@@ -2,6 +2,92 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.7.1 — CommandBuffer thread-safety
6
+
7
+ Fixes a data-loss/duplication race in `CommandBuffer`. `self._commands` was
8
+ shared between the foreground `add`/`flush`/`clear` path and the 2.0s
9
+ auto-flush daemon thread, with only the timer guarded — so a flush that
10
+ snapshotted-then-reset the buffer could drop a command a concurrent `add`
11
+ appended in between (silent slide/shape loss), and a timer flush racing a
12
+ foreground flush could POST the same commands twice (duplication). In
13
+ long-lived Daytona executor processes this silently dropped or duplicated
14
+ slides/shapes with no error. A dedicated `_commands_lock` now guards every
15
+ read/append/reassign of `_commands` (drain atomically under the lock; POST
16
+ outside it). No public API change. (#24278)
17
+
18
+ ## 0.7.0
19
+
20
+ **python-pptx behavioral-parity sweep — read-path return types, enum
21
+ values, and chart-data builder objects now match python-pptx 1.0.2.**
22
+
23
+ Surface parity (every public class/method/signature) was already 100%, but a
24
+ deep-dive against python-pptx 1.0.2 found *behavioral* divergences where
25
+ getters returned raw strings/`None` instead of enum members or typed
26
+ defaults, two enums had wrong integer values, and the chart-data builders
27
+ weren't python-pptx-shaped. All fixes are pure-local (no wire/server change);
28
+ `to_series_payload()` output is byte-identical to before.
29
+
30
+ **Enum value corrections (silent drop-in breakers):**
31
+
32
+ - **`MSO_THEME_COLOR.TEXT_2` / `BACKGROUND_2` were swapped** — now `15` /
33
+ `16` to match upstream (the `<a:schemeClr>` read/write maps were updated in
34
+ lockstep so OOXML output is unchanged). `MSO_THEME_COLOR_INDEX` inherits.
35
+ - **`MSO_TEXT_UNDERLINE_TYPE` had 6 wrong values, 9 missing members, and a
36
+ bogus `DASHED_LINE`** — rewritten to upstream's exact 19 members/values;
37
+ added the `MSO_UNDERLINE` alias (`from pptx.enum.text import MSO_UNDERLINE`).
38
+ - **`PP_MEDIA_TYPE`**: `MOVIE` corrected `2`→`3`, added `OTHER = 1`, and the
39
+ enum is now importable from its canonical `pptx.enum.shapes` location.
40
+ - Added `MSO_SHAPE_TYPE.MIXED = -2` and the `MSO` alias; added the
41
+ upstream-typo `MSO_PATTERN.ERCENT_40` member.
42
+
43
+ **Read-path return-type parity (getters now return enums / typed defaults):**
44
+
45
+ - `FillFormat.type` → `MSO_FILL_TYPE` (was a raw string); `background()` now
46
+ reports `MSO_FILL_TYPE.BACKGROUND`. `gradient_angle`/`gradient_stops`/
47
+ `pattern` raise `TypeError` on a wrong-type fill (parity).
48
+ - `LineFormat.dash_style` → `MSO_LINE_DASH_STYLE` (or `None` when unstyled,
49
+ instead of defaulting to `"solid"`); `LineFormat.width` defaults to
50
+ `Emu(0)` for an unstyled line (a color-/dash-only edit no longer injects a
51
+ synthetic 1pt width).
52
+ - `Chart.chart_type` → `XL_CHART_TYPE` member (was a raw server string);
53
+ `add_chart` now persists `grouping` so stacked/clustered variants read back
54
+ exactly.
55
+ - `TableCell.vertical_anchor` → `MSO_VERTICAL_ANCHOR`; `TableCell.margin_*`
56
+ default to the OOXML defaults (`91440`/`45720` EMU) instead of `None`.
57
+ - `Shape.rotation` is normalized to `[0, 360)` and float-coerced.
58
+ - `_Column.width` / `_Row.height` are populated for `add_table(...)` tables
59
+ (even division, last column/row absorbs the remainder).
60
+ - `RGBColor` is now a real `tuple` subclass (`RGBColor(255,0,0) == (255,0,0)`,
61
+ `isinstance(c, tuple)`).
62
+ - `ColorFormat.theme_color` returns `MSO_THEME_COLOR.NOT_THEME_COLOR` for an
63
+ sRGB color (raises `TypeError` when no color is present) instead of `None`.
64
+ - `TextFrame.auto_size` / `vertical_anchor` return their enums; `margin_*`
65
+ default to the python-pptx `Length` defaults; `Paragraph.line_spacing =
66
+ None` clears the override without crashing; `Font.language_id` returns
67
+ `MSO_LANGUAGE_ID.NONE` on an unset run.
68
+ - `SlideLayouts.get_by_name` is exact/case-sensitive (the fuzzy match moved to
69
+ the Athena-only `by_name`); `Slide.has_notes_slide` flips `True` once
70
+ `notes_slide` is accessed.
71
+ - `Video.ext` returns the bare extension (`'mp4'`, `'vid'` fallback) and
72
+ `Video.filename` synthesizes `'movie.{ext}'`.
73
+
74
+ **`pptx.util` truncation parity:** unit constructors (`Emu`/`Pt`/`Inches`/…)
75
+ truncate toward zero like upstream (`int(Pt(1.9999)) == 25398`) instead of
76
+ rounding; `Length.centipoints` is integer floor-division.
77
+
78
+ **Chart-data builder objects (`pptx.chart.data`) are now python-pptx-shaped:**
79
+
80
+ - `CategoryChartData.add_series(name, values)` returns a real
81
+ `CategorySeriesData` with `.values`, `.add_data_point()`, and `.index`;
82
+ `XyChartData`/`BubbleChartData.add_series` return `XySeriesData`/
83
+ `BubbleSeriesData` whose `add_data_point(...)` returns the data point and
84
+ expose `.x_values` / `.y_values` / `.bubble_sizes`.
85
+ - `CategoryChartData.add_category(label)` and a `Categories`/`Category`
86
+ sequence with `are_dates` / `are_numeric` / `number_format`; category label
87
+ types (dates, numbers) are preserved through `cd.categories[i].label`.
88
+ - The three chart-data containers implement the `Sequence` protocol
89
+ (`for s in chart_data` / `chart_data[0]`).
90
+
5
91
  ## 0.4.2
6
92
 
7
93
  **Paragraph-level font fidelity — `paragraph.font` now matches stock
@@ -52,8 +52,10 @@ A small number of REST-SDK-specific departures are documented in
52
52
  use `slide.notes_slide.notes_text_frame` for notes text.
53
53
  - **`XyChartData.add_series(name, values)`** signature drift vs upstream's
54
54
  `add_series(name, number_format=None)` — caller passes inline tuples.
55
- - **`TextFitter.best_fit_font_size()`** returns `max_size` unchanged
56
- (auto-fit is server-side); emits `RuntimeWarning`.
55
+ - **`TextFitter.best_fit_font_size()`** is server-measured: with a base
56
+ URL configured it queries `POST /measure-text-fit` and returns the
57
+ largest fitting size; only falls back to `max_size` (+ `RuntimeWarning`)
58
+ when no studio client is reachable.
57
59
  - **`_BaseSeriesData.index`** raises `NotImplementedError` (raising
58
60
  rather than silently returning 0).
59
61
  - **`Package.open()`** raises `UnsupportedFeatureError`.
@@ -69,6 +71,15 @@ local XML / OPC parts:
69
71
 
70
72
  `Font.language_id` IS now implemented (returns `MSO_LANGUAGE_ID` enum, accepts enum or int LCID) — added in v0.1.68.
71
73
 
74
+ ### Slide-index conventions
75
+
76
+ Slide indexing has TWO bases — never conflate them:
77
+
78
+ - **0-based** — this SDK (python-pptx parity): `prs.slides[0]`, `slide.slide_index`, and every `slide_index=` parameter are 0-based.
79
+ - **1-based** — the Athena citation/anchor system: `SlideAnchor.slideIndex` / `ShapeAnchor.slideIndex` (in `pptx/_references.py`), the citation methods, and anything displayed as "Slide N" are the 1-based display position.
80
+
81
+ The PPTX Studio server applier converts at the SDK→anchor boundary (`+1`). `slideId` (== the SDK's `slide.slide_id`) is the stable, base-agnostic identity — prefer it when available.
82
+
72
83
  ### If you need a deviation
73
84
 
74
85
  If there is a genuine technical reason why a deviation from python-pptx is necessary (e.g., the SDK is a REST client and cannot replicate XML-level behavior):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.5.0
3
+ Version: 0.7.1
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
@@ -45,48 +45,51 @@ text = slide.notes_slide.notes_text_frame.text
45
45
  A future server-side notes-master patch op will plumb the full shape
46
46
  tree through. Tracked in `PARITY_QUESTIONS.md`.
47
47
 
48
- ### `XyChartData.add_series()` — different signature
48
+ ### `XyChartData.add_series()` — both the upstream and inline patterns work (v0.7.0)
49
49
 
50
- Upstream signature: `add_series(name, number_format=None)` — caller adds
51
- data points via the returned series object.
52
-
53
- **Our signature:** `add_series(name, values)` — caller passes the
54
- (x, y) tuples up front.
55
-
56
- **Migration:**
50
+ As of v0.7.0 `add_series` returns a real `XySeriesData` (resp.
51
+ `BubbleSeriesData`), so the **upstream pattern works**:
57
52
 
58
53
  ```python
59
- # python-pptx pattern (NOT supported by athena-python-pptx):
54
+ # python-pptx pattern (now supported):
60
55
  series = data.add_series("Series 1")
61
- series.add_data_point(1.0, 2.0)
56
+ series.add_data_point(1.0, 2.0) # returns the XyDataPoint
62
57
  series.add_data_point(3.0, 4.0)
58
+ series.x_values # [1.0, 3.0]; series.y_values -> [2.0, 4.0]
63
59
 
64
- # athena-python-pptx pattern (use this):
60
+ # athena-python-pptx inline shorthand (still supported):
65
61
  data.add_series("Series 1", [(1.0, 2.0), (3.0, 4.0)])
66
62
  ```
67
63
 
68
- Same applies to `BubbleChartData.add_series`. Fixing this is a larger
69
- refactor (the SDK's chart-authoring path expects values inline);
70
- tracked in `PARITY_QUESTIONS.md`.
64
+ `add_series` additionally accepts an optional `number_format=` for
65
+ signature parity. The same applies to `BubbleChartData.add_series`
66
+ (inline triples are `(x, y, size)`).
71
67
 
72
- ### `TextFitter.best_fit_font_size()` — returns `max_size` unchanged
68
+ ### `TextFitter.best_fit_font_size()` — server-measured (needs a base URL)
73
69
 
74
- Upstream binary-searches font sizes locally to fit text within bounds.
75
- Our stub always returns `max_size` and emits a `RuntimeWarning`.
76
- Auto-fit happens server-side at render time. **Don't rely on this for
77
- local layout decisions**; use the auto-fit text frame property instead:
70
+ Upstream binary-searches font sizes against locally-installed font
71
+ metrics, which a REST SDK can't read. When a base URL is configured
72
+ (`ATHENA_PPTX_BASE_URL` or an explicit `client`) the classmethod issues
73
+ a `MeasureTextFit` query to the studio's `POST /measure-text-fit`
74
+ endpoint and returns the largest integer size that fits — measured
75
+ against the studio's bundled fonts via the same layouter the renderer
76
+ uses (see the extension entry below). It falls back to `max_size` with a
77
+ `RuntimeWarning` **only** when no studio client is reachable (offline /
78
+ import-time tests). For full-deck layout, prefer letting the text frame
79
+ auto-fit at render time:
78
80
 
79
81
  ```python
80
82
  from pptx.enum.text import MSO_AUTO_SIZE
81
83
  text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
82
84
  ```
83
85
 
84
- ### `_BaseSeriesData.index` — raises `NotImplementedError`
86
+ ### `_BaseSeriesData.index` — resolves against the parent chart-data (v0.7.0)
85
87
 
86
- Upstream's `CategorySeriesData.index` returns the series's offset
87
- within its parent chart-data. Our stub raises rather than silently
88
- returning `0` (which would corrupt any OOXML `<c:idx>` builder that
89
- trusts the value). If you need the series index, track it externally.
88
+ As of v0.7.0, a series created via `chart_data.add_series(...)` carries a
89
+ back-reference to its parent, so `series.index` returns the series's
90
+ offset within the chart-data exactly like upstream. A *detached* series
91
+ constructed standalone (no parent) still raises `NotImplementedError`
92
+ rather than lying about the offset.
90
93
 
91
94
  ### `Package.open(pkg_file)` — raises `UnsupportedFeatureError`
92
95
 
@@ -137,6 +140,24 @@ prs.save() # flush only (Athena-only)
137
140
  prs.save("out.pptx") # flush + export + write to local file (parity)
138
141
  ```
139
142
 
143
+ ### Oversized flushes are split into multiple atomic sub-transactions
144
+
145
+ The SDK serializes every buffered command into one JSON body and POSTs it
146
+ to `/decks/:id/commands`. pptx-studio's apps/api (Fastify) caps that body
147
+ at 32 MiB, and `AddPicture` / `SubstitutePlaceholder(picture)` carry inline
148
+ base64 image bytes (~4/3 expansion), so a few large images can blow past
149
+ the ceiling. For batches that exceed the limit, `CommandBuffer.flush`
150
+ splits them into multiple atomic sub-transactions (still validating the
151
+ whole batch up front, so an invalid command aborts before anything is
152
+ applied). The all-or-nothing guarantee then holds *per sub-transaction*
153
+ rather than end-to-end — a server-side failure mid-sequence can leave
154
+ earlier chunks committed. The split threshold is tunable via
155
+ `ATHENA_PPTX_MAX_REQUEST_BYTES`; batches within the limit ship as one
156
+ atomic request, unchanged. A single command larger than the budget (one
157
+ very large inline image) cannot be split — it is sent alone and may still
158
+ be rejected with a teaching `IMAGE_TOO_LARGE` error, but it no longer
159
+ takes the rest of the queued edits down with it.
160
+
140
161
  ---
141
162
 
142
163
  ## Agent-Friendly Additions (not in python-pptx)
@@ -215,34 +236,23 @@ python-pptx's `_Row.height` and `_Column.width` are read/write EMU properties. T
215
236
 
216
237
  In python-pptx, `Table.rows` returns a `_RowCollection` of `_Row` objects (each with `.height`). This SDK matches that. In a prior version, `Table.rows` returned a flat collection of cell lists.
217
238
 
218
- ### `TextFrame.clear()` — also resets bullet on paragraph 0
239
+ ### `TextFrame.clear()` — bullet inheritance (now matches upstream)
219
240
 
220
- In upstream python-pptx, `text_frame.clear()` removes every paragraph except the first, and on that first paragraph it removes every child element **except** `<a:pPr>`. Any bullet definition the author wrote into `<a:pPr>` survives the clear, and an inherited bullet from the master / layout's `<a:lstStyle>` is left to be re-resolved by PowerPoint at render time.
241
+ `text_frame.clear()` matches upstream python-pptx: it removes the runs but
242
+ preserves the cleared paragraph's inherited list style. A content/body
243
+ placeholder therefore keeps its master `<p:bodyStyle>` bullet, and a title
244
+ keeps its `<p:titleStyle>` `<a:buNone/>` (no bullet), exactly as PowerPoint
245
+ re-resolves them at render time.
221
246
 
222
- This SDK additionally emits a `SetParagraphStyle(paragraph_index=0, bullet='none')` after the empty `SetText`, explicitly overriding the bullet on the cleared paragraph.
223
-
224
- The deviation exists because pptx-studio **pre-bakes** master/layout list-style inheritance into `richContent.paragraphs[i].bullet` at ingest time (and at slide materialization), then preserves that baked field through every subsequent `applySetText` via `preserveParagraphProps`. Without the explicit override, the python-pptx idiom
225
-
226
- ```python
227
- tf.clear()
228
- p = tf.paragraphs[0]
229
- run = p.add_run()
230
- run.text = "Title"
231
- ```
232
-
233
- renders the title with a stray "•" whenever the placeholder lives on a layout whose master defines a level-1 `<a:buChar/>` (the common case for `<p:bodyStyle>` and any content placeholder). The explicit `bullet='none'` clears the baked inheritance so the literal "no pPr was authored" intent reaches the renderer.
234
-
235
- To re-enable a bullet after `clear()`, set it back explicitly:
236
-
237
- ```python
238
- tf.clear()
239
- p = tf.paragraphs[0]
240
- p.bullet = True # restores 'disc' default
241
- run = p.add_run()
242
- run.text = "Bullet item"
243
- ```
244
-
245
- Only `bullet` is overridden — `alignment`, `level`, `line_spacing`, `space_before`, `space_after`, `margin_left`, `indent`, and `bullet_color` are left alone (they continue to follow upstream `<a:pPr>`-preservation semantics, since baked-inheritance surprises are far less common on those fields).
247
+ > **Historical note:** earlier versions emitted a
248
+ > `SetParagraphStyle(paragraph_index=0, bullet='none')` after the empty
249
+ > `SetText` to mask a *stray title bullet*. The real cause was incorrect
250
+ > per-type bullet baking — empty layout/master prompt paragraphs were baked
251
+ > with `bullet:'none'` instead of their master `<p:txStyles>` value, and a
252
+ > blanket override then stripped bullets from **content** placeholders too
253
+ > (where bullets are expected). That baking is now correct per placeholder
254
+ > type (`titleStyle` → no bullet, `bodyStyle` → its `<a:buChar/>`), so the
255
+ > override was removed and `clear()` is faithful to upstream.
246
256
 
247
257
  ---
248
258
 
@@ -328,6 +338,37 @@ img = ws.export_range_as_image_asset("A1:F20", name="Q4 Revenue")
328
338
  slide.shapes.add_picture(img.url, Inches(1), Inches(1), width=Inches(6))
329
339
  ```
330
340
 
341
+ ### `Picture.image_url` — owner-scoped media URL for cross-deck image copy
342
+
343
+ python-pptx upstream exposes `Picture.image` returning an `Image` with
344
+ `.blob` bytes. The REST SDK can't materialize bytes locally, so `.image`
345
+ stays `None` (see the omissions list above). **Additionally**, when a
346
+ picture is loaded from a server snapshot it now carries `image_url`: a
347
+ stable `{base_url}/media?key=<assetId>` URL that streams the picture's
348
+ bytes. It returns `None` for pictures with no backing media asset (an
349
+ SDK-added picture not yet flushed/refreshed, a placeholder with no image,
350
+ or exotic sources like OLE/movie posters that lack a top-level asset id).
351
+
352
+ The URL is owner/EDIT-scoped (the `/media` route runs the same deck-access
353
+ check as every other deck op), and `add_picture` auto-attaches the
354
+ sandbox bearer for Athena hosts — so it composes directly into the
355
+ canonical "copy every image from deck A into deck B" recipe:
356
+
357
+ ```python
358
+ src = Presentation(asset_id="asset_source")
359
+ dst = Presentation(asset_id="asset_dest")
360
+ for i, slide in enumerate(src.slides):
361
+ for pic in slide.shapes.get_images():
362
+ if pic.image_url:
363
+ dst.slides[i].shapes.add_picture(
364
+ pic.image_url, pic.left, pic.top, pic.width, pic.height
365
+ )
366
+ dst.save()
367
+ ```
368
+
369
+ **Portable code that needs to run against stock python-pptx cannot rely
370
+ on `image_url`** — it is an Athena-only accessor.
371
+
331
372
  ### `name=` kwarg on `add_textbox()` / `add_shape()` / `add_picture()`
332
373
 
333
374
  Optional keyword for setting a stable shape name at creation. python-pptx
@@ -1368,8 +1409,8 @@ A citation record is:
1368
1409
  ```jsonc
1369
1410
  {
1370
1411
  "id": "citation_<uuid12>",
1371
- "destinationAnchor": { "type": "slide", "slideId": "...", "slideIndex": 0 },
1372
- // or { "type": "shape", "slideId": "...", "slideIndex": 0, "shapeIds": ["..."] }
1412
+ "destinationAnchor": { "type": "slide", "slideId": "...", "slideIndex": 1 },
1413
+ // or { "type": "shape", "slideId": "...", "slideIndex": 1, "shapeIds": ["..."] }
1373
1414
  "citation_string": "<source Spaces URL>",
1374
1415
  "displayValue": "Q4 Sales", // optional
1375
1416
  "active": true,
@@ -1399,17 +1440,31 @@ cid = slide.add_citation(
1399
1440
  )
1400
1441
  ```
1401
1442
 
1402
- ### `Shape.cite(source, *, anchor=None, display_value=None) -> str`
1443
+ ### `Shape.add_citation(source, *, anchor=None, display_value=None) -> str`
1403
1444
 
1404
1445
  Shape-scoped citation — emits `AddShapeCitation` carrying this shape's id, so
1405
1446
  the server writes a `shape` destination anchor with `shapeIds`. Returns the
1406
- created citation id.
1447
+ created citation id. (Mirrors `Slide.add_citation` / `Cell.add_citation` — the
1448
+ citation verb is `add_citation` on every citable object.)
1407
1449
 
1408
1450
  ```python
1409
1451
  shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
1410
- cid = shape.cite(AssetReference(id="asset_doc_xyz"))
1452
+ cid = shape.add_citation(AssetReference(id="asset_doc_xyz"))
1411
1453
  ```
1412
1454
 
1455
+ **Slide-index base shift (citations).** The SDK/command wire `slide_index`
1456
+ (`prs.slides[i]`, `slide.slide_index`) is **0-based** — python-pptx array
1457
+ parity. But the `destinationAnchor.slideIndex` the server writes into the
1458
+ citation record is **1-based** — the "Slide N" display position per the
1459
+ athena-references contract. The server applies the `+1` at the SDK→anchor
1460
+ boundary, so the stored anchor's `slideIndex` is always one greater than the
1461
+ SDK index of the cited slide.
1462
+
1463
+ > ⚠️ If the **cited source** is itself a slide/shape (you pass a
1464
+ > `SlideAnchor` / `ShapeAnchor` as `anchor=`), that anchor's `slide_index` is
1465
+ > the **1-based** display position per the athena-references contract — do
1466
+ > **not** pass the SDK's 0-based `slide.slide_index` into it (add 1).
1467
+
1413
1468
  ### `Slide.remove_citation(citation_id, *, soft=True)` / `Presentation.remove_citation(citation_id, *, soft=True)`
1414
1469
 
1415
1470
  Remove a citation from the deck by id. Citations are deck-scoped, so both
@@ -133,7 +133,7 @@ def flush_all() -> None:
133
133
  _active_buffers[:] = alive
134
134
 
135
135
 
136
- __version__ = "0.5.0"
136
+ __version__ = "0.7.1"
137
137
 
138
138
  __all__ = [
139
139
  # Main entry point
@@ -8,11 +8,11 @@ context manager interface for batch operations.
8
8
  from __future__ import annotations
9
9
 
10
10
  __athena_extension_module__: bool = True
11
- __athena_extension_description__: str = (
12
- "Command buffer + batching helpers (REST-SDK only)."
13
- )
11
+ __athena_extension_description__: str = "Command buffer + batching helpers (REST-SDK only)."
14
12
  __athena_extension_since__: str = "0.1.0"
15
13
 
14
+ import json
15
+ import os
16
16
  import threading
17
17
  from contextlib import contextmanager
18
18
  from threading import local
@@ -20,12 +20,71 @@ from typing import TYPE_CHECKING, Any, Generator, Optional
20
20
 
21
21
  from . import _ptc
22
22
  from .commands import AnyCommand
23
+ from .errors import BatchPartialError
23
24
  from .typing import CommandsResponse
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from .client import Client
27
28
 
28
29
 
30
+ # The SDK serializes every buffered command into one JSON body and POSTs it to
31
+ # ``/decks/:id/commands``. pptx-studio's apps/api (Fastify) caps that body at
32
+ # 32 MiB, so a single oversized flush 413s and silently loses the entire write
33
+ # (text edits + adds alike) because Fastify rejects the body before the route
34
+ # handler runs. AddPicture / SubstitutePlaceholder(picture) carry inline base64
35
+ # image bytes (~4/3 expansion), so a few large images blow past the ceiling.
36
+ # ``CommandBuffer.flush`` therefore splits the buffer into chunks that stay
37
+ # comfortably under that ceiling; the headroom absorbs the request envelope
38
+ # (client/txn/return), JSON structural characters, and multi-byte expansion.
39
+ _DEFAULT_MAX_REQUEST_BYTES = 24 * 1024 * 1024
40
+
41
+
42
+ def _max_request_bytes() -> int:
43
+ """Per-request command-body budget in bytes.
44
+
45
+ ``ATHENA_PPTX_MAX_REQUEST_BYTES`` overrides the default when set to a
46
+ positive integer (e.g. for a server configured with a different limit).
47
+ """
48
+ raw = os.environ.get("ATHENA_PPTX_MAX_REQUEST_BYTES")
49
+ if raw:
50
+ try:
51
+ override = int(raw)
52
+ except ValueError:
53
+ override = 0
54
+ if override > 0:
55
+ return override
56
+ return _DEFAULT_MAX_REQUEST_BYTES
57
+
58
+
59
+ def _command_size_bytes(command: AnyCommand) -> int:
60
+ """Serialized JSON byte size of one command's contribution to the body."""
61
+ return len(json.dumps(command.to_dict()).encode("utf-8"))
62
+
63
+
64
+ def _chunk_commands_by_size(commands: list[AnyCommand], max_bytes: int) -> list[list[AnyCommand]]:
65
+ """Greedily pack ``commands`` into ordered chunks under ``max_bytes``.
66
+
67
+ A single command larger than ``max_bytes`` (e.g. a large inline base64
68
+ image) cannot be split, so it lands alone in its own chunk; that one POST
69
+ may still be rejected by the server, but only that command is at risk
70
+ rather than the entire flush.
71
+ """
72
+ chunks: list[list[AnyCommand]] = []
73
+ current: list[AnyCommand] = []
74
+ current_bytes = 0
75
+ for command in commands:
76
+ size = _command_size_bytes(command)
77
+ if current and current_bytes + size > max_bytes:
78
+ chunks.append(current)
79
+ current = []
80
+ current_bytes = 0
81
+ current.append(command)
82
+ current_bytes += size
83
+ if current:
84
+ chunks.append(current)
85
+ return chunks
86
+
87
+
29
88
  # Only commands whose class name is in this set produce PTC sub-tool-cards.
30
89
  # Every Command subclass is a candidate, but each emit is now a synchronous
31
90
  # HTTP POST to agora (see ``_ptc._send``), so emitting one card per low-level
@@ -112,20 +171,49 @@ class CommandBuffer:
112
171
  self._auto_flush_seconds = auto_flush_seconds
113
172
  self._flush_timer: Optional[threading.Timer] = None
114
173
  self._timer_lock = threading.Lock()
174
+ # Guards every read/append/reassignment of ``_commands`` so the
175
+ # background auto-flush timer thread can't interleave with foreground
176
+ # add/flush/clear calls and drop, duplicate, or reorder buffered
177
+ # commands. Held only for the in-memory buffer ops — NEVER across the
178
+ # network POST in ``flush`` (that would serialize every write behind a
179
+ # multi-second request and let the timer block the foreground).
180
+ self._commands_lock = threading.Lock()
115
181
  # Exception captured by the background auto-flush timer. Re-raised on
116
182
  # the next foreground operation (add/flush/refresh/save) so server-
117
183
  # rejected commands aren't silently lost.
118
184
  self._deferred_error: Optional[BaseException] = None
185
+ # True once a shape-creating command has been BUFFERED without a
186
+ # subsequent refresh. Buffered creates keep their local client id
187
+ # (the proxy is only reconciled to the server id by a full refresh),
188
+ # so this flag tells callers like ``Slide.clear_shapes`` whether a
189
+ # reconciling refresh is actually needed before deleting by id.
190
+ self._unreconciled_creates = False
119
191
 
120
192
  @property
121
193
  def is_batching(self) -> bool:
122
194
  """Return True if currently in batch mode."""
123
195
  return self._batch_depth > 0
124
196
 
197
+ @property
198
+ def has_unreconciled_creates(self) -> bool:
199
+ """True if a shape-creating command was buffered since the last refresh.
200
+
201
+ Buffered creates carry a client id (`shp_<uuid>`) that only reconciles
202
+ to the server id on a full `refresh()`. Synchronously-flushed creates
203
+ reconcile from the response immediately and do not set this flag.
204
+ """
205
+ return self._unreconciled_creates
206
+
207
+ def mark_creates_reconciled(self) -> None:
208
+ """Clear the unreconciled-creates flag. Called after a full refresh
209
+ rebuilds every shape proxy from the server snapshot (server ids)."""
210
+ self._unreconciled_creates = False
211
+
125
212
  @property
126
213
  def pending_count(self) -> int:
127
214
  """Return the number of pending commands."""
128
- return len(self._commands)
215
+ with self._commands_lock:
216
+ return len(self._commands)
129
217
 
130
218
  @property
131
219
  def last_response(self) -> Optional[CommandsResponse]:
@@ -166,10 +254,17 @@ class CommandBuffer:
166
254
  except Exception: # noqa: BLE001
167
255
  pass
168
256
 
169
- self._commands.append(command)
257
+ with self._commands_lock:
258
+ self._commands.append(command)
170
259
 
171
260
  if not self._auto_batch and not self.is_batching:
261
+ # Synchronous flush — the caller reconciles the proxy's id from the
262
+ # response, so this create does not leave a stale client id.
172
263
  return self.flush()
264
+ # Buffered create: its client id won't reconcile to the server id until
265
+ # the next refresh(), so flag it for clear_shapes et al.
266
+ if getattr(command, "client_id", None) is not None:
267
+ self._unreconciled_creates = True
173
268
  # Schedule auto-flush if enabled and not inside explicit batch()
174
269
  if self._auto_flush_seconds and self._auto_flush_seconds > 0 and not self.is_batching:
175
270
  self._schedule_auto_flush()
@@ -228,35 +323,99 @@ class CommandBuffer:
228
323
  Send all buffered commands to the server.
229
324
 
230
325
  Returns:
231
- Server response, or None if no commands to send
326
+ Server response from the final POST, or None if no commands to send.
327
+
328
+ Large buffers are split into multiple ``/decks/:id/commands`` POSTs so
329
+ no single request exceeds the server's body-size limit (see
330
+ :func:`_max_request_bytes`). Each chunk is applied as its own atomic
331
+ server transaction and chunks are sent in order. No flush requests a
332
+ snapshot — auto-flushes never read it, so building one is wasted
333
+ O(whole-deck) work. A buffer that fits within the budget — the common
334
+ case — is sent as a single atomic POST, unchanged.
335
+
336
+ On POST failure (network, 4xx/5xx, auth, or a ``BatchPartialError``
337
+ for a chunk that committed a prefix), the commands that have not yet
338
+ been applied are restored to ``self._commands`` and the exception
339
+ propagates. Earlier chunks already committed server-side, so the
340
+ restore deliberately excludes them — callers that catch and retry
341
+ resend only the un-applied tail instead of double-applying.
232
342
  """
233
343
  self._cancel_timer()
234
344
  # Surface a background-flush error before draining the buffer, so
235
345
  # the caller sees it instead of a no-op return.
236
346
  self._raise_deferred_error()
237
347
 
238
- if not self._commands:
239
- return None
240
-
241
- commands = self._commands
242
- self._commands = []
243
-
348
+ # Atomically drain the buffer under the lock so a concurrent foreground
349
+ # ``add`` (or a second flush) can't have its append wiped by the
350
+ # ``= []`` below, and two flushes can't both POST the same commands.
351
+ # The POST itself runs OUTSIDE the lock.
352
+ with self._commands_lock:
353
+ if not self._commands:
354
+ return None
355
+ commands = self._commands
356
+ self._commands = []
357
+
358
+ chunks = _chunk_commands_by_size(commands, _max_request_bytes())
359
+
360
+ # A multi-chunk flush cannot be a single atomic server transaction, so
361
+ # validate the whole batch up front: an invalid command must abort the
362
+ # flush before any chunk is applied, preserving all-or-nothing
363
+ # semantics for bad input. Single-chunk flushes skip this —
364
+ # ``post_commands`` validates that one batch before it reaches the wire.
365
+ if len(chunks) > 1:
366
+ try:
367
+ for command in commands:
368
+ command.validate()
369
+ except Exception:
370
+ with self._commands_lock:
371
+ self._commands = commands + self._commands
372
+ _ptc_emit_end_batch(commands, is_error=True)
373
+ raise
374
+
375
+ applied = 0
244
376
  try:
245
- self._last_response = self._client.post_commands(
246
- self._deck_id,
247
- commands,
248
- return_snapshot=True,
249
- )
377
+ for chunk in chunks:
378
+ self._last_response = self._client.post_commands(
379
+ self._deck_id,
380
+ chunk,
381
+ return_snapshot=False,
382
+ )
383
+ applied += len(chunk)
384
+ except BatchPartialError as exc:
385
+ # The failing chunk committed its commands before
386
+ # ``failure_index`` server-side; everything in earlier chunks
387
+ # already committed too. Restore only the unsent suffix — the
388
+ # failing command (at ``applied + exc.failure_index``) and the
389
+ # rest of the buffer — so a retry doesn't re-apply the committed
390
+ # prefix.
391
+ committed = applied + exc.failure_index
392
+ self._restore_unsent(commands[committed:])
393
+ _ptc_emit_end_batch(commands[:committed], is_error=False)
394
+ _ptc_emit_end_batch(commands[committed:], is_error=True)
395
+ raise
250
396
  except Exception:
251
- _ptc_emit_end_batch(commands, is_error=True)
397
+ # The current chunk's POST failed atomically (network, 4xx/5xx,
398
+ # auth), so nothing in it committed; earlier chunks already did.
399
+ # Restore only the un-applied tail so callers that retry resend
400
+ # the tail instead of double-applying the committed prefix.
401
+ self._restore_unsent(commands[applied:])
402
+ _ptc_emit_end_batch(commands[:applied], is_error=False)
403
+ _ptc_emit_end_batch(commands[applied:], is_error=True)
252
404
  raise
253
405
  _ptc_emit_end_batch(commands, is_error=False)
254
406
  return self._last_response
255
407
 
408
+ def _restore_unsent(self, commands: list[AnyCommand]) -> None:
409
+ """Prepend unsent commands back onto the buffer after a failed flush."""
410
+ if commands:
411
+ with self._commands_lock:
412
+ self._commands = commands + self._commands
413
+
256
414
  def clear(self) -> None:
257
415
  """Clear all pending commands without sending."""
258
416
  self._cancel_timer()
259
- self._commands = []
417
+ with self._commands_lock:
418
+ self._commands = []
260
419
 
261
420
  @contextmanager
262
421
  def batch(self) -> Generator[CommandBuffer, None, None]: