athena-python-pptx 0.17.0__tar.gz → 0.18.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 (109) hide show
  1. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/API_PARITY_REPORT.md +0 -1
  2. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/CHANGELOG.md +46 -0
  3. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/PKG-INFO +1 -1
  4. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/docs/API_PARITY_EXCEPTIONS.md +60 -17
  5. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/__init__.py +1 -1
  6. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/_unsupported.py +18 -7
  7. athena_python_pptx-0.18.0/pptx/chart/chart.py +7 -0
  8. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/client.py +48 -0
  9. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/commands.py +3 -2
  10. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/_tbl.py +22 -14
  11. athena_python_pptx-0.18.0/pptx/oxml/shapes/autoshape.py +75 -0
  12. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/shared.py +23 -6
  13. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/__init__.py +174 -49
  14. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pyproject.toml +1 -1
  15. athena_python_pptx-0.17.0/pptx/chart/chart.py +0 -7
  16. athena_python_pptx-0.17.0/pptx/oxml/shapes/autoshape.py +0 -32
  17. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/.gitignore +0 -0
  18. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/AGENTS.md +0 -0
  19. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/CLAUDE.md +0 -0
  20. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/DEV-GUIDE.md +0 -0
  21. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/PARITY_QUESTIONS.md +0 -0
  22. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/README.md +0 -0
  23. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/docs/athena-api.json +0 -0
  24. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/docs/athena-api.md +0 -0
  25. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/_athena_extension.py +0 -0
  26. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/_citations.py +0 -0
  27. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/_ptc.py +0 -0
  28. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/_references.py +0 -0
  29. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/action.py +0 -0
  30. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/batching.py +0 -0
  31. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/__init__.py +0 -0
  32. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/axis.py +0 -0
  33. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/category.py +0 -0
  34. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/data.py +0 -0
  35. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/datalabel.py +0 -0
  36. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/legend.py +0 -0
  37. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/marker.py +0 -0
  38. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/plot.py +0 -0
  39. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/point.py +0 -0
  40. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/series.py +0 -0
  41. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/chart/xlsx.py +0 -0
  42. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/decorators.py +0 -0
  43. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/__init__.py +0 -0
  44. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/chtfmt.py +0 -0
  45. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/color.py +0 -0
  46. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/effect.py +0 -0
  47. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/fill.py +0 -0
  48. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/dml/line.py +0 -0
  49. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/docgen.py +0 -0
  50. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/__init__.py +0 -0
  51. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/action.py +0 -0
  52. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/chart.py +0 -0
  53. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/dml.py +0 -0
  54. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/lang.py +0 -0
  55. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/shapes.py +0 -0
  56. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/enum/text.py +0 -0
  57. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/errors.py +0 -0
  58. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/exc.py +0 -0
  59. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/generated/__init__.py +0 -0
  60. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/generated/autoshape_types.py +0 -0
  61. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/kit.py +0 -0
  62. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/media.py +0 -0
  63. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/__init__.py +0 -0
  64. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/_mirror.py +0 -0
  65. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/_txbody.py +0 -0
  66. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/ns.py +0 -0
  67. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/presentation.py +0 -0
  68. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/__init__.py +0 -0
  69. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/connector.py +0 -0
  70. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/graphfrm.py +0 -0
  71. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/groupshape.py +0 -0
  72. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/shapes/picture.py +0 -0
  73. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/simpletypes.py +0 -0
  74. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/table.py +0 -0
  75. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/text.py +0 -0
  76. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/oxml/xmlchemy.py +0 -0
  77. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/package.py +0 -0
  78. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/__init__.py +0 -0
  79. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/_base.py +0 -0
  80. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/chart.py +0 -0
  81. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/coreprops.py +0 -0
  82. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/embeddedpackage.py +0 -0
  83. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/image.py +0 -0
  84. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/media.py +0 -0
  85. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/presentation.py +0 -0
  86. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/parts/slide.py +0 -0
  87. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/presentation.py +0 -0
  88. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/autoshape.py +0 -0
  89. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/base.py +0 -0
  90. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/connector.py +0 -0
  91. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/freeform.py +0 -0
  92. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/graphfrm.py +0 -0
  93. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/group.py +0 -0
  94. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/picture.py +0 -0
  95. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/placeholder.py +0 -0
  96. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shapes/shapetree.py +0 -0
  97. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/shared.py +0 -0
  98. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/slide.py +0 -0
  99. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/slides.py +0 -0
  100. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/spec.py +0 -0
  101. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/table.py +0 -0
  102. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/text/__init__.py +0 -0
  103. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/text/fonts.py +0 -0
  104. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/text/layout.py +0 -0
  105. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/text/text.py +0 -0
  106. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/types.py +0 -0
  107. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/typing.py +0 -0
  108. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/units.py +0 -0
  109. {athena_python_pptx-0.17.0 → athena_python_pptx-0.18.0}/pptx/util.py +0 -0
@@ -110,7 +110,6 @@ via Tranches R–W:
110
110
  | `*.element`, `*.part`, `*.ln`, `*.get_or_add_ln` | XML element / OPC package / direct DML XML access — REST SDK has no local XML or packages |
111
111
  | `FillFormat.from_fill_parent()` | Internal XML constructor |
112
112
  | `ColorFormat.from_colorchoice_parent()` | Internal XML constructor |
113
- | `Picture.image.blob` | Image bytes live in the studio's asset store — not materialized into Python |
114
113
 
115
114
  ## Closed Parity Gaps (added 2026-05-18)
116
115
 
@@ -2,6 +2,52 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.18.0 — chart.chart_area / chart.plot_area format and add_chart(name=)
6
+
7
+ - `chart.chart_area.format` and `chart.plot_area.format` now round-trip fill
8
+ and line edits through chart specs, OOXML export, import and preview
9
+ rendering. Use `fill.background()` for a transparent chart or plot area.
10
+ `slide.shapes.add_chart(..., name=...)` also accepts an Athena-only shape
11
+ name.
12
+ - `Picture.image` returns python-pptx's `Image`, so `picture.image.blob`,
13
+ `.content_type`, `.ext`, `.sha1`, `.size` and `.filename` work. It returned
14
+ None for every picture, and code reading an existing deck's images had to
15
+ fetch `image_url` by hand with the sandbox key. A picture added in this
16
+ session gives back the bytes it was added from; a picture read from the deck
17
+ fetches its bytes once from the studio's `/media` route with the SDK's own
18
+ credentials (redirects refused, 50 MB cap) and caches them on the shape. A
19
+ picture with no media raises `ValueError("no embedded image")`, as
20
+ python-pptx does.
21
+
22
+ ## 0.17.1
23
+
24
+ - `Table.table_style` returns the table's style GUID, the text of
25
+ `table._tbl`'s `a:tableStyleId`, which python-pptx code reads: an uploaded
26
+ table's from the deck, or the one `add_table()` / `add_linked_table()` gave a
27
+ table the SDK added; None for a table without one. It returned None for every
28
+ table (it read a snapshot field the studio never sends). It stays read-only:
29
+ the studio has no command that changes an existing table's style.
30
+ - `shape._element.txBody` (and `shape._sp.txBody`) of an autoshape, text box
31
+ or placeholder is its text frame's `_txBody`, the same lxml element
32
+ (`sp.txBody is shape.text_frame._txBody`, as in python-pptx), and
33
+ `sp.get_or_add_txBody()` returns it; it raised `UnsupportedAttributeError`.
34
+ Edits through it are held and sent as the text's commands, as edits through
35
+ `text_frame._txBody` are, so python-pptx's "for every shape, rewrite each
36
+ `a:t` of `shape._element.txBody`" recipe works. As in python-pptx it is None
37
+ for a picture's, graphic frame's, connector's or group's element, and for a
38
+ shape without a text frame.
39
+ - A table cell's cleared edge (`cell.border_left.no_border()`, or
40
+ `SetCellBorders` with `None`) reads in `cell._tc` as an `a:lnX` holding
41
+ `a:noFill` on every table, as the studio now stores and exports it (an
42
+ uploaded table's read as no `a:lnX`, which in PowerPoint is the table
43
+ style's line). A width or dash written after `no_border()` keeps the edge
44
+ without a line, as it does an `a:noFill` an XML recipe wrote, and an RGB
45
+ color draws it again (it sent a width-only edge). Removing a cell's `a:lnX`
46
+ through `cell._tc` raises
47
+ `UnsupportedAttributeError`: in python-pptx that hands the edge back to the
48
+ table style, which no studio command does (it cleared the edge before). For
49
+ no line, keep the `a:lnX` and give it an `a:noFill`, or call `no_border()`.
50
+
5
51
  ## 0.17.0
6
52
 
7
53
  - python-pptx's slide delete and move recipes work. They edit
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: athena-python-pptx
3
- Version: 0.17.0
3
+ Version: 0.18.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
@@ -463,7 +463,10 @@ other_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
463
463
  differing from python-pptx's copy.
464
464
  - **Supported operations:** an element's `tag`, `getparent`, `getnext`,
465
465
  `getprevious`, `addnext`, `addprevious`, `copy.deepcopy`, `shape_id`,
466
- `shape_name`, `has_ph_elm`, and its read-only `p:cNvPr` (`sp.nvSpPr.cNvPr`,
466
+ `shape_name`, `has_ph_elm`, `txBody` (a `p:sp`'s is its text frame's
467
+ `_txBody`, the same element, as in python-pptx, with `get_or_add_txBody()`;
468
+ the other elements' is None, as python-pptx answers; see the text XML
469
+ section below), and its read-only `p:cNvPr` (`sp.nvSpPr.cNvPr`,
467
470
  any shape's `sp._nvXxPr.cNvPr`): `get`, `attrib`, `keys`, `values`, `items`,
468
471
  `id`, `name`, for the id, name and hidden flag. A tree's `len`, iteration,
469
472
  indexing, `index`, `remove`, `del`, `insert`, `append`, `extend`,
@@ -474,7 +477,7 @@ other_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
474
477
  not python-pptx's integer. A copy gets a new id, so a recipe's id
475
478
  renumbering (`cNvPr.id = ...`) is not needed and raises.
476
479
  - **Refused operations** raise `UnsupportedAttributeError` naming the
477
- supported API: property-level XML (`spPr`, `txBody`, `xfrm`), XML built
480
+ supported API: property-level XML (`spPr`, `xfrm`), XML built
478
481
  elsewhere (`parse_xml`, a new element), writes to `p:cNvPr`, `descr` and
479
482
  `title` reads (the studio does not report them), moving a shape to another
480
483
  slide or into another group, a copy into a group, and a notes slide's,
@@ -506,7 +509,8 @@ run._r.get_or_add_rPr().set("spc", "300") # letter spacing (3pt)
506
509
  etree.SubElement(text_frame._bodyPr, qn("a:normAutofit"))
507
510
  ```
508
511
 
509
- `text_frame._txBody` (also `_element`), `text_frame._bodyPr`, `paragraph._p`
512
+ `text_frame._txBody` (also `_element`, and a `p:sp`'s `shape._element.txBody`),
513
+ `text_frame._bodyPr`, `paragraph._p`
510
514
  (`_element`, `_pPr`, `_defRPr`), `run._r` and `font._rPr` (`_element`) are real
511
515
  lxml elements of python-pptx's classes (`pptx.oxml.text.CT_TextBody`,
512
516
  `CT_TextParagraph`, ...), one tree per text frame, built from the deck, with
@@ -635,7 +639,8 @@ helpers (`get_or_add_tcPr()`, `tc.marL`, `tc.anchor`, `tbl.firstRow`,
635
639
  - **What is sent** (through the setters the public API uses):
636
640
  - a cell's `a:lnL` / `a:lnR` / `a:lnT` / `a:lnB` (`w`, `a:solidFill` with
637
641
  an `a:srgbClr`, `a:noFill`, `a:prstDash`) as one `SetCellBorders` per
638
- cell; removing an edge clears it (`cell.border_left.no_border()`), and
642
+ cell; an edge with no line is an `a:lnX` holding `a:noFill`
643
+ (`cell.border_left.no_border()`), and
639
644
  `cell.border_*` reads what the XML set (a later `width` or `dash_style`
640
645
  write keeps an edge's `a:noFill` or theme color). On an uploaded table a
641
646
  border's color may also be an `a:schemeClr`.
@@ -661,7 +666,10 @@ helpers (`get_or_add_tcPr()`, `tc.marL`, `tc.anchor`, `tbl.firstRow`,
661
666
  dashes, arrowheads, a color with modifiers, a theme color on a table the
662
667
  SDK added), diagonal borders, `vert`, `anchorCtr` and the other `a:tcPr`
663
668
  attributes and children, gradient, picture, pattern and theme-color cell
664
- fills, and removing a cell's fill (handing it back to the table style);
669
+ fills, removing a cell's fill (handing it back to the table style), and
670
+ removing a cell's `a:lnL` / `a:lnR` / `a:lnT` / `a:lnB` (which hands that
671
+ edge back to the table style: the studio stores an edge as a line or as no
672
+ line, so for no line keep the `a:lnX` and give it an `a:noFill`);
665
673
  - merges (`gridSpan` / `rowSpan` / `hMerge` / `vMerge`: use `cell.merge()` /
666
674
  `cell.split()`), a cell's text (use `cell.text` / `cell.text_frame`), rows
667
675
  or columns added or moved through XML (use `table.rows.add()` /
@@ -1277,6 +1285,24 @@ a later `runs[0].text` edit then wrote the empty run and doubled the text.
1277
1285
 
1278
1286
  These properties/methods were added because LLM agents (Claude, GPT, etc.) frequently generate code using these patterns. Without them, agent-generated table code fails at runtime.
1279
1287
 
1288
+ ### `Chart.chart_area` and `Chart.plot_area` formatting
1289
+
1290
+ python-pptx does not expose chart-area or plot-area fill and line formatting.
1291
+ The Athena SDK carries these edits through the chart spec and export patches,
1292
+ so a transparent chart background does not require editing private XML:
1293
+
1294
+ ```python
1295
+ chart.chart_area.format.fill.background()
1296
+ chart.plot_area.format.fill.background()
1297
+ ```
1298
+
1299
+ Both areas also support solid RGB or theme fills, brightness, line color,
1300
+ width and dash style. Use `chart.chart_area` for the whole chart frame and
1301
+ `chart.plot_area` for the plotting region.
1302
+ These edits apply to classic charts (authored or uploaded); Office 2016
1303
+ chartEx charts, such as waterfall and treemap, refuse them
1304
+ (`chartExEditsRefusal`).
1305
+
1280
1306
  ### `Font.cap` — PowerPoint-native capitalization mode
1281
1307
 
1282
1308
  The SDK exposes PowerPoint's OOXML run capitalization property as
@@ -1547,10 +1573,13 @@ slide.shapes.add_picture(img.url, Inches(1), Inches(1), width=Inches(6))
1547
1573
 
1548
1574
  ### `Picture.image_url` — owner-scoped media URL for cross-deck image copy
1549
1575
 
1550
- python-pptx upstream exposes `Picture.image` returning an `Image` with
1551
- `.blob` bytes. The REST SDK can't materialize bytes locally, so `.image`
1552
- stays `None` (see the omissions list above). **Additionally**, when a
1553
- picture is loaded from a server snapshot it now carries `image_url`: a
1576
+ `Picture.image` returns python-pptx's `Image` (`.blob`, `.content_type`,
1577
+ `.ext`, `.filename`, `.sha1`, `.size`, `.dpi`): a picture added in this
1578
+ session carries the bytes it was added from, and a picture read from the deck
1579
+ fetches its bytes once from the studio's `/media` route with the SDK's own
1580
+ credentials (it raises `ValueError("no embedded image")`, as python-pptx
1581
+ does, for a picture with no media). **Additionally**, when a
1582
+ picture is loaded from a server snapshot it carries `image_url`: a
1554
1583
  stable `{base_url}/media?key=<assetId>` URL that streams the picture's
1555
1584
  bytes. It returns `None` for pictures with no backing media asset (an
1556
1585
  SDK-added picture not yet flushed/refreshed, a placeholder with no image,
@@ -1576,12 +1605,13 @@ dst.save()
1576
1605
  **Portable code that needs to run against stock python-pptx cannot rely
1577
1606
  on `image_url`** — it is an Athena-only accessor.
1578
1607
 
1579
- ### `name=` kwarg on `add_textbox()` / `add_shape()` / `add_picture()`
1608
+ ### `name=` kwarg on `add_textbox()` / `add_shape()` / `add_picture()` / `add_chart()`
1580
1609
 
1581
1610
  Optional keyword for setting a stable shape name at creation. python-pptx
1582
1611
  upstream auto-assigns names like `"Rectangle 47"` and only exposes
1583
1612
  `shape.name = "..."` as a setter; ours additionally accepts `name=` at
1584
- construction. **Portable code that needs to run against stock python-pptx
1613
+ construction on `add_textbox()`, `add_shape()`, `add_picture()` and
1614
+ `add_chart()`. **Portable code that needs to run against stock python-pptx
1585
1615
  should omit `name=` and assign after the fact.**
1586
1616
 
1587
1617
  When `name=` is omitted, Athena's generated name skips any identical name in
@@ -1599,6 +1629,13 @@ card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
1599
1629
  name="kpi_card_revenue")
1600
1630
  ```
1601
1631
 
1632
+ Charts accept the same keyword:
1633
+
1634
+ ```python
1635
+ frame = slide.shapes.add_chart(chart_type, left, top, width, height, data,
1636
+ name="quarterly_revenue")
1637
+ ```
1638
+
1602
1639
  ### `Presentation.reorder_slides(new_order)`
1603
1640
 
1604
1641
  Reorder slides by passing a permutation of indices. Already shipped in
@@ -2008,7 +2045,7 @@ cell.border_top.color.rgb = RGBColor(0x1F, 0x49, 0x7D)
2008
2045
  cell.border_top.width = Pt(1.5)
2009
2046
  cell.border_top.dash_style = "solid"
2010
2047
 
2011
- cell.border_bottom.no_border() # remove the bottom edge
2048
+ cell.border_bottom.no_border() # no line on the bottom edge
2012
2049
  ```
2013
2050
 
2014
2051
  Each edge stores `colorHex`, `widthEmu`, `dashStyle` and the
@@ -2024,11 +2061,17 @@ indirectly.
2024
2061
 
2025
2062
  ## `Table.table_style` is read-only
2026
2063
 
2027
- python-pptx exposes `Table.table_style` as a writable property that
2028
- swaps the OOXML `<a:tableStyleId>` GUID. Our SDK exposes it as read-only
2029
- today. Pass `table_style` to `slide.shapes.add_table(table_style=...)`
2030
- at creation time to choose the style; modifying it on an existing table
2031
- will be unlocked when the server gains a `SetTableStyle` command.
2064
+ python-pptx has no `Table.table_style`: python-pptx code reads a table's
2065
+ style GUID from its XML, the text of `table._tbl.tblPr`'s
2066
+ `<a:tableStyleId>`. The SDK's read-only `Table.table_style` returns that
2067
+ same GUID: an uploaded table's style from the deck, or the one the studio
2068
+ gave a table the SDK added (`add_table()` uses python-pptx's default,
2069
+ `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`; `add_linked_table()` takes
2070
+ `table_style_id`). It is None for a table without a style, and
2071
+ `table._tbl` shows the same value. The studio has no command that changes
2072
+ an existing table's style, so assigning the property raises
2073
+ `AttributeError`, and an edit to `a:tableStyleId` through `table._tbl`
2074
+ raises `UnsupportedAttributeError`.
2032
2075
 
2033
2076
  ---
2034
2077
 
@@ -327,7 +327,7 @@ def validate_all_touched() -> dict[int, list[str]]:
327
327
  return results
328
328
 
329
329
 
330
- __version__ = "0.17.0"
330
+ __version__ = "0.18.0"
331
331
 
332
332
  __all__ = [
333
333
  # Main entry point
@@ -335,7 +335,9 @@ _TEXT_FRAME = (
335
335
  _SHAPE = "Use the Shape API: text_frame, fill, line, shadow, left / top / width / height, rotation, delete()."
336
336
  _CHART = (
337
337
  "Use the Chart API: chart.plots, chart.series, chart.replace_data(chart_data), "
338
- "chart.has_title / chart.chart_title, chart.value_axis, chart.category_axis."
338
+ "chart.has_title / chart.chart_title, chart.value_axis, chart.category_axis. "
339
+ "For the chart's background use chart.chart_area.format / chart.plot_area.format "
340
+ "(fill.background() makes it transparent)."
339
341
  )
340
342
  # python-pptx's private lxml attributes that recipes reach for (``series._element``,
341
343
  # ``chart._chartSpace``, ...) on objects this SDK has no mirror of. Whatever the
@@ -423,18 +425,27 @@ def keep_getter_error(obj: Any, name: str, error: UnsupportedAttributeError) ->
423
425
  pass
424
426
 
425
427
 
428
+ def take_getter_error(obj: Any, name: str) -> UnsupportedAttributeError | None:
429
+ """The error the getter of property ``name`` left on ``obj``
430
+ (:func:`keep_getter_error`), taken off it, for a guard's ``__getattr__`` to
431
+ raise; None when it left none. A class whose own ``__getattr__`` does this
432
+ flags it ``__athena_member_guard__``."""
433
+ try:
434
+ stashed = vars(obj).pop(_GETTER_ERROR, None)
435
+ except TypeError:
436
+ return None
437
+ return stashed[1] if stashed is not None and stashed[0] == name else None
438
+
439
+
426
440
  def _make_guard(
427
441
  cls: type, members: dict[str, str], guessed: dict[str, str]
428
442
  ) -> Callable[[Any, str], Any]:
429
443
  own = cls.__dict__.get("__getattr__")
430
444
 
431
445
  def __getattr__(self: Any, name: str) -> Any:
432
- try:
433
- stashed = vars(self).pop(_GETTER_ERROR, None)
434
- except TypeError:
435
- stashed = None
436
- if stashed is not None and stashed[0] == name:
437
- raise stashed[1]
446
+ stashed = take_getter_error(self, name)
447
+ if stashed is not None:
448
+ raise stashed
438
449
  reason = members.get(name)
439
450
  if reason is not None:
440
451
  raise UnsupportedAttributeError(f"{type(self).__name__}.{name}", REASONS[reason])
@@ -0,0 +1,7 @@
1
+ """Shim re-exporting upstream's ``pptx.chart.chart`` surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..shapes import Chart, ChartArea, ChartTitle, PlotArea
6
+
7
+ __all__ = ["Chart", "ChartArea", "ChartTitle", "PlotArea"]
@@ -808,6 +808,54 @@ class Client:
808
808
  except requests.RequestException as e:
809
809
  raise ConnectionError(f"Failed to download render from {url}: {e}", url=url)
810
810
 
811
+ def _download_media(self, *, key: str, max_bytes: int) -> bytes:
812
+ """Download one deck media object's bytes with this client's credentials.
813
+
814
+ ``/media`` runs the same deck-access check as every deck op, so the
815
+ bearer stays on the configured API host and a redirect is refused
816
+ rather than followed. Reads stop at ``max_bytes``.
817
+ """
818
+ path = "/media"
819
+ url = f"{self.base_url.rstrip('/')}{path}?key={quote(key, safe='')}"
820
+ headers = self._get_headers(path)
821
+ headers.pop("Content-Type", None)
822
+ headers["Accept"] = "*/*"
823
+ try:
824
+ response = self._session.get(
825
+ url,
826
+ headers=headers,
827
+ timeout=self.timeout,
828
+ allow_redirects=False,
829
+ stream=True,
830
+ )
831
+ try:
832
+ if 300 <= response.status_code < 400:
833
+ raise RemoteError(
834
+ message="Media download redirected; refusing to follow it",
835
+ status_code=response.status_code,
836
+ error_code="MEDIA_REDIRECT",
837
+ )
838
+ if not response.ok:
839
+ self._handle_response(response)
840
+ chunks: list[bytes] = []
841
+ received = 0
842
+ for chunk in response.iter_content(chunk_size=65536):
843
+ if not chunk:
844
+ continue
845
+ received += len(chunk)
846
+ if received > max_bytes:
847
+ raise RemoteError(
848
+ message=f"Media object exceeds the {max_bytes}-byte read cap",
849
+ status_code=response.status_code,
850
+ error_code="MEDIA_TOO_LARGE",
851
+ )
852
+ chunks.append(chunk)
853
+ return b"".join(chunks)
854
+ finally:
855
+ response.close()
856
+ except requests.RequestException as e:
857
+ raise ConnectionError(f"Failed to download media from {url}: {e}", url=url) from e
858
+
811
859
  # -------------------------------------------------------------------------
812
860
  # Deck operations
813
861
  # -------------------------------------------------------------------------
@@ -1542,13 +1542,14 @@ class SetCellBorders(Command):
1542
1542
  ``"dashDot"`` / ``"lgDash"`` / ``"lgDashDot"`` / ``"lgDashDotDot"`` /
1543
1543
  ``"sysDash"`` / ``"sysDot"`` / ``"sysDashDot"`` / ``"sysDashDotDot"``)
1544
1544
 
1545
- Passing ``None`` for an edge removes that edge's border.
1545
+ Passing ``None`` for an edge gives that edge no line (the studio stores
1546
+ and exports it as ``<a:lnX><a:noFill/></a:lnX>``).
1546
1547
 
1547
1548
  Args:
1548
1549
  shape_id: ID of the table shape
1549
1550
  row: Row index (0-based)
1550
1551
  col: Column index (0-based)
1551
- border_left: Left-edge border spec, or ``None`` to clear
1552
+ border_left: Left-edge border spec, or ``None`` for no line
1552
1553
  border_right: Right-edge border spec
1553
1554
  border_top: Top-edge border spec
1554
1555
  border_bottom: Bottom-edge border spec
@@ -8,7 +8,7 @@ commands the public API sends (:mod:`pptx.oxml._mirror` runs the lifecycle):
8
8
 
9
9
  * ``a:tcPr`` borders ``a:lnL`` / ``a:lnR`` / ``a:lnT`` / ``a:lnB`` (``w``,
10
10
  ``a:solidFill``, ``a:noFill``, ``a:prstDash``) as one ``SetCellBorders`` per
11
- cell; removing an edge's ``a:lnX`` clears that border;
11
+ cell; an edge with no line is an ``a:lnX`` holding ``a:noFill``;
12
12
  * ``a:tcPr`` ``marL`` / ``marR`` / ``marT`` / ``marB`` as ``SetCellMargins``,
13
13
  ``anchor`` as ``SetCellAnchor``, and the cell's fill (an RGB ``a:solidFill``
14
14
  or ``a:noFill``) as ``SetTableCell``;
@@ -18,8 +18,9 @@ commands the public API sends (:mod:`pptx.oxml._mirror` runs the lifecycle):
18
18
  ``a:gridCol`` and its ``a:tc`` in every row) as ``DeleteTableColumn``.
19
19
 
20
20
  The studio has no command that changes an existing table's style, so an edit
21
- to ``a:tblPr/a:tableStyleId`` raises, as does anything else no command carries
22
- (merges, rows or columns added through XML, the cells' text).
21
+ to ``a:tblPr/a:tableStyleId`` raises, as does removing an edge's ``a:lnX``
22
+ (which hands the edge back to the table style) and anything else no command
23
+ carries (merges, rows or columns added through XML, the cells' text).
23
24
  """
24
25
 
25
26
  from __future__ import annotations
@@ -165,6 +166,11 @@ _ADD = (
165
166
  "column's formatting), and table.delete_row(i) / table.delete_column(i)."
166
167
  )
167
168
  _MERGE = "Merge and split cells with cell.merge(other_cell) / cell.split()."
169
+ _EDGE_REMOVED = (
170
+ "Removing a cell's a:lnX hands the edge back to the table style, and the studio has no "
171
+ "command for that: it stores an edge as a line or as no line. For no line, keep the "
172
+ "a:lnX and give it an a:noFill (cell.border_left.no_border())."
173
+ )
168
174
  _TEXT = (
169
175
  "A cell's text is not edited through XML here: use cell.text, or "
170
176
  "cell.text_frame.paragraphs[0].runs[0].font for its formatting."
@@ -207,11 +213,12 @@ def _fill_type(cell: TableCell) -> str | None:
207
213
 
208
214
  def _border_spec(cell: TableCell, edge: str, tag: str) -> dict[str, Any] | None:
209
215
  """An edge's border as the studio stores it (``widthEmu``, ``colorHex`` /
210
- ``schemeColor`` / ``noFill``, ``dashStyle``): what this session wrote,
211
- else the snapshot's; None when the cell has none of its own."""
216
+ ``schemeColor`` / ``noFill``, ``dashStyle``): what this session wrote (an
217
+ edge it cleared has no line), else the snapshot's; None when the cell has
218
+ none of its own."""
212
219
  written = cell._border_specs
213
220
  if written is not None and edge in written:
214
- return written[edge]
221
+ return written[edge] or {"noFill": True}
215
222
  spec = (cell._snapshot_borders or {}).get(tag)
216
223
  return spec if isinstance(spec, dict) and spec else None
217
224
 
@@ -295,8 +302,9 @@ def _render_tblPr(table: Table) -> Any:
295
302
  for flag, name, _ in _LOOK:
296
303
  if getattr(table, name):
297
304
  tblPr.set(flag, "1")
298
- if table._table_style_id:
299
- _sub(tblPr, "a:tableStyleId").text = table._table_style_id
305
+ style_id = table._style_id()
306
+ if style_id:
307
+ _sub(tblPr, "a:tableStyleId").text = style_id
300
308
  return tblPr
301
309
 
302
310
 
@@ -922,6 +930,8 @@ class TableMirror(XmlMirror):
922
930
  for edge, tag in _EDGES:
923
931
  if base.edges.get(tag) == cur.edges.get(tag):
924
932
  continue
933
+ if not cur.edges.get(tag):
934
+ raise unsupported(f"{tcpr_path}/a:{tag}", _EDGE_REMOVED)
925
935
  spec, cap = self._edge_spec(cur.tcpr, tag, f"{tcpr_path}/a:{tag}")
926
936
  edges[edge] = spec
927
937
  warn_cap = warn_cap or cap
@@ -942,14 +952,12 @@ class TableMirror(XmlMirror):
942
952
  steps.append(self._plan_fill(cell, cur.tcpr, tcpr_path))
943
953
  return steps
944
954
 
945
- def _edge_spec(self, tcPr: Any, tag: str, path: str) -> tuple[dict[str, Any] | None, bool]:
946
- """An edge's ``a:lnX`` as the border spec ``SetCellBorders`` sends (None: no
947
- border of its own), and whether it set a line cap."""
955
+ def _edge_spec(self, tcPr: Any, tag: str, path: str) -> tuple[dict[str, Any], bool]:
956
+ """An edge's ``a:lnX`` (the cell has one) as the border spec
957
+ ``SetCellBorders`` sends, and whether it set a line cap."""
948
958
  lines = tcPr.findall(qn(f"a:{tag}")) if tcPr is not None else []
949
959
  if len(lines) > 1:
950
960
  raise unsupported(path, f"Keep one a:{tag} in an a:tcPr.")
951
- if not lines:
952
- return None, False
953
961
  return _line_spec(lines[0], path, scheme_colors=self._table._source in _uploaded_sources())
954
962
 
955
963
  def _plan_fill(self, cell: TableCell, tcPr: Any, path: str) -> Callable[[], None]:
@@ -1078,7 +1086,7 @@ def _line_spec(line: Any, path: str, *, scheme_colors: bool) -> tuple[dict[str,
1078
1086
  if not spec:
1079
1087
  raise unsupported(
1080
1088
  path,
1081
- "A border sets a width (w), a fill or a:prstDash; remove the a:lnX to clear the border "
1089
+ "A border sets a width (w), a fill or a:prstDash; for no line put an a:noFill in it "
1082
1090
  "(cell.border_left.no_border()).",
1083
1091
  )
1084
1092
  return spec, cap
@@ -0,0 +1,75 @@
1
+ """``p:sp``, python-pptx's ``pptx.oxml.shapes.autoshape``.
2
+
3
+ The element of an autoshape, a text box, a freeform or a placeholder:
4
+ ``shape._element`` / ``shape._sp``. See :mod:`pptx.oxml.shapes.shared` for what
5
+ it mirrors. Its ``p:txBody`` is the shape's text frame's: ``sp.txBody`` is
6
+ ``shape.text_frame._txBody`` (:mod:`pptx.oxml._txbody`), so what a recipe changes
7
+ in it is held and sent as that text's commands.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from ..._unsupported import keep_getter_errors
15
+ from ...errors import UnsupportedAttributeError
16
+ from ..ns import qn
17
+ from .shared import BaseShapeElement, _NonVisualProps
18
+
19
+
20
+ class CT_ShapeNonVisual(_NonVisualProps):
21
+ """``p:nvSpPr``: the shape's non-visual properties (its ``cNvPr``)."""
22
+
23
+ _tag = qn("p:nvSpPr")
24
+
25
+
26
+ class CT_Shape(BaseShapeElement):
27
+ """``p:sp``: an autoshape, text box, freeform or placeholder. Its
28
+ ``p:nvSpPr`` (the id and name) and its ``p:txBody`` (the text frame's) are
29
+ mirrored; its ``p:spPr`` and ``p:style`` are not."""
30
+
31
+ _tag = qn("p:sp")
32
+ _nv_class = CT_ShapeNonVisual
33
+
34
+ @property
35
+ def nvSpPr(self) -> CT_ShapeNonVisual:
36
+ """The ``p:nvSpPr`` child."""
37
+ return self._nvXxPr # type: ignore[return-value]
38
+
39
+ @property
40
+ @keep_getter_errors
41
+ def txBody(self) -> Any:
42
+ """The ``p:txBody`` child: the shape's text frame's ``_txBody``, the same
43
+ element (``sp.txBody is shape.text_frame._txBody``, as in python-pptx).
44
+ What a recipe changes in it is held and sent before the next command, an
45
+ explicit flush or an API call on that text, and it shows the text's
46
+ current state when requested again. None for a shape without a text frame,
47
+ as python-pptx answers for a ``p:sp`` without a ``p:txBody``."""
48
+ shape = self._shape
49
+ if shape is None:
50
+ raise UnsupportedAttributeError(
51
+ "CT_Shape.txBody",
52
+ "this is a copy (copy.deepcopy()) that is not in a shape tree yet, and the "
53
+ "studio clones a shape with the text its original has when the copy is "
54
+ "inserted: insert the copy, then edit the text of the shape it becomes "
55
+ "(slide.shapes[-1].text_frame).",
56
+ )
57
+ if not shape.has_text_frame:
58
+ return None
59
+ return shape.text_frame._txBody
60
+
61
+ def get_or_add_txBody(self) -> Any:
62
+ """The ``p:txBody`` (:attr:`txBody`). python-pptx adds one to a ``p:sp``
63
+ without one; here every shape that can hold text has its text frame's,
64
+ and no command gives one to a shape that cannot."""
65
+ txBody = self.txBody
66
+ if txBody is None:
67
+ raise UnsupportedAttributeError(
68
+ "CT_Shape.get_or_add_txBody",
69
+ "this shape has no text frame (shape.has_text_frame is False), and no studio "
70
+ "command gives it one: put the text in a text box or an autoshape.",
71
+ )
72
+ return txBody
73
+
74
+
75
+ __all__ = ["CT_Shape", "CT_ShapeNonVisual"]
@@ -6,7 +6,8 @@ what python-pptx's delete, z-order and duplicate recipes do with one: read its
6
6
  ``tag``, find it in its tree (``getparent()``, ``getnext()``, ``getprevious()``),
7
7
  place another shape beside it (``addnext()``, ``addprevious()``) and copy it
8
8
  (``copy.deepcopy()``). Its non-visual properties (``sp.nvSpPr.cNvPr``) give the
9
- shape's id and name, read-only.
9
+ shape's id and name, read-only. A ``p:sp``'s ``p:txBody`` is its text frame's
10
+ (:class:`~pptx.oxml.shapes.autoshape.CT_Shape`).
10
11
 
11
12
  Edits go through :class:`_ShapeTreeEdit`, one per presentation, which turns
12
13
  them into studio commands once they are complete (see
@@ -20,6 +21,7 @@ import contextlib
20
21
  from collections.abc import Iterator, Mapping
21
22
  from typing import TYPE_CHECKING, Any, ClassVar
22
23
 
24
+ from ..._unsupported import take_getter_error
23
25
  from ...errors import UnsupportedAttributeError
24
26
  from ..ns import qn
25
27
 
@@ -32,8 +34,9 @@ _MIRRORED: str = (
32
34
  "athena-python-pptx mirrors the shape-tree XML python-pptx's delete, z-order and "
33
35
  "duplicate recipes use: an element's tag, getparent(), getnext() / getprevious(), "
34
36
  "addnext() / addprevious() and copy.deepcopy(); the read-only p:cNvPr id and name "
35
- "(sp.nvSpPr.cNvPr.get('name')); and the shape tree's len, iteration, indexing, "
36
- "index, remove, del, insert, append, extend and insert_element_before."
37
+ "(sp.nvSpPr.cNvPr.get('name')); a p:sp's p:txBody (sp.txBody, its text frame's "
38
+ "_txBody); and the shape tree's len, iteration, indexing, index, remove, del, "
39
+ "insert, append, extend and insert_element_before."
37
40
  )
38
41
  _SHAPE_API: str = (
39
42
  "For the rest use the shape API: shape.text_frame for text, shape.fill / shape.line "
@@ -55,9 +58,9 @@ _PROPERTY_HINTS: dict[str, str] = {
55
58
  "Its a:xfrm is not mirrored: use shape.left / top / width / height, rotation, "
56
59
  "flip_h and flip_v.",
57
60
  ),
58
- **dict.fromkeys(
59
- ("txBody", "get_or_add_txBody"),
60
- "Its p:txBody is not mirrored: use shape.text_frame (paragraphs, runs, font).",
61
+ "get_or_add_txBody": (
62
+ "Only a p:sp (an autoshape, text box or placeholder) holds a p:txBody; there "
63
+ "sp.txBody is its text frame's (shape.text_frame._txBody)."
61
64
  ),
62
65
  **dict.fromkeys(
63
66
  ("blipFill", "blip_rId", "srcRect_l", "srcRect_t", "srcRect_r", "srcRect_b"),
@@ -369,6 +372,13 @@ class BaseShapeElement:
369
372
  """True for a placeholder (a ``p:sp`` / ``p:pic`` / ``p:graphicFrame`` with ``p:ph``)."""
370
373
  return bool(self._source.is_placeholder)
371
374
 
375
+ @property
376
+ def txBody(self) -> Any:
377
+ """The ``p:txBody`` child: None, as python-pptx answers for every shape
378
+ element but a ``p:sp``, the only one that holds one (there it is the
379
+ text frame's: :class:`~pptx.oxml.shapes.autoshape.CT_Shape`)."""
380
+ return None
381
+
372
382
  # -- its place in a tree -------------------------------------------------
373
383
 
374
384
  def _edit(self) -> _ShapeTreeEdit:
@@ -514,8 +524,15 @@ class BaseShapeElement:
514
524
  def __getattr__(self, name: str) -> Any:
515
525
  if name.startswith("__"):
516
526
  raise AttributeError(name)
527
+ # The explanation a property getter raised (keep_getter_errors), which
528
+ # Python would otherwise replace with this lookup's.
529
+ kept = take_getter_error(self, name)
530
+ if kept is not None:
531
+ raise kept
517
532
  raise unsupported(f"{type(self).__name__}.{name}", name)
518
533
 
534
+ __getattr__.__athena_member_guard__ = True # type: ignore[attr-defined]
535
+
519
536
  def __repr__(self) -> str:
520
537
  return f"<{type(self).__name__} '<{nsptag(self.tag)}>' at 0x{id(self):x}>"
521
538