athena-python-pptx 0.1.54__tar.gz → 0.1.56__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 (38) hide show
  1. athena_python_pptx-0.1.56/CHANGELOG.md +29 -0
  2. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/__init__.py +1 -1
  4. athena_python_pptx-0.1.56/pptx/chart/data.py +117 -0
  5. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/client.py +56 -6
  6. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/commands.py +98 -0
  7. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/shapes.py +333 -46
  8. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pyproject.toml +1 -1
  9. athena_python_pptx-0.1.54/CHANGELOG.md +0 -15
  10. athena_python_pptx-0.1.54/pptx/chart/data.py +0 -96
  11. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/.gitignore +0 -0
  12. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/API_PARITY_REPORT.md +0 -0
  13. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/CLAUDE.md +0 -0
  14. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/DEV-GUIDE.md +0 -0
  15. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/PUBLISHING.md +0 -0
  16. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/README.md +0 -0
  17. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/docs/API_PARITY_EXCEPTIONS.md +0 -0
  18. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/docs/athena-api.json +0 -0
  19. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/docs/athena-api.md +0 -0
  20. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/batching.py +0 -0
  21. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/chart/__init__.py +0 -0
  22. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/decorators.py +0 -0
  23. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/dml/__init__.py +0 -0
  24. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/dml/color.py +0 -0
  25. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/docgen.py +0 -0
  26. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/__init__.py +0 -0
  27. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/action.py +0 -0
  28. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/chart.py +0 -0
  29. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/dml.py +0 -0
  30. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/shapes.py +0 -0
  31. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/enum/text.py +0 -0
  32. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/errors.py +0 -0
  33. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/presentation.py +0 -0
  34. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/slides.py +0 -0
  35. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/text.py +0 -0
  36. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/typing.py +0 -0
  37. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/units.py +0 -0
  38. {athena_python_pptx-0.1.54 → athena_python_pptx-0.1.56}/pptx/util.py +0 -0
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to `athena-python-pptx` are documented in this file.
4
+
5
+ ## 0.1.56
6
+
7
+ - **Charts: end-to-end author + edit support** for column / bar / line / area / pie / doughnut (including all stacked variants).
8
+ - `slide.shapes.add_chart(chart_type, left, top, width, height, chart_data)` now authors a fresh chart from scratch — previously raised `UnsupportedFeatureError`. Returns a `GraphicFrame` whose `.chart` property exposes the resulting `Chart` object. Supported types: `XL_CHART_TYPE.COLUMN_CLUSTERED`, `COLUMN_STACKED`, `COLUMN_STACKED_100`, `BAR_CLUSTERED`, `BAR_STACKED`, `BAR_STACKED_100`, `LINE`, `LINE_MARKERS`, `LINE_STACKED`, `LINE_STACKED_100`, `AREA`, `AREA_STACKED`, `AREA_STACKED_100`, `PIE`, `DOUGHNUT`. 3-D / scatter / bubble / radar / stock / surface / combo still raise `UnsupportedFeatureError` with a clearer message listing the supported set.
9
+ - `placeholder.insert_chart(chart_type, chart_data)` now wires up the same path — previously raised `NotImplementedError`.
10
+ - `Chart.replace_data(chart_data)` now emits `UpdateChartData` patches against an existing chart (ingested or just-authored) — previously raised `UnsupportedFeatureError`. Rewrites series values, series names, and category labels in place; embedded `.xlsx` workbook stays in sync.
11
+ - New `Chart.chart_title` setter and `Chart.has_legend` setter emit `SetChartTitle` and `SetLegendVisible` patches respectively.
12
+ - `CategoryChartData.add_series()` and `XyChartData` / `BubbleChartData` now capture data client-side instead of raising eagerly. (Authoring scatter / bubble charts still raises at `add_chart()` time.)
13
+ - `GraphicFrame` extended to host a `Chart` (in addition to `Table`), so `gf.chart` and `gf.has_chart` work.
14
+
15
+ ## 0.1.55
16
+
17
+ - Internal: bumped version coordinated with the `Presentation.create()` server-side fix (#19270). No SDK API change.
18
+
19
+ ## 0.1.54
20
+
21
+ - `RemoteError.__str__` now includes the HTTP status code (e.g., `[HTTP 400] Invalid request body: ...`) so the status is visible in tracebacks without unpacking `exc.status_code`.
22
+
23
+ ## 0.1.39
24
+
25
+ - Added SDK support for `slide.shapes.add_table(...)` and table creation command wiring.
26
+ - Added `slide.notes_slide.notes_text_frame.text` compatibility adapter for python-pptx style notes access.
27
+ - Added support for auto-shape text frame access (`shape.text_frame`) to match python-pptx behavior.
28
+ - Added smoke/integration tests for table creation/cell updates, notes slide adapter, and shape text-frame regression.
29
+ - Updated README examples for notes slide adapter and auto-shape text support.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.1.54
3
+ Version: 0.1.56
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
@@ -126,7 +126,7 @@ def flush_all() -> None:
126
126
  _active_buffers[:] = alive
127
127
 
128
128
 
129
- __version__ = "0.1.54"
129
+ __version__ = "0.1.56"
130
130
 
131
131
  __all__ = [
132
132
  # Main entry point
@@ -0,0 +1,117 @@
1
+ """
2
+ Chart data classes matching python-pptx.
3
+
4
+ These classes capture series data client-side. The actual server-side
5
+ materialization happens via:
6
+
7
+ * `Shape.chart.replace_data(chart_data)` for ingested charts — emits
8
+ `UpdateSeriesValue` / `UpdateCategoryLabel` patches against the existing
9
+ chart part.
10
+ * `slide.shapes.add_chart(chart_type, ..., chart_data)` for SDK-authored
11
+ charts — sends an `AddChart` command that the export-worker
12
+ materializes via `@pptx/chart-ooxml/export/author.ts`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+ from typing import Any, Sequence
17
+
18
+ from ..errors import UnsupportedFeatureError
19
+
20
+
21
+ class CategoryChartData:
22
+ """
23
+ Chart data for category-based charts (column, bar, line, area, pie,
24
+ doughnut). Matches python-pptx's `CategoryChartData` API.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._categories: list[str] = []
29
+ self._series: list[tuple[str, list[float | None]]] = []
30
+
31
+ @property
32
+ def categories(self) -> list[str]:
33
+ """Category labels (x-axis tick labels)."""
34
+ return self._categories
35
+
36
+ @categories.setter
37
+ def categories(self, value: Sequence[str]) -> None:
38
+ """Set category labels."""
39
+ self._categories = [str(v) for v in value]
40
+
41
+ def add_series(
42
+ self,
43
+ name: str,
44
+ values: Sequence[float | None],
45
+ number_format: str = "",
46
+ ) -> None:
47
+ """Add a data series.
48
+
49
+ `number_format` is accepted for python-pptx parity but is not
50
+ currently propagated to the chart spec — the embedded workbook's
51
+ format string controls display formatting.
52
+ """
53
+ self._series.append((name, [None if v is None else float(v) for v in values]))
54
+
55
+ def to_series_payload(self) -> list[dict[str, Any]]:
56
+ """Internal: convert to the wire shape used by AddChart / patches."""
57
+ return [{"name": name, "values": list(values)} for name, values in self._series]
58
+
59
+ def __len__(self) -> int:
60
+ return len(self._series)
61
+
62
+
63
+ # Alias for compatibility with python-pptx (which exposes both names).
64
+ ChartData = CategoryChartData
65
+
66
+
67
+ class _XySeries:
68
+ def __init__(self, name: str) -> None:
69
+ self.name = name
70
+ self.points: list[tuple[float, float]] = []
71
+
72
+ def add_data_point(self, x: float, y: float) -> None:
73
+ self.points.append((float(x), float(y)))
74
+
75
+
76
+ class XyChartData:
77
+ """Chart data for XY (scatter) charts. Captured client-side; authoring
78
+ these via the SDK is gated on the chart-ooxml authoring path adding
79
+ scatter support — currently raises `UnsupportedFeatureError` from
80
+ `add_chart()` for scatter types. The data structure itself is
81
+ populated for parity with python-pptx code."""
82
+
83
+ def __init__(self) -> None:
84
+ self._series: list[_XySeries] = []
85
+
86
+ def add_series(self, name: str, values: Any = None) -> _XySeries:
87
+ series = _XySeries(name)
88
+ if values is not None:
89
+ for point in values:
90
+ if isinstance(point, (tuple, list)) and len(point) == 2:
91
+ series.add_data_point(point[0], point[1])
92
+ self._series.append(series)
93
+ return series
94
+
95
+
96
+ class _BubbleSeries(_XySeries):
97
+ def __init__(self, name: str) -> None:
98
+ super().__init__(name)
99
+ self.sizes: list[float] = []
100
+
101
+ def add_data_point(self, x: float, y: float, size: float = 1.0) -> None: # type: ignore[override]
102
+ super().add_data_point(x, y)
103
+ self.sizes.append(float(size))
104
+
105
+
106
+ class BubbleChartData:
107
+ """Chart data for bubble charts. Same status as `XyChartData` — captured
108
+ client-side, but `add_chart()` for bubble type is unsupported until the
109
+ chart-ooxml authoring path covers bubble plots."""
110
+
111
+ def __init__(self) -> None:
112
+ self._series: list[_BubbleSeries] = []
113
+
114
+ def add_series(self, name: str, values: Any = None) -> _BubbleSeries:
115
+ series = _BubbleSeries(name)
116
+ self._series.append(series)
117
+ return series
@@ -201,6 +201,7 @@ class Client:
201
201
  path: str,
202
202
  json: Optional[dict] = None,
203
203
  params: Optional[dict] = None,
204
+ timeout: Optional[float] = None,
204
205
  ) -> Any:
205
206
  """Make an HTTP request."""
206
207
  url = urljoin(self.base_url + "/", path.lstrip("/"))
@@ -218,7 +219,7 @@ class Client:
218
219
  json=json,
219
220
  params=params,
220
221
  headers=headers,
221
- timeout=self.timeout,
222
+ timeout=timeout if timeout is not None else self.timeout,
222
223
  )
223
224
  return self._handle_response(response)
224
225
  except requests.ConnectionError as e:
@@ -260,21 +261,70 @@ class Client:
260
261
  payload = {"name": name} if name else None
261
262
  return self._request("POST", "/decks", json=payload)
262
263
 
263
- def create_empty_deck(self, name: Optional[str] = None) -> dict[str, Any]:
264
+ def create_empty_deck(
265
+ self,
266
+ name: Optional[str] = None,
267
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
268
+ max_attempts: int = 240,
269
+ ) -> dict[str, Any]:
264
270
  """
265
- Create a new empty deck that's immediately ready.
271
+ Create a new empty deck and wait until it is ready.
266
272
 
267
273
  Unlike create_deck(), this creates a deck without requiring an upload.
268
- The deck is immediately usable with no slides.
274
+ The server provisions a blank PPTX template and runs it through the
275
+ ingest pipeline; this method blocks until ingest is finished.
276
+
277
+ The server may return either:
278
+ * 201 with status="ready" if ingest finishes within its synchronous
279
+ poll window, or
280
+ * 202 with status="processing" if ingest is still in progress.
281
+
282
+ In the 202 case we poll GET /decks/:id at ``poll_interval`` until the
283
+ status flips to "ready" (or "error", in which case we raise). The
284
+ initial POST uses an extended timeout so it doesn't race the server's
285
+ own poll loop.
269
286
 
270
287
  Args:
271
288
  name: Optional name for the deck
289
+ poll_interval: Seconds between status polls
290
+ max_attempts: Maximum number of poll attempts (default 240 ≈ 2 min)
272
291
 
273
292
  Returns:
274
- Dictionary with id, name, status, slideCount, createdAt
293
+ Dictionary with id, name, status="ready", slideCount, createdAt
294
+
295
+ Raises:
296
+ UploadError: If ingest reports an error or never reaches "ready".
275
297
  """
276
298
  payload = {"name": name} if name else None
277
- return self._request("POST", "/decks/empty", json=payload)
299
+ # Use an extended timeout so we don't race the server's synchronous
300
+ # poll window (the server may hold the request open for up to ~30s
301
+ # waiting for ingest to complete before returning 202).
302
+ result = self._request("POST", "/decks/empty", json=payload, timeout=120)
303
+
304
+ deck_id = result.get("id")
305
+ status = result.get("status")
306
+ if status == "ready":
307
+ return result
308
+ if not deck_id:
309
+ raise UploadError(
310
+ "Server did not return a deck id from POST /decks/empty",
311
+ None,
312
+ )
313
+
314
+ # Poll until ingest finishes.
315
+ for _ in range(max_attempts):
316
+ deck = self.get_deck(deck_id)
317
+ current_status = deck.get("status")
318
+ if current_status == "ready":
319
+ return deck
320
+ if current_status == "error":
321
+ raise UploadError("Empty deck ingest failed", deck_id)
322
+ time.sleep(poll_interval)
323
+
324
+ raise UploadError(
325
+ "Empty deck ingest timed out before reaching 'ready'",
326
+ deck_id,
327
+ )
278
328
 
279
329
  def delete_deck(self, deck_id: str) -> None:
280
330
  """Delete a deck."""
@@ -1371,6 +1371,101 @@ class SubstitutePlaceholder(Command):
1371
1371
  raise ValidationError("rows and cols required for table", "rows")
1372
1372
 
1373
1373
 
1374
+ @dataclass
1375
+ class UpdateChartData(Command):
1376
+ """
1377
+ Append `ChartPatch` intents to a chart shape's mutation queue.
1378
+
1379
+ The export-worker drains the queue at export time and applies each
1380
+ patch to the underlying chart-part XML via
1381
+ @pptx/chart-ooxml/export/patcher.ts. Supported patch ops mirror the
1382
+ chart-core ChartPatch discriminated union: UpdateSeriesValue,
1383
+ UpdateSeriesName, UpdateCategoryLabel, SetChartTitle,
1384
+ SetLegendVisible, SetSeriesColor.
1385
+ """
1386
+
1387
+ shape_id: ShapeId
1388
+ patches: list[dict[str, Any]] = field(default_factory=list)
1389
+
1390
+ @property
1391
+ def command_type(self) -> str:
1392
+ return "UpdateChartData"
1393
+
1394
+ def validate(self) -> None:
1395
+ if not self.shape_id:
1396
+ raise ValidationError("shape_id is required", "shape_id")
1397
+ if not self.patches:
1398
+ raise ValidationError("at least one patch is required", "patches")
1399
+ valid_ops = {
1400
+ "UpdateSeriesValue",
1401
+ "UpdateSeriesName",
1402
+ "UpdateCategoryLabel",
1403
+ "SetChartTitle",
1404
+ "SetLegendVisible",
1405
+ "SetSeriesColor",
1406
+ }
1407
+ for i, p in enumerate(self.patches):
1408
+ if not isinstance(p, dict) or p.get("op") not in valid_ops:
1409
+ raise ValidationError(
1410
+ f"patches[{i}].op must be one of {sorted(valid_ops)}",
1411
+ f"patches[{i}]",
1412
+ )
1413
+
1414
+
1415
+ @dataclass
1416
+ class AddChart(Command):
1417
+ """
1418
+ Author a brand-new chart from scratch on a slide.
1419
+
1420
+ The chart element is created with provenance='sdk' and no chartPartRef;
1421
+ the export-worker materializes the chart-part XML, embedded workbook,
1422
+ and slide-level relationships via @pptx/chart-ooxml/export/author.ts
1423
+ on the next export.
1424
+ """
1425
+
1426
+ slide_index: int
1427
+ chart_type: str # 'column' | 'bar' | 'line' | 'area' | 'pie' | 'doughnut'
1428
+ x_emu: int
1429
+ y_emu: int
1430
+ w_emu: int
1431
+ h_emu: int
1432
+ categories: list[str] = field(default_factory=list)
1433
+ series: list[dict[str, Any]] = field(default_factory=list)
1434
+ title: Optional[str] = None
1435
+ grouping: Optional[str] = None # 'clustered' | 'stacked' | 'percentStacked'
1436
+ name: Optional[str] = None
1437
+ client_id: Optional[str] = None
1438
+
1439
+ @property
1440
+ def command_type(self) -> str:
1441
+ return "AddChart"
1442
+
1443
+ def validate(self) -> None:
1444
+ if self.slide_index < 0:
1445
+ raise ValidationError("slide_index must be >= 0", "slide_index")
1446
+ valid_types = {"column", "bar", "line", "area", "pie", "doughnut"}
1447
+ if self.chart_type not in valid_types:
1448
+ raise ValidationError(
1449
+ f"chart_type must be one of {sorted(valid_types)}",
1450
+ "chart_type",
1451
+ )
1452
+ if self.w_emu <= 0:
1453
+ raise ValidationError("w_emu must be positive", "w_emu")
1454
+ if self.h_emu <= 0:
1455
+ raise ValidationError("h_emu must be positive", "h_emu")
1456
+ if not self.series:
1457
+ raise ValidationError("at least one series is required", "series")
1458
+ if self.grouping is not None and self.grouping not in (
1459
+ "clustered",
1460
+ "stacked",
1461
+ "percentStacked",
1462
+ ):
1463
+ raise ValidationError(
1464
+ "grouping must be 'clustered', 'stacked', or 'percentStacked'",
1465
+ "grouping",
1466
+ )
1467
+
1468
+
1374
1469
  # Type alias for any command
1375
1470
  AnyCommand = Union[
1376
1471
  AddTextBox,
@@ -1411,4 +1506,7 @@ AnyCommand = Union[
1411
1506
  SetShapeAdjustments,
1412
1507
  FitText,
1413
1508
  SubstitutePlaceholder,
1509
+ # Charts
1510
+ UpdateChartData,
1511
+ AddChart,
1414
1512
  ]
@@ -19,7 +19,9 @@ from .commands import (
19
19
  SetShapeAdjustments, MergeCells as MergeCellsCmd,
20
20
  SplitCell as SplitCellCmd, SetCellMargins, SetColWidth, SetRowHeight,
21
21
  SetShapeAdjustments, SubstitutePlaceholder,
22
+ UpdateChartData as UpdateChartDataCmd, AddChart as AddChartCmd,
22
23
  )
24
+ from .chart.data import CategoryChartData, ChartData
23
25
  from .dml.color import RGBColor, ColorFormat
24
26
  from .errors import UnsupportedFeatureError
25
27
  from .text import TextFrame
@@ -35,6 +37,75 @@ if TYPE_CHECKING:
35
37
  # ``from pptx.shapes import MSO_SHAPE`` and
36
38
  # ``from pptx.enum.shapes import MSO_SHAPE`` resolve to the same IntEnum.
37
39
  from .enum.shapes import MSO_SHAPE # noqa: F811
40
+ from .enum.chart import XL_CHART_TYPE
41
+
42
+
43
+ # Map XL_CHART_TYPE → (server chart_type, grouping). Only the chart types
44
+ # author.ts supports today are listed here; anything else raises
45
+ # UnsupportedFeatureError. Surface area mirrors python-pptx's accepted enum
46
+ # values so callers can switch between python-pptx and athena-python-pptx
47
+ # without code changes for the supported subset.
48
+ _CHART_TYPE_MAP: dict[int, tuple[str, Optional[str]]] = {
49
+ XL_CHART_TYPE.COLUMN_CLUSTERED: ("column", "clustered"),
50
+ XL_CHART_TYPE.COLUMN_STACKED: ("column", "stacked"),
51
+ XL_CHART_TYPE.COLUMN_STACKED_100: ("column", "percentStacked"),
52
+ XL_CHART_TYPE.BAR_CLUSTERED: ("bar", "clustered"),
53
+ XL_CHART_TYPE.BAR_STACKED: ("bar", "stacked"),
54
+ XL_CHART_TYPE.BAR_STACKED_100: ("bar", "percentStacked"),
55
+ XL_CHART_TYPE.LINE: ("line", "clustered"),
56
+ XL_CHART_TYPE.LINE_MARKERS: ("line", "clustered"),
57
+ XL_CHART_TYPE.LINE_STACKED: ("line", "stacked"),
58
+ XL_CHART_TYPE.LINE_STACKED_100: ("line", "percentStacked"),
59
+ XL_CHART_TYPE.AREA: ("area", "clustered"),
60
+ XL_CHART_TYPE.AREA_STACKED: ("area", "stacked"),
61
+ XL_CHART_TYPE.AREA_STACKED_100: ("area", "percentStacked"),
62
+ XL_CHART_TYPE.PIE: ("pie", None),
63
+ XL_CHART_TYPE.DOUGHNUT: ("doughnut", None),
64
+ }
65
+
66
+
67
+ def _split_chart_type(chart_type: Any) -> tuple[str, Optional[str]]:
68
+ """
69
+ Resolve a `XL_CHART_TYPE` (or matching int / str) into the server's
70
+ `(chart_type, grouping)` pair. Raises `UnsupportedFeatureError` for
71
+ chart types the author.ts path does not yet emit (3-D variants,
72
+ scatter, bubble, radar, stock, combo, surface).
73
+ """
74
+ if isinstance(chart_type, str):
75
+ normalized = chart_type.lower().strip()
76
+ if normalized in {"column", "bar", "line", "area"}:
77
+ return normalized, "clustered"
78
+ if normalized in {"pie", "doughnut"}:
79
+ return normalized, None
80
+ raise UnsupportedFeatureError(
81
+ "add_chart",
82
+ f"Unsupported chart type string '{chart_type}'. "
83
+ "Use one of: 'column', 'bar', 'line', 'area', 'pie', 'doughnut'.",
84
+ )
85
+ try:
86
+ key = int(chart_type)
87
+ except (TypeError, ValueError):
88
+ raise UnsupportedFeatureError(
89
+ "add_chart",
90
+ f"Unsupported chart_type value: {chart_type!r}",
91
+ )
92
+ mapped = _CHART_TYPE_MAP.get(key)
93
+ if mapped is None:
94
+ raise UnsupportedFeatureError(
95
+ "add_chart",
96
+ f"Chart type {chart_type!r} is not supported by the SDK author "
97
+ "path. Supported XL_CHART_TYPE values: COLUMN_CLUSTERED, "
98
+ "COLUMN_STACKED, COLUMN_STACKED_100, BAR_CLUSTERED, BAR_STACKED, "
99
+ "BAR_STACKED_100, LINE, LINE_MARKERS, LINE_STACKED, "
100
+ "LINE_STACKED_100, AREA, AREA_STACKED, AREA_STACKED_100, PIE, "
101
+ "DOUGHNUT.",
102
+ )
103
+ return mapped
104
+
105
+
106
+ def _coerce_chart_type(chart_type: Any) -> str:
107
+ """Backward-compatible single-string accessor."""
108
+ return _split_chart_type(chart_type)[0]
38
109
 
39
110
 
40
111
  class PlaceholderFormat:
@@ -844,10 +915,13 @@ class ClickAction:
844
915
 
845
916
  class Chart:
846
917
  """
847
- Minimal chart proxy for python-pptx compatibility.
918
+ Chart proxy for python-pptx compatibility.
848
919
 
849
- Chart creation/editing is not implemented yet, but this proxy allows
850
- `shape.chart` access patterns to fail with explicit unsupported messages.
920
+ Wraps a chart Shape and exposes the python-pptx chart edit surface.
921
+ Edits emit `UpdateChartData` commands that append `ChartPatch` intents
922
+ onto the chart element's `chartPatches` queue; the export-worker drains
923
+ the queue and applies each patch via
924
+ `@pptx/chart-ooxml/export/patcher.ts` on the next export.
851
925
  """
852
926
 
853
927
  def __init__(self, shape: "Shape"):
@@ -858,12 +932,115 @@ class Chart:
858
932
  """Best-effort chart type if available in element properties."""
859
933
  return self._shape._properties.get("chartType")
860
934
 
935
+ @property
936
+ def has_title(self) -> bool:
937
+ """python-pptx parity: True if the underlying chart has a title."""
938
+ return bool(self._shape._properties.get("title"))
939
+
861
940
  def replace_data(self, chart_data: Any) -> None:
862
- """Replace chart data (not yet supported)."""
863
- raise UnsupportedFeatureError(
864
- "shape.chart.replace_data",
865
- "Chart editing is not yet supported",
866
- )
941
+ """
942
+ Replace this chart's series data with the values in `chart_data`.
943
+
944
+ Mirrors python-pptx semantics: existing series are kept in place
945
+ and their values are overwritten in series order. The category
946
+ labels are also rewritten.
947
+
948
+ Implementation: emits `UpdateSeriesValue` patches (one per data
949
+ point) plus `UpdateSeriesName` and `UpdateCategoryLabel` patches.
950
+ Server queues them on the chart element's `chartPatches`; the
951
+ export-worker applies them to the chart-part XML on export.
952
+ """
953
+ if not isinstance(chart_data, CategoryChartData):
954
+ raise TypeError(
955
+ "replace_data() requires a CategoryChartData instance "
956
+ f"(got {type(chart_data).__name__})"
957
+ )
958
+
959
+ chart_id = self._shape.shape_id
960
+ existing_spec = self._shape._properties.get("chartSpec") or {}
961
+ existing_plots = existing_spec.get("plots") or []
962
+ existing_series_ids: list[str] = []
963
+ if existing_plots:
964
+ for s in (existing_plots[0].get("series") or []):
965
+ existing_series_ids.append(str(s.get("id") or ""))
966
+
967
+ patches: list[dict[str, Any]] = []
968
+
969
+ for s_idx, (name, values) in enumerate(zip(
970
+ [n for n, _ in chart_data._series],
971
+ [v for _, v in chart_data._series],
972
+ )):
973
+ series_id = (
974
+ existing_series_ids[s_idx]
975
+ if s_idx < len(existing_series_ids)
976
+ else f"series-{s_idx}"
977
+ )
978
+ patches.append({
979
+ "op": "UpdateSeriesName",
980
+ "chartId": chart_id,
981
+ "seriesId": series_id,
982
+ "name": name,
983
+ })
984
+ for i, v in enumerate(values):
985
+ patches.append({
986
+ "op": "UpdateSeriesValue",
987
+ "chartId": chart_id,
988
+ "seriesId": series_id,
989
+ "index": i,
990
+ "value": v,
991
+ })
992
+
993
+ for i, label in enumerate(chart_data.categories):
994
+ patches.append({
995
+ "op": "UpdateCategoryLabel",
996
+ "chartId": chart_id,
997
+ "index": i,
998
+ "label": label,
999
+ })
1000
+
1001
+ if not patches:
1002
+ return
1003
+
1004
+ if self._shape._buffer is not None:
1005
+ self._shape._buffer.add(UpdateChartDataCmd(
1006
+ shape_id=chart_id,
1007
+ patches=patches,
1008
+ ))
1009
+
1010
+ @property
1011
+ def chart_title(self) -> Optional[str]:
1012
+ return self._shape._properties.get("title")
1013
+
1014
+ @chart_title.setter
1015
+ def chart_title(self, value: str) -> None:
1016
+ if self._shape._buffer is None:
1017
+ return
1018
+ self._shape._buffer.add(UpdateChartDataCmd(
1019
+ shape_id=self._shape.shape_id,
1020
+ patches=[{
1021
+ "op": "SetChartTitle",
1022
+ "chartId": self._shape.shape_id,
1023
+ "title": str(value),
1024
+ }],
1025
+ ))
1026
+
1027
+ @property
1028
+ def has_legend(self) -> bool:
1029
+ legend = self._shape._properties.get("legend") or {}
1030
+ return bool(legend.get("visible"))
1031
+
1032
+ @has_legend.setter
1033
+ def has_legend(self, value: bool) -> None:
1034
+ if self._shape._buffer is None:
1035
+ return
1036
+ self._shape._buffer.add(UpdateChartDataCmd(
1037
+ shape_id=self._shape.shape_id,
1038
+ patches=[{
1039
+ "op": "SetLegendVisible",
1040
+ "chartId": self._shape.shape_id,
1041
+ "visible": bool(value),
1042
+ }],
1043
+ ))
867
1044
 
868
1045
 
869
1046
  class _ElementParentProxy:
@@ -2303,21 +2480,53 @@ class Shape:
2303
2480
  """
2304
2481
  Insert a chart into this placeholder.
2305
2482
 
2306
- .. note::
2307
- Chart insertion is not yet supported in the REST SDK.
2308
- This method exists for API parity with python-pptx.
2309
-
2310
- Raises:
2311
- NotImplementedError: Chart insertion is not yet supported.
2483
+ Authoring path: emits an `AddChart` command targeted at the
2484
+ placeholder's slide and uses the placeholder's anchor as the chart
2485
+ bounding box. The export-worker materializes the chart-part XML
2486
+ and embedded workbook on the next export via
2487
+ `@pptx/chart-ooxml/export/author.ts`.
2312
2488
  """
2313
2489
  if not self.is_placeholder:
2314
2490
  raise ValueError("insert_chart() is only valid on placeholder shapes")
2315
- raise NotImplementedError(
2316
- "Chart insertion into placeholders is not yet supported by athena-python-pptx. "
2317
- "Do NOT retry this method — it will always fail. "
2318
- "Alternatives: substitute the placeholder with a picture (pre-rendered chart image) "
2319
- "via add_picture(), or with a table via add_table(). "
2320
- "Chart authoring is Phase 5 of the charting refactor."
2491
+ if not isinstance(chart_data, CategoryChartData):
2492
+ raise TypeError(
2493
+ "insert_chart() requires a CategoryChartData instance "
2494
+ f"(got {type(chart_data).__name__})"
2495
+ )
2496
+
2497
+ chart_type_str, grouping = _split_chart_type(chart_type)
2498
+
2499
+ transform = self._transform
2500
+ x_emu = int(ensure_emu(self.left)) if transform else 0
2501
+ y_emu = int(ensure_emu(self.top)) if transform else 0
2502
+ w_emu = int(ensure_emu(self.width)) if transform else 6_858_000
2503
+ h_emu = int(ensure_emu(self.height)) if transform else 4_572_000
2504
+
2505
+ if self._buffer is not None:
2506
+ self._buffer.add(AddChartCmd(
2507
+ slide_index=self._slide.slide_index,
2508
+ chart_type=chart_type_str,
2509
+ x_emu=x_emu, y_emu=y_emu, w_emu=w_emu, h_emu=h_emu,
2510
+ categories=list(chart_data.categories),
2511
+ series=chart_data.to_series_payload(),
2512
+ grouping=grouping,
2513
+ ))
2514
+
2515
+ host_shape = Shape(
2516
+ shape_id=self._shape_id,
2517
+ slide=self._slide,
2518
+ buffer=self._buffer,
2519
+ element_type="chart",
2520
+ transform=Transform(x=x_emu, y=y_emu, w=w_emu, h=h_emu),
2521
+ properties={"chartType": chart_type_str},
2522
+ source="sdk",
2523
+ )
2524
+ return GraphicFrame(
2525
+ shape_id=self._shape_id,
2526
+ slide=self._slide,
2527
+ buffer=self._buffer,
2528
+ chart=Chart(host_shape),
2529
+ transform=Transform(x=x_emu, y=y_emu, w=w_emu, h=h_emu),
2321
2530
  )
2322
2531
 
2323
2532
  def __repr__(self) -> str:
@@ -2908,20 +3117,81 @@ class Shapes:
2908
3117
  width: Length,
2909
3118
  height: Length,
2910
3119
  chart_data: Any,
2911
- ) -> Any:
2912
- """Add a chart (not yet supported in this SDK)."""
2913
- raise UnsupportedFeatureError(
2914
- "shapes.add_chart",
2915
- (
2916
- "Chart authoring from scratch is not supported by athena-python-pptx yet. "
2917
- "Do NOT retry add_chart() it will always fail. "
2918
- "Supported alternatives: "
2919
- "(1) render the chart to a PNG/JPEG externally (e.g. matplotlib, chart.js-node, or an external service) and use shapes.add_picture() to insert it; "
2920
- "(2) represent the data as a table via shapes.add_table(); "
2921
- "(3) describe the chart in text via shapes.add_textbox(). "
2922
- "Chart ingest round-trip is supported for existing charts in uploaded PPTX decks, but creation is tracked under Phase 5 of the charting refactor (see pptx-studio/docs/CHARTING_REFACTOR_PLAN_2026-04-21.md)."
2923
- ),
3120
+ ) -> "GraphicFrame":
3121
+ """
3122
+ Author a brand-new chart on this slide.
3123
+
3124
+ Mirrors python-pptx's `SlideShapes.add_chart()`. Returns a
3125
+ `GraphicFrame` whose `.chart` property exposes the resulting
3126
+ `Chart` proxy. Edits via `chart.replace_data()` / `chart.chart_title`
3127
+ / `chart.has_legend` are emitted as `UpdateChartData` patches and
3128
+ applied on export.
3129
+
3130
+ Supported chart types: `XL_CHART_TYPE.COLUMN_CLUSTERED`,
3131
+ `BAR_CLUSTERED`, `BAR_STACKED`, `BAR_STACKED_100`,
3132
+ `COLUMN_STACKED`, `COLUMN_STACKED_100`, `LINE`, `LINE_STACKED`,
3133
+ `LINE_STACKED_100`, `AREA`, `AREA_STACKED`, `AREA_STACKED_100`,
3134
+ `PIE`, `DOUGHNUT`. Other types raise `UnsupportedFeatureError`.
3135
+ """
3136
+ import uuid
3137
+
3138
+ if not isinstance(chart_data, CategoryChartData):
3139
+ raise TypeError(
3140
+ "add_chart() requires a CategoryChartData instance "
3141
+ f"(got {type(chart_data).__name__})"
3142
+ )
3143
+
3144
+ chart_type_str, grouping = _split_chart_type(chart_type)
3145
+
3146
+ x_emu = int(ensure_emu(left))
3147
+ y_emu = int(ensure_emu(top))
3148
+ w_emu = int(ensure_emu(width))
3149
+ h_emu = int(ensure_emu(height))
3150
+
3151
+ client_id = f"cht_{uuid.uuid4().hex[:8]}"
3152
+ shape_id = client_id
3153
+
3154
+ if self._buffer:
3155
+ cmd = AddChartCmd(
3156
+ slide_index=self._slide.slide_index,
3157
+ chart_type=chart_type_str,
3158
+ x_emu=x_emu, y_emu=y_emu, w_emu=w_emu, h_emu=h_emu,
3159
+ categories=list(chart_data.categories),
3160
+ series=chart_data.to_series_payload(),
3161
+ title=None,
3162
+ grouping=grouping,
3163
+ client_id=client_id,
3164
+ )
3165
+ response = self._buffer.add(cmd)
3166
+ if response and response.get("created"):
3167
+ shape_ids = response["created"].get("shapeIds", [])
3168
+ if shape_ids:
3169
+ shape_id = shape_ids[0]
3170
+
3171
+ transform = Transform(x=x_emu, y=y_emu, w=w_emu, h=h_emu)
3172
+
3173
+ host_shape = Shape(
3174
+ shape_id=shape_id,
3175
+ slide=self._slide,
3176
+ buffer=self._buffer,
3177
+ element_type="chart",
3178
+ transform=transform,
3179
+ properties={"chartType": chart_type_str},
3180
+ source="sdk",
2924
3181
  )
3182
+ chart_proxy = Chart(host_shape)
3183
+
3184
+ graphic_frame = GraphicFrame(
3185
+ shape_id=shape_id,
3186
+ slide=self._slide,
3187
+ buffer=self._buffer,
3188
+ chart=chart_proxy,
3189
+ transform=transform,
3190
+ )
3191
+ graphic_frame._source = "sdk"
3192
+ self._shapes.append(graphic_frame)
3193
+ self._shapes_by_id[shape_id] = graphic_frame
3194
+ return graphic_frame
2925
3195
 
2926
3196
  # -------------------------------------------------------------------------
2927
3197
  # Phase 5: Grouping
@@ -4074,11 +4344,12 @@ class _ColumnCollection:
4074
4344
 
4075
4345
  class GraphicFrame(Shape):
4076
4346
  """
4077
- A graphic frame shape that contains a table.
4347
+ A graphic frame shape that contains a table or chart.
4078
4348
 
4079
4349
  Mirrors python-pptx's GraphicFrame class.
4080
- In python-pptx, SlideShapes.add_table() returns a GraphicFrame,
4081
- and the Table is accessed via .table property.
4350
+ In python-pptx, SlideShapes.add_table() returns a GraphicFrame and
4351
+ SlideShapes.add_chart() returns a GraphicFrame whose `.chart` exposes
4352
+ the underlying Chart object.
4082
4353
  """
4083
4354
 
4084
4355
  def __init__(
@@ -4086,25 +4357,27 @@ class GraphicFrame(Shape):
4086
4357
  shape_id: ShapeId,
4087
4358
  slide: "Slide",
4088
4359
  buffer: Optional["CommandBuffer"],
4089
- table: "Table",
4360
+ table: Optional["Table"] = None,
4361
+ chart: Optional["Chart"] = None,
4090
4362
  transform: Optional[Transform] = None,
4091
4363
  ):
4364
+ if table is None and chart is None:
4365
+ raise ValueError("GraphicFrame requires a table or chart")
4092
4366
  super().__init__(
4093
4367
  shape_id=shape_id,
4094
4368
  slide=slide,
4095
4369
  buffer=buffer,
4096
- element_type="table",
4370
+ element_type="chart" if chart is not None else "table",
4097
4371
  transform=transform,
4098
4372
  )
4099
4373
  self._table = table
4374
+ self._chart = chart
4100
4375
 
4101
4376
  @property
4102
4377
  def table(self) -> "Table":
4103
- """
4104
- The Table object contained in this graphic frame.
4105
-
4106
- Mirrors python-pptx GraphicFrame.table property.
4107
- """
4378
+ """The Table object contained in this graphic frame."""
4379
+ if self._table is None:
4380
+ raise ValueError("This GraphicFrame does not contain a table")
4108
4381
  return self._table
4109
4382
 
4110
4383
  @property
@@ -4112,22 +4385,36 @@ class GraphicFrame(Shape):
4112
4385
  """True if this graphic frame contains a table."""
4113
4386
  return self._table is not None
4114
4387
 
4388
+ @property
4389
+ def chart(self) -> "Chart":
4390
+ """The Chart object contained in this graphic frame."""
4391
+ if self._chart is None:
4392
+ raise ValueError("This GraphicFrame does not contain a chart")
4393
+ return self._chart
4394
+
4395
+ @property
4396
+ def has_chart(self) -> bool:
4397
+ """True if this graphic frame contains a chart."""
4398
+ return self._chart is not None
4399
+
4115
4400
  # Passthrough methods so agent code like tbl.cell(0,0) works directly
4116
4401
  def cell(self, row: int, col: int) -> "TableCell":
4117
4402
  """Shortcut: delegates to self.table.cell(row, col)."""
4118
- return self._table.cell(row, col)
4403
+ return self.table.cell(row, col)
4119
4404
 
4120
4405
  @property
4121
4406
  def rows(self) -> "_RowCollection":
4122
4407
  """Shortcut: delegates to self.table.rows."""
4123
- return self._table.rows
4408
+ return self.table.rows
4124
4409
 
4125
4410
  @property
4126
4411
  def cols(self) -> int:
4127
4412
  """Shortcut: delegates to self.table.cols."""
4128
- return self._table.cols
4413
+ return self.table.cols
4129
4414
 
4130
4415
  def __repr__(self) -> str:
4416
+ if self._chart is not None:
4417
+ return f"<GraphicFrame shape_id={self._shape_id} chart>"
4131
4418
  return f"<GraphicFrame shape_id={self._shape_id} table={self._table._rows}x{self._table._cols}>"
4132
4419
 
4133
4420
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-pptx"
7
- version = "0.1.54"
7
+ version = "0.1.56"
8
8
  description = "Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,15 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `athena-python-pptx` are documented in this file.
4
-
5
- ## 0.1.54
6
-
7
- - `RemoteError.__str__` now includes the HTTP status code (e.g., `[HTTP 400] Invalid request body: ...`) so the status is visible in tracebacks without unpacking `exc.status_code`.
8
-
9
- ## 0.1.39
10
-
11
- - Added SDK support for `slide.shapes.add_table(...)` and table creation command wiring.
12
- - Added `slide.notes_slide.notes_text_frame.text` compatibility adapter for python-pptx style notes access.
13
- - Added support for auto-shape text frame access (`shape.text_frame`) to match python-pptx behavior.
14
- - Added smoke/integration tests for table creation/cell updates, notes slide adapter, and shape text-frame regression.
15
- - Updated README examples for notes slide adapter and auto-shape text support.
@@ -1,96 +0,0 @@
1
- """
2
- Chart data classes matching python-pptx.
3
-
4
- These are stub implementations that raise UnsupportedFeatureError.
5
- """
6
-
7
- from __future__ import annotations
8
- from typing import Any, Sequence
9
-
10
- from ..errors import UnsupportedFeatureError
11
-
12
-
13
- class CategoryChartData:
14
- """
15
- Chart data for category-based charts.
16
-
17
- This is a stub implementation - charts are not yet supported.
18
- """
19
-
20
- def __init__(self) -> None:
21
- self._categories: list[str] = []
22
- self._series: list[tuple[str, Sequence[float]]] = []
23
-
24
- @property
25
- def categories(self) -> list[str]:
26
- """Category labels."""
27
- return self._categories
28
-
29
- @categories.setter
30
- def categories(self, value: Sequence[str]) -> None:
31
- """Set category labels."""
32
- self._categories = list(value)
33
-
34
- def add_series(self, name: str, values: Sequence[float], number_format: str = "") -> None:
35
- """
36
- Add a data series.
37
-
38
- Note: This method stores data but actual chart creation is not yet supported.
39
- """
40
- self._series.append((name, values))
41
-
42
- def _raise_not_supported(self) -> None:
43
- raise UnsupportedFeatureError(
44
- "CategoryChartData",
45
- "Charts are not yet supported. Data can be stored but charts cannot be created."
46
- )
47
-
48
-
49
- # Alias for compatibility
50
- ChartData = CategoryChartData
51
-
52
-
53
- class XyChartData:
54
- """
55
- Chart data for XY (scatter) charts.
56
-
57
- This is a stub implementation - charts are not yet supported.
58
- """
59
-
60
- def __init__(self) -> None:
61
- self._series: list[Any] = []
62
-
63
- def add_series(self, name: str, values: Any = None) -> Any:
64
- """
65
- Add a data series.
66
-
67
- Raises:
68
- UnsupportedFeatureError: Charts are not yet supported
69
- """
70
- raise UnsupportedFeatureError(
71
- "XyChartData.add_series",
72
- "XY charts are not yet supported"
73
- )
74
-
75
-
76
- class BubbleChartData:
77
- """
78
- Chart data for bubble charts.
79
-
80
- This is a stub implementation - charts are not yet supported.
81
- """
82
-
83
- def __init__(self) -> None:
84
- self._series: list[Any] = []
85
-
86
- def add_series(self, name: str, values: Any = None) -> Any:
87
- """
88
- Add a data series.
89
-
90
- Raises:
91
- UnsupportedFeatureError: Charts are not yet supported
92
- """
93
- raise UnsupportedFeatureError(
94
- "BubbleChartData.add_series",
95
- "Bubble charts are not yet supported"
96
- )