athena-python-pptx 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/PKG-INFO +1 -2
  2. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/docs/API_PARITY_EXCEPTIONS.md +28 -5
  3. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/__init__.py +1 -1
  4. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/batching.py +34 -1
  5. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/client.py +17 -1
  6. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/commands.py +98 -0
  7. athena_python_pptx-0.10.0/pptx/oxml/__init__.py +43 -0
  8. athena_python_pptx-0.10.0/pptx/oxml/ns.py +91 -0
  9. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/presentation.py +50 -57
  10. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/__init__.py +172 -34
  11. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/slides.py +99 -52
  12. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/typing.py +1 -0
  13. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pyproject.toml +6 -2
  14. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/.gitignore +0 -0
  15. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/API_PARITY_REPORT.md +0 -0
  16. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/CHANGELOG.md +0 -0
  17. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/CLAUDE.md +0 -0
  18. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/DEV-GUIDE.md +0 -0
  19. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/PARITY_QUESTIONS.md +0 -0
  20. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/PUBLISHING.md +0 -0
  21. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/README.md +0 -0
  22. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/docs/athena-api.json +0 -0
  23. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/docs/athena-api.md +0 -0
  24. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/_athena_extension.py +0 -0
  25. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/_citations.py +0 -0
  26. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/_ptc.py +0 -0
  27. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/_references.py +0 -0
  28. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/action.py +0 -0
  29. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/__init__.py +0 -0
  30. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/axis.py +0 -0
  31. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/category.py +0 -0
  32. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/chart.py +0 -0
  33. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/data.py +0 -0
  34. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/datalabel.py +0 -0
  35. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/legend.py +0 -0
  36. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/marker.py +0 -0
  37. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/plot.py +0 -0
  38. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/point.py +0 -0
  39. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/series.py +0 -0
  40. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/chart/xlsx.py +0 -0
  41. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/decorators.py +0 -0
  42. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/__init__.py +0 -0
  43. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/chtfmt.py +0 -0
  44. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/color.py +0 -0
  45. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/effect.py +0 -0
  46. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/fill.py +0 -0
  47. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/dml/line.py +0 -0
  48. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/docgen.py +0 -0
  49. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/__init__.py +0 -0
  50. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/action.py +0 -0
  51. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/chart.py +0 -0
  52. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/dml.py +0 -0
  53. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/lang.py +0 -0
  54. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/shapes.py +0 -0
  55. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/enum/text.py +0 -0
  56. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/errors.py +0 -0
  57. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/exc.py +0 -0
  58. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/media.py +0 -0
  59. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/package.py +0 -0
  60. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/__init__.py +0 -0
  61. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/_base.py +0 -0
  62. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/chart.py +0 -0
  63. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/coreprops.py +0 -0
  64. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/embeddedpackage.py +0 -0
  65. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/image.py +0 -0
  66. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/media.py +0 -0
  67. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/presentation.py +0 -0
  68. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/parts/slide.py +0 -0
  69. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/autoshape.py +0 -0
  70. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/base.py +0 -0
  71. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/connector.py +0 -0
  72. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/freeform.py +0 -0
  73. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/graphfrm.py +0 -0
  74. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/group.py +0 -0
  75. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/picture.py +0 -0
  76. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/placeholder.py +0 -0
  77. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shapes/shapetree.py +0 -0
  78. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/shared.py +0 -0
  79. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/slide.py +0 -0
  80. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/spec.py +0 -0
  81. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/table.py +0 -0
  82. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/text/__init__.py +0 -0
  83. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/text/fonts.py +0 -0
  84. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/text/layout.py +0 -0
  85. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/text/text.py +0 -0
  86. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/types.py +0 -0
  87. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/units.py +0 -0
  88. {athena_python_pptx-0.9.0 → athena_python_pptx-0.10.0}/pptx/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -31,7 +31,6 @@ Requires-Dist: responses>=0.23; extra == 'dev'
31
31
  Requires-Dist: ruff>=0.1; extra == 'dev'
32
32
  Provides-Extra: e2e
33
33
  Requires-Dist: pytest>=7.0; extra == 'e2e'
34
- Requires-Dist: python-pptx>=0.6.21; extra == 'e2e'
35
34
  Provides-Extra: visual
36
35
  Requires-Dist: numpy>=1.24; extra == 'visual'
37
36
  Requires-Dist: pillow>=10.0; extra == 'visual'
@@ -288,12 +288,18 @@ the iterate-and-rebuild pattern in the v0.1.77 retrospective. None of them
288
288
  exist in upstream python-pptx — they're Athena additions and code that uses
289
289
  them is *not* portable to stock python-pptx.
290
290
 
291
- ### `slide.clear_shapes() -> int`
291
+ ### `slide.clear_shapes(*, delete_unmodeled_source_content=False) -> int`
292
292
 
293
- Delete every slide-local shape in one batched `post_commands` call. Returns
294
- the number of shapes queued for deletion. Targets only slide-local shapes
295
- (what `slide.shapes` iterates); inherited layout / master placeholders are
296
- left alone.
293
+ Delete every slide-local shape with one slide-scoped command. Returns the
294
+ number of modeled shapes removed. Unmodeled visual OOXML from the imported
295
+ slide is preserved by default, because Athena not rendering an object is not
296
+ authorization to delete it. Inherited layout / master placeholders and
297
+ slide-level background/notes are left alone.
298
+
299
+ Pass `delete_unmodeled_source_content=True` only after the user explicitly
300
+ authorizes replacing the complete imported visual layer. That option can
301
+ delete text boxes, SmartArt, controls, and other PowerPoint content that is
302
+ not visible in Athena.
297
303
 
298
304
  **Why we added it:** the previous workaround was a manual
299
305
  `for shape in slide.shapes: shape.delete()` loop. Combined with the silent
@@ -390,6 +396,15 @@ upstream auto-assigns names like `"Rectangle 47"` and only exposes
390
396
  construction. **Portable code that needs to run against stock python-pptx
391
397
  should omit `name=` and assign after the fact.**
392
398
 
399
+ When `name=` is omitted, Athena's generated name skips any identical name in
400
+ the slide's current SDK snapshot. This matters after clone/delete workflows
401
+ where the simple shape-count candidate can already exist. The selected name is
402
+ also available immediately on the returned proxy, before a snapshot refresh.
403
+ This is snapshot-local rather than a distributed uniqueness guarantee: a
404
+ concurrent collaborator can add the same name after the snapshot was loaded.
405
+ Keep the returned proxy or `shape_id` for cross-client-safe follow-up edits;
406
+ use names as human-readable selectors after refreshing the deck.
407
+
393
408
  ```python
394
409
  card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
395
410
  Inches(1), Inches(1), Inches(4), Inches(2),
@@ -1049,6 +1064,14 @@ work in addition to SDK surface and are tracked separately.
1049
1064
 
1050
1065
  ### Shape-level additions
1051
1066
 
1067
+ - **`Shape.z_order = "to_back" | "to_front" | "backward" | "forward"`**
1068
+ is a write-only convenience alias for `send_to_back()`,
1069
+ `bring_to_front()`, `send_backward()`, and `bring_forward()`. Assignment
1070
+ emits the same `SetShapeZOrder` command as the method form; unsupported
1071
+ strings raise `ValueError` instead of creating a misleading local
1072
+ attribute. Reading `shape.z_order` raises `AttributeError` because deck
1073
+ snapshots do not expose a stable numeric z-order value.
1074
+
1052
1075
  - **`Shape.duplicate(target_slide=None, offset_x=None, offset_y=None)`**
1053
1076
  (closes python-pptx#533, #620, 10-comment thread requesting a public
1054
1077
  duplicate API). Alias for the existing `Shape.clone()` — both verbs
@@ -133,7 +133,7 @@ def flush_all() -> None:
133
133
  _active_buffers[:] = alive
134
134
 
135
135
 
136
- __version__ = "0.9.0"
136
+ __version__ = "0.10.0"
137
137
 
138
138
  __all__ = [
139
139
  # Main entry point
@@ -18,9 +18,11 @@ from contextlib import contextmanager
18
18
  from threading import local
19
19
  from typing import TYPE_CHECKING, Any, Generator, Optional
20
20
 
21
+ import requests
22
+
21
23
  from . import _ptc
22
24
  from .commands import AnyCommand, validate_command
23
- from .errors import BatchPartialError
25
+ from .errors import BatchPartialError, ValidationError
24
26
  from .typing import CommandsResponse
25
27
 
26
28
  if TYPE_CHECKING:
@@ -390,6 +392,37 @@ class CommandBuffer:
390
392
  return_snapshot=False,
391
393
  )
392
394
  applied += len(chunk)
395
+ except requests.exceptions.InvalidJSONError as exc:
396
+ # The request body could not be serialized, so nothing reached
397
+ # the wire. Restoring the unserializable command(s) would poison
398
+ # the buffer permanently — every later flush would re-fail on the
399
+ # same payload — so drop them from the restore and surface a
400
+ # ValidationError naming what was dropped. (validate_command
401
+ # rejects NaN/Infinity at add() time; this covers commands whose
402
+ # payload became unserializable after they were buffered.)
403
+ unsent = commands[applied:]
404
+ serializable: list[AnyCommand] = []
405
+ poisoned: list[AnyCommand] = []
406
+ for command in unsent:
407
+ try:
408
+ json.dumps(command.to_dict(), allow_nan=False)
409
+ except (TypeError, ValueError):
410
+ poisoned.append(command)
411
+ else:
412
+ serializable.append(command)
413
+ if not poisoned:
414
+ self._restore_unsent(unsent)
415
+ raise
416
+ self._restore_unsent(serializable)
417
+ _ptc_emit_end_batch(commands[:applied], is_error=False)
418
+ _ptc_emit_end_batch(unsent, is_error=True)
419
+ labels = ", ".join(c.context_label() for c in poisoned)
420
+ raise ValidationError(
421
+ f"Dropped {len(poisoned)} buffered command(s) that cannot be "
422
+ f"serialized to JSON ({labels}). The remaining buffered "
423
+ f"commands were kept and will be sent on the next flush. "
424
+ f"Original error: {exc}"
425
+ ) from exc
393
426
  except BatchPartialError as exc:
394
427
  # The failing chunk committed its commands before
395
428
  # ``failure_index`` server-side; everything in earlier chunks
@@ -40,7 +40,23 @@ from .typing import (
40
40
  from .commands import AnyCommand, validate_command
41
41
 
42
42
 
43
- SDK_VERSION = "0.1.0"
43
+ def _resolve_sdk_version() -> str:
44
+ """Installed package version, matching ``pptx.__version__``.
45
+
46
+ Read from importlib.metadata rather than importing ``pptx`` — client.py
47
+ is imported during ``pptx/__init__`` execution, so importing back would
48
+ be circular. Falls back to "unknown" for source checkouts that were
49
+ never pip-installed.
50
+ """
51
+ try:
52
+ from importlib.metadata import version
53
+
54
+ return version("athena-python-pptx")
55
+ except Exception:
56
+ return "unknown"
57
+
58
+
59
+ SDK_VERSION = _resolve_sdk_version()
44
60
  SDK_CLIENT_TYPE = "python-sdk"
45
61
 
46
62
  DEFAULT_TIMEOUT = 30 # seconds
@@ -13,6 +13,8 @@ __athena_extension_description__: str = (
13
13
  )
14
14
  __athena_extension_since__: str = "0.1.0"
15
15
 
16
+ import math
17
+
16
18
  from dataclasses import dataclass, field, asdict
17
19
  from typing import Any, ClassVar, Optional, Union
18
20
  from ._citations import normalize_citation_string
@@ -69,6 +71,23 @@ class Command:
69
71
  raise NotImplementedError
70
72
 
71
73
 
74
+ def _find_non_finite(value: Any, path: str) -> Optional[str]:
75
+ """Return the JSON path of the first NaN/Infinity in ``value``, if any."""
76
+ if isinstance(value, float) and not math.isfinite(value):
77
+ return path
78
+ if isinstance(value, dict):
79
+ for key, item in value.items():
80
+ found = _find_non_finite(item, f"{path}.{key}" if path else str(key))
81
+ if found is not None:
82
+ return found
83
+ elif isinstance(value, (list, tuple)):
84
+ for i, item in enumerate(value):
85
+ found = _find_non_finite(item, f"{path}[{i}]")
86
+ if found is not None:
87
+ return found
88
+ return None
89
+
90
+
72
91
  def validate_command(command: "Command") -> None:
73
92
  """Validate ``command``, prefixing any ValidationError with its identity.
74
93
 
@@ -77,6 +96,11 @@ def validate_command(command: "Command") -> None:
77
96
  call site (batched flush, multi-command POST) that alone can't be traced
78
97
  back to a shape or slide. Every SDK-internal validation path goes through
79
98
  this wrapper so the error always carries the command context.
99
+
100
+ Also rejects NaN/Infinity anywhere in the wire payload: JSON has no
101
+ encoding for them, so they would otherwise pass ``validate()`` and then
102
+ blow up requests' JSON encoder at flush time — long after the offending
103
+ user line — leaving the unserializable command stuck in the buffer.
80
104
  """
81
105
  try:
82
106
  command.validate()
@@ -85,6 +109,15 @@ def validate_command(command: "Command") -> None:
85
109
  f"{command.context_label()}: {exc.message}", exc.field
86
110
  ) from None
87
111
 
112
+ non_finite_path = _find_non_finite(command.to_dict(), "")
113
+ if non_finite_path is not None:
114
+ raise ValidationError(
115
+ f"{command.context_label()}: value at '{non_finite_path}' is NaN "
116
+ f"or Infinity, which cannot be sent as JSON. Replace it with a "
117
+ f"finite number, or None to omit the data point.",
118
+ non_finite_path,
119
+ )
120
+
88
121
 
89
122
  @dataclass
90
123
  class AddTextBox(Command):
@@ -269,6 +302,8 @@ class AddSlide(Command):
269
302
  Add a new slide to the presentation.
270
303
 
271
304
  Args:
305
+ client_id: Correlation ID for commands that reference the new slide in
306
+ the same batch.
272
307
  index: Position to insert the slide (optional, defaults to end)
273
308
  layout_index: Layout template index (optional, fallback when no
274
309
  name/path given)
@@ -280,6 +315,7 @@ class AddSlide(Command):
280
315
  ``slideLayout{i+1}.xml`` filename numbering).
281
316
  """
282
317
 
318
+ client_id: Optional[str] = None
283
319
  index: Optional[int] = None
284
320
  layout_index: Optional[int] = None
285
321
  layout_name: Optional[str] = None
@@ -334,6 +370,22 @@ class DeleteShape(Command):
334
370
  raise ValidationError("shape_id is required", "shape_id")
335
371
 
336
372
 
373
+ @dataclass
374
+ class ClearSlideShapes(Command):
375
+ """Delete modeled slide-local shapes, preserving source-only OOXML by default."""
376
+
377
+ slide_id: SlideId
378
+ delete_unmodeled_source_content: bool = False
379
+
380
+ @property
381
+ def command_type(self) -> str:
382
+ return "ClearSlideShapes"
383
+
384
+ def validate(self) -> None:
385
+ if not self.slide_id:
386
+ raise ValidationError("slide_id is required", "slide_id")
387
+
388
+
337
389
  @dataclass
338
390
  class SetParagraphStyle(Command):
339
391
  """
@@ -459,14 +511,34 @@ class SetShapeStyle(Command):
459
511
  line_scheme_color: Optional[str] = None
460
512
  line_width_emu: Optional[int] = None
461
513
  line_dash: Optional[str] = None
514
+ clear_fill: bool = False
462
515
 
463
516
  @property
464
517
  def command_type(self) -> str:
465
518
  return "SetShapeStyle"
466
519
 
520
+ def to_dict(self) -> dict[str, Any]:
521
+ # The wire format distinguishes "clear the fill" (explicit null) from
522
+ # "leave unchanged" (field absent), but base to_dict drops None fields.
523
+ # ``clear_fill`` is a local flag translated into explicit nulls here;
524
+ # it never rides the wire itself.
525
+ result = super().to_dict()
526
+ result.pop("clearFill", None)
527
+ if self.clear_fill:
528
+ result["fillColorHex"] = None
529
+ result["fillSchemeColor"] = None
530
+ return result
531
+
467
532
  def validate(self) -> None:
468
533
  if not self.shape_id:
469
534
  raise ValidationError("shape_id is required", "shape_id")
535
+ if self.clear_fill and (
536
+ self.fill_color_hex is not None or self.fill_scheme_color is not None
537
+ ):
538
+ raise ValidationError(
539
+ "clear_fill cannot be combined with a fill color",
540
+ "clear_fill",
541
+ )
470
542
  if self.fill_transparency is not None and not (0.0 <= self.fill_transparency <= 1.0):
471
543
  raise ValidationError("fill_transparency must be between 0.0 and 1.0", "fill_transparency")
472
544
 
@@ -2702,6 +2774,30 @@ class DeleteTableColumn(Command):
2702
2774
  raise ValidationError("col_index must be >= 0", "col_index")
2703
2775
 
2704
2776
 
2777
+ @dataclass
2778
+ class DeleteTableRow(Command):
2779
+ """Delete the row at ``row_index`` from an existing table.
2780
+
2781
+ Row axis of :class:`DeleteTableColumn`. Unlike ``SetTableRowCount``
2782
+ (which can only truncate rows from the end), this removes a specific
2783
+ row and shifts later rows up, matching ``Table.remove_row(index)``
2784
+ semantics.
2785
+ """
2786
+
2787
+ shape_id: ShapeId
2788
+ row_index: int
2789
+
2790
+ @property
2791
+ def command_type(self) -> str:
2792
+ return "DeleteTableRow"
2793
+
2794
+ def validate(self) -> None:
2795
+ if not self.shape_id:
2796
+ raise ValidationError("shape_id is required", "shape_id")
2797
+ if self.row_index < 0:
2798
+ raise ValidationError("row_index must be >= 0", "row_index")
2799
+
2800
+
2705
2801
  @dataclass
2706
2802
  class SetPointDataLabelStyle(Command):
2707
2803
  """Per-point data-label override on a series.
@@ -3343,6 +3439,7 @@ AnyCommand = Union[
3343
3439
  AddSlide,
3344
3440
  DeleteSlide,
3345
3441
  DeleteShape,
3442
+ ClearSlideShapes,
3346
3443
  SetParagraphStyle,
3347
3444
  AddShape,
3348
3445
  SetShapeStyle,
@@ -3397,6 +3494,7 @@ AnyCommand = Union[
3397
3494
  InsertTableRow,
3398
3495
  InsertTableColumn,
3399
3496
  DeleteTableColumn,
3497
+ DeleteTableRow,
3400
3498
  SetPointDataLabelStyle,
3401
3499
  SetChartTitlePosition,
3402
3500
  AddChart2016,
@@ -0,0 +1,43 @@
1
+ """Compatibility shim for python-pptx's ``pptx.oxml`` package.
2
+
3
+ Upstream python-pptx exposes its lxml-backed OOXML layer here. The REST SDK
4
+ has no local XML tree — every mutation is a server command — so the element
5
+ classes and parsers cannot exist. This shim keeps ``import pptx.oxml`` (and
6
+ the very common ``from pptx.oxml.ns import qn``) from crashing an entire
7
+ script at import time: the pure-string helpers in :mod:`pptx.oxml.ns` work
8
+ exactly like upstream, and anything that would need a real XML tree raises
9
+ :class:`~pptx.errors.UnsupportedFeatureError` at the call site with guidance
10
+ instead of ``ModuleNotFoundError`` at the import.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __athena_extension_module__: bool = True
16
+ __athena_extension_description__: str = (
17
+ "pptx.oxml import-compatibility shim (REST-SDK only — no local XML tree)."
18
+ )
19
+ __athena_extension_since__: str = "0.9.2"
20
+
21
+ from typing import Any, NoReturn
22
+
23
+ from ..errors import UnsupportedFeatureError
24
+
25
+
26
+ def parse_xml(xml: Any) -> NoReturn:
27
+ """Upstream parses an XML fragment into an lxml element tree."""
28
+ raise UnsupportedFeatureError(
29
+ "pptx.oxml.parse_xml",
30
+ "This SDK is a REST client — there is no local XML tree to parse "
31
+ "into. Use the documented python-pptx object API (shapes, "
32
+ "text_frame, table, chart) instead of raw OOXML manipulation.",
33
+ )
34
+
35
+
36
+ def register_element_cls(tag: str, cls: Any) -> NoReturn:
37
+ """Upstream registers a custom lxml element class for a tag."""
38
+ raise UnsupportedFeatureError(
39
+ "pptx.oxml.register_element_cls",
40
+ "This SDK is a REST client — custom OOXML element classes cannot "
41
+ "be registered because documents are edited through server "
42
+ "commands, not a local XML tree.",
43
+ )
@@ -0,0 +1,91 @@
1
+ """OOXML namespace helpers — faithful port of ``pptx.oxml.ns``.
2
+
3
+ These are pure string utilities with no XML-tree dependency, so they behave
4
+ identically to upstream python-pptx. Namespace map copied verbatim from
5
+ python-pptx 1.0.2.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _nsmap: dict[str, str] = {
11
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
12
+ "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
13
+ "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
14
+ "ct": "http://schemas.openxmlformats.org/package/2006/content-types",
15
+ "dc": "http://purl.org/dc/elements/1.1/",
16
+ "dcmitype": "http://purl.org/dc/dcmitype/",
17
+ "dcterms": "http://purl.org/dc/terms/",
18
+ "ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
19
+ "i": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
20
+ "m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
21
+ "mo": "http://schemas.microsoft.com/office/mac/office/2008/main",
22
+ "mv": "urn:schemas-microsoft-com:mac:vml",
23
+ "o": "urn:schemas-microsoft-com:office:office",
24
+ "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
25
+ "pd": "http://schemas.openxmlformats.org/drawingml/2006/presentationDrawing",
26
+ "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
27
+ "pr": "http://schemas.openxmlformats.org/package/2006/relationships",
28
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
29
+ "sl": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
30
+ "v": "urn:schemas-microsoft-com:vml",
31
+ "ve": "http://schemas.openxmlformats.org/markup-compatibility/2006",
32
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
33
+ "w10": "urn:schemas-microsoft-com:office:word",
34
+ "wne": "http://schemas.microsoft.com/office/word/2006/wordml",
35
+ "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
36
+ "xsi": "http://www.w3.org/2001/XMLSchema-instance",
37
+ }
38
+
39
+ nsmap = _nsmap
40
+
41
+
42
+ class NamespacePrefixedTag(str):
43
+ """Value object holding a namespace-prefixed tag like ``'a:t'``."""
44
+
45
+ def __new__(cls, nstag: str, *args: object) -> "NamespacePrefixedTag":
46
+ return super().__new__(cls, nstag)
47
+
48
+ def __init__(self, nstag: str) -> None:
49
+ self._pfx, self._local_part = nstag.split(":")
50
+ self._ns_uri = _nsmap[self._pfx]
51
+
52
+ @property
53
+ def clark_name(self) -> str:
54
+ return f"{{{self._ns_uri}}}{self._local_part}"
55
+
56
+ @property
57
+ def local_part(self) -> str:
58
+ return self._local_part
59
+
60
+ @property
61
+ def nsmap(self) -> dict[str, str]:
62
+ return {self._pfx: self._ns_uri}
63
+
64
+ @property
65
+ def nspfx(self) -> str:
66
+ return self._pfx
67
+
68
+ @property
69
+ def nsuri(self) -> str:
70
+ return self._ns_uri
71
+
72
+
73
+ def namespaces(*prefixes: str) -> dict[str, str]:
74
+ """Namespace map containing only the specified prefixes."""
75
+ return {pfx: _nsmap[pfx] for pfx in prefixes}
76
+
77
+
78
+ def nsdecls(*prefixes: str) -> str:
79
+ """Namespace declarations for the specified prefixes, xmlns-attr form."""
80
+ return " ".join(f'xmlns:{pfx}="{_nsmap[pfx]}"' for pfx in prefixes)
81
+
82
+
83
+ def nsuri(nspfx: str) -> str:
84
+ """Namespace URI corresponding to ``nspfx``."""
85
+ return _nsmap[nspfx]
86
+
87
+
88
+ def qn(namespace_prefixed_tag: str) -> str:
89
+ """Clark-notation name for a namespace-prefixed tag, e.g. ``qn('a:t')``."""
90
+ prefix, local = namespace_prefixed_tag.split(":")
91
+ return f"{{{_nsmap[prefix]}}}{local}"
@@ -405,35 +405,48 @@ class Presentation:
405
405
  @athena_extension(
406
406
  issue=1018,
407
407
  since="0.1.81",
408
- description="Presentation.open — named factory for re-opening an existing deck for iterative edits.",
408
+ description=(
409
+ "Presentation.open — open an existing deck by asset id, or "
410
+ "upload a local .pptx by path."
411
+ ),
409
412
  )
410
413
  def open(
411
414
  cls,
412
- deck_id: str,
415
+ source: Optional[Union[str, Path]] = None,
413
416
  base_url: Optional[str] = None,
414
417
  api_key: Optional[str] = None,
418
+ name: Optional[str] = None,
419
+ *,
420
+ deck_id: Optional[str] = None,
421
+ path: Optional[Union[str, Path]] = None,
415
422
  ) -> Presentation:
416
- """Open an existing presentation by ``deck_id`` for iterative edits.
423
+ """Open an existing deck by asset id, or upload a local file by path.
424
+
425
+ Closes python-pptx#1018 — "open a PPT in append mode". Two forms,
426
+ dispatched on the shape of ``source``:
427
+
428
+ * ``Presentation.open("asset_<uuid>")`` — reattach to the
429
+ server-side asset for iterative edits. Same as
430
+ ``Presentation(asset_id=...)`` but named to mirror the
431
+ iterate-and-edit verb, and to read naturally next to
432
+ ``Presentation.create`` / ``Presentation.upload``.
433
+ * ``Presentation.open("deck.pptx")`` — upload a local ``.pptx``
434
+ file (alias for :meth:`upload`, matching python-pptx's
435
+ ``Presentation(path)`` pattern).
417
436
 
418
- Closes python-pptx#1018 — "open a PPT in append mode". Upstream
419
- python-pptx has ``Presentation(filename)`` which loads + parses
420
- an OPC package; the REST SDK has no local OPC, so the equivalent
421
- is reattaching to the server-side asset. This is the same call
422
- as ``Presentation(deck_id=...)`` but named to mirror the iterate-
423
- and-edit verb the upstream issue is filed under, and to read
424
- naturally next to ``Presentation.create`` / ``Presentation.upload``.
437
+ ``deck_id=`` and ``path=`` are accepted as explicit keyword
438
+ spellings of the two forms for callers that want to bypass the
439
+ shape-based dispatch.
425
440
 
426
441
  Args:
427
- deck_id: The Athena asset id of the existing deck.
442
+ source: Athena asset id (``asset_<uuid>`` / legacy
443
+ ``deck_<id>``) or a local file path.
428
444
  base_url: Optional API base URL. Defaults to
429
445
  ``ATHENA_PPTX_BASE_URL`` env var.
430
446
  api_key: Optional API key. Defaults to ``ATHENA_PPTX_API_KEY``.
431
-
432
- Returns:
433
- Presentation reattached to the server-side deck. New slide
434
- adds / shape edits stream straight into the live Y.Doc so
435
- collaborators see them in real time — the iterative-edit
436
- flow the upstream issue describes.
447
+ name: Optional presentation name — upload form only.
448
+ deck_id: Keyword-only explicit id form.
449
+ path: Keyword-only explicit upload form.
437
450
 
438
451
  Example:
439
452
  prs = Presentation.open("asset_3a93...")
@@ -441,7 +454,26 @@ class Presentation:
441
454
  ... # inspect / edit
442
455
  prs.save() # flush pending commands
443
456
  """
444
- return cls(deck_id=deck_id, base_url=base_url, api_key=api_key)
457
+ provided = [v for v in (source, deck_id, path) if v is not None]
458
+ if len(provided) != 1:
459
+ raise TypeError(
460
+ "Presentation.open() takes exactly one source: a positional "
461
+ "asset id or file path, or one of the deck_id=/path= keywords."
462
+ )
463
+ if deck_id is None and path is None:
464
+ if isinstance(source, str) and _DECK_ID_PATTERN.match(source):
465
+ deck_id = source
466
+ else:
467
+ path = source
468
+ if deck_id is not None:
469
+ if name is not None:
470
+ raise TypeError(
471
+ "Presentation.open(name=...) is only valid when uploading "
472
+ "a local file; an existing deck already has a name."
473
+ )
474
+ return cls(deck_id=deck_id, base_url=base_url, api_key=api_key)
475
+ assert path is not None
476
+ return cls.upload(path, base_url=base_url, api_key=api_key, name=name)
445
477
 
446
478
  @classmethod
447
479
  @athena_extension(
@@ -694,45 +726,6 @@ class Presentation:
694
726
 
695
727
  return cls(deck_id=deck_id, base_url=base_url, api_key=api_key, auto_refresh=wait)
696
728
 
697
- @classmethod
698
- @athena_extension(
699
- since="0.1.0",
700
- description="Presentation.open — alias for upload().",
701
- )
702
- def open(
703
- cls,
704
- path: Union[str, Path],
705
- base_url: Optional[str] = None,
706
- api_key: Optional[str] = None,
707
- name: Optional[str] = None,
708
- ) -> Presentation:
709
- """
710
- Open a local PPTX file by uploading it to the server.
711
-
712
- This is an alias for upload() to match python-pptx's Presentation(path) pattern.
713
-
714
- Args:
715
- path: Path to the local PPTX file
716
- base_url: Base URL of the API. If not provided, uses ATHENA_PPTX_BASE_URL
717
- environment variable.
718
- api_key: Optional API key. If not provided, uses ATHENA_PPTX_API_KEY
719
- environment variable.
720
- name: Optional name for the presentation
721
-
722
- Returns:
723
- Presentation instance for the uploaded deck
724
-
725
- Example:
726
- from pptx import Presentation
727
-
728
- # Using environment variables
729
- prs = Presentation.open("my_presentation.pptx")
730
-
731
- # Or with explicit parameters
732
- prs = Presentation.open("my_presentation.pptx", base_url="http://localhost:4000")
733
- """
734
- return cls.upload(path, base_url=base_url, api_key=api_key, name=name)
735
-
736
729
  @property
737
730
  @athena_extension(
738
731
  since="0.1.0",