Skip to content

feat: shape-tree ergonomics — iter_leaf_shapes, find_by_xpath, mapping access, selection-pane order (Modernization Phase 4)#45

Merged
MHoroszowski merged 1 commit intomasterfrom
feature/modernization-phase4
May 8, 2026
Merged

feat: shape-tree ergonomics — iter_leaf_shapes, find_by_xpath, mapping access, selection-pane order (Modernization Phase 4)#45
MHoroszowski merged 1 commit intomasterfrom
feature/modernization-phase4

Conversation

@MHoroszowski
Copy link
Copy Markdown
Owner

Phase 4 of issue #29 (Modernization & Ergonomics): shape-tree ergonomics

Phases 1 (#39), 2 (#43), and 3 (#44) shipped PathLike, Font.color non-mutation, UTC datetimes, Shapes.by_name, the pyright CI gate, and pyproject cleanup. This PR closes the four shape-tree ergonomic sub-features in issue #29's API ergonomics group — all small, additive, all closing upstream tickets.

Public surface added

# Mapping-like access (closes scanny#800)
slide.shapes["Title 1"]              # → BaseShape (string key delegates to by_name)
slide.shapes[0]                      # → BaseShape (int key keeps existing index semantics)
"Title 1" in slide.shapes            # → True
slide.shapes.keys()                  # → ["Title 1", "Content Placeholder 2", ...]
slide.placeholders["Title 1"]        # same surface on SlidePlaceholders
slide.placeholders[10]               # int still keys by ph_idx (NOT sequence index)

# Recursive traversal (closes scanny#435)
for shape in slide.shapes.iter_leaf_shapes():
    ...   # descends into GroupShape children; yields non-group leaves only

# XPath escape hatch
shape.find_by_xpath(".//a:t")
shape.find_by_xpath(".//foo:bar", namespaces={"foo": "..."})

# Selection-pane order (closes scanny#532)
slide.shapes.in_selection_pane_order()    # tuple in reverse-XML / z-order

What it adds

_BaseShapes.iter_leaf_shapes() (closes scanny#435)

  • Generator that recursively descends into GroupShape children, yielding only non-group leaf shapes.
  • Useful when "give me every actual shape" is the right traversal — the existing for shape in slide.shapes counts each group as 1 entry. A consumer wanting groups can still iterate normally.

_BaseShapes.__getitem__(key) overloaded to accept int | str (closes scanny#800)

  • Integer keys keep existing semantics (index in document order, IndexError on out-of-range).
  • String keys delegate to by_name(key) from Phase 2.
  • bool keys are rejected with TypeError — they're a subclass of int so would otherwise silently resolve to index 0/1, almost certainly an unintended call. Forge audit catch.
  • Plus added __contains__(key) (string checks name; int checks index range; bool/other types return False) and keys() (list of every shape's name in document order).
  • Same Mapping-like surface added to SlidePlaceholders — string lookup by .name, integer keeps the existing ph_idx dictionary-key semantics.

Decision: not subclassing collections.abc.Mapping. The Mapping contract requires __iter__ to yield keys; existing Shapes.__iter__ yields shape values. Switching would silently break every existing call site. Duck-typed Mapping-like behavior gets the user-facing benefit without the breaking change.

BaseShape.find_by_xpath(xpath, namespaces=None) — power-user XPath escape hatch

  • With namespaces=None (default): uses the project's standard _nsmap so common prefixes (a:, p:, r:, xsi:, adec:, p14:, etc.) work without explicit declaration.
  • With a custom namespaces dict: bypasses the project wrapper via a module-level _LXML_XPATH = lxml.etree._Element.xpath constant so the user's prefix map is honored verbatim. Forge audit suggested lifting the lookup to module-level rather than importing inside the method — done.
  • Returns whatever lxml returns — typically a list of matching elements, or [] on no match.

_BaseShapes.in_selection_pane_order() (closes scanny#532)

  • Read-only tuple of shapes in PowerPoint Selection Pane order (top-most first = last in XML).
  • Returns a snapshot, not a live view; doesn't promise to update if the collection is mutated after the call.

Tests

  • 29 new pytest cases in tests/test_modernization_phase4.py covering all four helpers, Mapping-like dispatch on str/int/bool/other key types, GroupShape exclusion contract, custom-nsmap path, tuple type + reversal contract, and Phase-2/3 regression checks. Full suite: 3485 passed.
  • 7 new behave scenarios in features/modernization-phase4.feature exercising the four helpers end-to-end. Full behave: 1048 scenarios passed, 0 failed.
  • Ruff clean; pyright public-API gate (added in Phase 3) stays clean.

Reporting contract (CLAUDE.md §7)

$ python3 -m pytest tests/ -q | tail -3
........................................................................ [ 99%]
.............................                                            [100%]
3485 passed in 5.29s

$ python3 -m ruff check src tests | tail -3
All checks passed!

$ python3 -m behave features/ --no-color 2>&1 | tail -3
1048 scenarios passed, 0 failed, 0 skipped
3151 steps passed, 0 failed, 0 skipped
Took 0min 1.662s

$ pyright src/pptx/{__init__,api,presentation,util,exc,types}.py
0 errors, 0 warnings, 0 informations

UAT

  • uat_modernization_phase4.py (untracked per CLAUDE.md §6) at repo root.
  • Builds a slide with title + body + three textboxes (Alpha/Bravo/Charlie); exercises all four helpers and round-trips the saved deck.
  • Visual UAT: maintainer opened the saved deck, opened PowerPoint's Selection Pane, confirmed listed order matches in_selection_pane_order() output (top-most first).

Issue #29 ledger after this merge

Phase PR What
1 #39 PathLike, PERCENT_40 typo, Slide.background
2 #43 Font.color non-mutation, UTC datetimes, Shapes.by_name
3 #44 pyright CI gate, Python 3.13, pyproject cleanup
4 this iter_leaf_shapes, find_by_xpath, mapping-like access, in_selection_pane_order

Remaining on issue #29: uv migration and ruff selection strengthening (B/RUF) — both deferred to standalone PRs per Phase 3 PR body.

Refs #29

…g access, selection-pane order (Modernization Phase 4)

Issue: #29 (Phase 4)

Phases 1 (#39), 2 (#43), and 3 (#44) shipped PathLike, Font.color
non-mutation, UTC datetimes, `Shapes.by_name`, the pyright CI gate, and
pyproject cleanup. Phase 4 closes the four shape-tree ergonomic
sub-features in issue #29's API ergonomics group — all small, additive,
all closing upstream tickets.

Public surface added (additive — no existing API removed)
- `_BaseShapes.iter_leaf_shapes()` — generator that recursively descends
  into `GroupShape` children, yielding only non-group leaf shapes.
  Useful when "give me every actual shape" is the right traversal,
  rather than the existing `for shape in slide.shapes` which counts
  groups as one. A consumer wanting groups can still iterate normally.
  Closes scanny#435.

- `_BaseShapes.__getitem__(key)` overloaded to accept `int | str`.
  Integer keys keep existing semantics (index in document order,
  IndexError on out-of-range). String keys delegate to `by_name(key)` —
  the Phase 2 lookup helper. Plus added `__contains__(key)` (string
  checks name presence; int checks index range; other types return
  False) and `keys()` (list of every shape's name in document order).
  Same Mapping-like surface added to `SlidePlaceholders`: string lookup
  by `.name`, integer keeps the existing `ph_idx` dictionary key
  semantics. Closes scanny#800.

  Decision: not subclassing `collections.abc.Mapping`. The Mapping
  contract requires `__iter__` to yield keys; existing `Shapes.__iter__`
  yields shape values. Switching would silently break every existing
  call site. Duck-typed Mapping-like behavior gets the user-facing
  benefit without the breaking change.

- `BaseShape.find_by_xpath(xpath, namespaces=None)` — power-user XPath
  escape hatch over a shape's element subtree. With `namespaces=None`,
  uses the project's standard `_nsmap` so common prefixes (`a:`, `p:`,
  `r:`, `xsi:`, `adec:`, `p14:`, etc.) work without explicit
  declaration. Custom `namespaces` dict bypasses the project wrapper
  via `lxml.etree._Element.xpath` directly so the user's prefix map
  is honored. Returns whatever lxml returns — typically a list of
  matching elements, or [] on no match.

- `_BaseShapes.in_selection_pane_order()` — read-only tuple of shapes
  in PowerPoint Selection Pane order (top-most first = last in XML).
  Returns a snapshot, not a live view; doesn't promise to update if
  the collection is mutated after the call. Closes
  scanny#532.

Tests
- 28 new pytest cases in `tests/test_modernization_phase4.py` covering:
  iter_leaf_shapes (top-level identity, GroupShape exclusion, leaf
  count); Mapping-like access on Shapes (string-key, KeyError, int-key
  unchanged, `__contains__` on str/int/other types, `keys()` order);
  same on SlidePlaceholders (str by name, int by `ph_idx`); find_by_xpath
  with default + custom nsmap; in_selection_pane_order tuple type +
  reversal contract + length preservation + non-mutation. Plus
  Phase-2/3 regression checks. Full pytest: `3484 passed`.
- 7 new behave scenarios in `features/modernization-phase4.feature`
  covering the four helpers end-to-end. Full behave: `1048 scenarios
  passed, 0 failed` (baseline 1041 + 7 new).
- Ruff: `ruff check src tests` → All checks passed; `ruff format
  --check` → no diff.
- Pyright public-API gate (Phase 3): `0 errors, 0 warnings, 0
  informations`.

Issue #29 ledger after this PR
- Phase 1 (#39) — PathLike, PERCENT_40 typo, Slide.background
- Phase 2 (#43) — Font.color non-mutation, UTC datetimes, Shapes.by_name
- Phase 3 (#44) — pyright CI gate, Python 3.13, pyproject cleanup
- Phase 4 (this PR) — iter_leaf_shapes, find_by_xpath, mapping-like
  access on Shapes/SlidePlaceholders, in_selection_pane_order
- Remaining: `uv` migration and ruff selection strengthening
  (`B`/`RUF`) — defer to standalone PRs per Phase 3 PR body.

Refs #29
@MHoroszowski MHoroszowski merged commit 53fea8f into master May 8, 2026
7 checks passed
@MHoroszowski MHoroszowski deleted the feature/modernization-phase4 branch May 8, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: General listing that matches Selection Pane feature: add _BaseShapes.iter_leaf_shapes()

1 participant