Skip to content

Commit ad7edb8

Browse files
Matthew HoroszowskiMatthew Horoszowski
authored andcommitted
feat(ergonomics): PathLike + PERCENT_40 typo + Slide.background.element fix (modernization Phase 1)
Phase 1 of issue #29 (Modernization & Ergonomics epic). Bundles three small, mostly-orthogonal wins that the issue called out as "trivial cherry-picks" plus a long-standing correctness bug that pollutes the power-user surface. Defers `Font.color` no-mutate-on-read (closes upstream scanny#1111/scanny#1074), `collections.abc` import sweep, and dev-tooling modernization (uv / pyright strict) to Phase 2. What this PR adds / fixes ------------------------- 1. **`pathlib.Path` / `os.PathLike` support across the API.** Closes upstream PR scanny#1123. The four entry points users most commonly hit with a `Path` now accept it without a TypeError: - `pptx.Presentation(path)` — open a deck from a Path - `prs.save(path)` — write a deck to a Path - `slide.shapes.add_picture(path, ...)` — embed an image by Path - `pptx.parts.image.Image.from_file(path)` — lower-level loader Each accepts any `os.PathLike[str]` (Path, custom subclasses, etc.) by coercing via `os.fspath(...)` at the boundary. Existing `str` and file-like-object callers are unaffected. 2. **`MSO_PATTERN_TYPE.PERCENT_40` typo fixed.** Closes upstream scanny#1131. The enum member was misspelled `ERCENT_40` (missing leading `P`). The fix renames to the correct `PERCENT_40` with no value change (xml_value remains `pct40`, integer value remains 6). Code that referenced the broken name needs to update — the broken name is gone deliberately, callers will get a clear AttributeError. 3. **`slide.background.element` returns the `<p:bg>` element.** Closes upstream issue scanny#1126. Previously `slide.background.element` (and the private `._element`) returned the parent `<p:cSld>` element instead of the actual `<p:bg>` background. Power users introspecting the XML now get the right node. The `<p:bg>` is materialized on construction (matching the legacy destructive behavior of accessing `.fill`); since `slide.background` is a lazyproperty, this only fires on first slide-background access, not on slide load. Out of scope (for explicit Phase 2 follow-up) --------------------------------------------- - **`Font.color` mutate-on-read** (closes upstream scanny#1111, scanny#1074) — the getter currently materializes `<a:solidFill>` on access, which means READING a font's color modifies the file. The fix requires a lazy ColorFormat wrapper that delays solidFill creation until the setter actually fires; that's a larger refactor that needs its own PR with careful review against the existing color/fill test suite. - **`collections.abc` import sweep** (closes upstream scanny#771) — no remaining offenders in our fork's source tree (the upstream PR was authored against an older codebase). If any creep in via vendored fixes, Phase 2 will sweep. - **Dev-tooling modernization** (uv, pyright strict, pytest-syrupy) — covered in the issue body but deserves its own PR per the issue's own "patch 1 / 2 / 3" framing. Test coverage ------------- - 15 new unit tests in `tests/test_modernization_phase1.py`: * 4 tests on `Presentation()` accepting Path/str/BytesIO/PathLike-subclass * 2 tests on `Presentation.save(Path)` round-trip * 1 test on `add_picture(Path)` * 3 tests on `Image.from_file(Path|str|stream)` * 2 tests on the PERCENT_40 typo fix * 3 tests on `slide.background.element` (returns `<p:bg>`, fill still works, RGB round-trip survives) - 4 new behave scenarios in `features/modernization-phase1.feature` - New `uat_modernization_phase1.py` (untracked per repo §6) — these are programmer ergonomics, not visual changes, so the UAT prints per-fix verification rather than producing a deck for visual review. Verification ------------ ``` $ python3 -m pytest tests/ -q | tail -3 3237 passed in 4.54s $ ruff check src tests | tail -3 All checks passed! $ python3 -m behave features/ --no-color | tail -3 1003 scenarios passed, 0 failed, 0 skipped 3010 steps passed, 0 failed, 0 skipped ``` Refs #29
1 parent 76291a2 commit ad7edb8

8 files changed

Lines changed: 336 additions & 10 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Feature: Modernization Phase 1 — PathLike, PERCENT_40, slide.background.element
2+
In order to use python-pptx with modern Python idioms
3+
As a developer using python-pptx
4+
I need pathlib.Path support, the PERCENT_40 enum typo fixed,
5+
and slide.background.element to return the actual <p:bg> element
6+
7+
8+
Scenario: Open a presentation from a pathlib.Path
9+
Given a freshly-saved presentation at a Path
10+
When I call Presentation(path) with the Path
11+
Then I get a presentation back
12+
13+
14+
Scenario: Save a presentation to a pathlib.Path
15+
Given a fresh presentation
16+
When I save it to a Path
17+
Then a non-empty .pptx file exists at that Path
18+
19+
20+
Scenario: PERCENT_40 enum is exposed with the correct name
21+
Then MSO_PATTERN_TYPE.PERCENT_40 exists with xml_value pct40
22+
And the broken name ERCENT_40 does not exist
23+
24+
25+
Scenario: slide.background.element returns the <p:bg> element
26+
Given a fresh slide on a fresh presentation
27+
Then slide.background.element local-name is bg

features/steps/modernization.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Gherkin step implementations for Modernization Phase 1 (issue #29)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from behave import given, then, when
8+
from environment import scratch_dir # noqa: E402
9+
10+
from pptx import Presentation
11+
from pptx.enum.dml import MSO_PATTERN_TYPE
12+
13+
14+
# given ===================================================
15+
16+
17+
@given("a freshly-saved presentation at a Path")
18+
def given_freshly_saved_presentation(context):
19+
target = Path(scratch_dir) / "modernization_seed.pptx"
20+
target.parent.mkdir(parents=True, exist_ok=True)
21+
Presentation().save(target)
22+
context.path = target
23+
24+
25+
@given("a fresh presentation")
26+
def given_fresh_presentation(context):
27+
context.prs = Presentation()
28+
29+
30+
@given("a fresh slide on a fresh presentation")
31+
def given_fresh_slide(context):
32+
prs = Presentation()
33+
context.slide = prs.slides.add_slide(prs.slide_layouts[6])
34+
35+
36+
# when ====================================================
37+
38+
39+
@when("I call Presentation(path) with the Path")
40+
def when_call_presentation_with_path(context):
41+
context.prs = Presentation(context.path)
42+
43+
44+
@when("I save it to a Path")
45+
def when_save_to_path(context):
46+
target = Path(scratch_dir) / "modernization_out.pptx"
47+
target.parent.mkdir(parents=True, exist_ok=True)
48+
context.prs.save(target)
49+
context.saved_path = target
50+
51+
52+
# then ====================================================
53+
54+
55+
@then("I get a presentation back")
56+
def then_presentation_back(context):
57+
assert context.prs is not None
58+
59+
60+
@then("a non-empty .pptx file exists at that Path")
61+
def then_non_empty_file_exists(context):
62+
assert context.saved_path.exists()
63+
assert context.saved_path.stat().st_size > 0
64+
65+
66+
@then("MSO_PATTERN_TYPE.PERCENT_40 exists with xml_value pct40")
67+
def then_percent_40_correct(context):
68+
assert MSO_PATTERN_TYPE.PERCENT_40.xml_value == "pct40"
69+
70+
71+
@then("the broken name ERCENT_40 does not exist")
72+
def then_ercent_40_absent(context):
73+
assert hasattr(MSO_PATTERN_TYPE, "ERCENT_40") is False
74+
75+
76+
@then("slide.background.element local-name is bg")
77+
def then_background_element_is_bg(context):
78+
from lxml import etree
79+
80+
bg_elm = context.slide.background.element
81+
local = etree.QName(bg_elm.tag).localname
82+
assert local == "bg", f"expected 'bg', got '{local}'"

src/pptx/api.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,22 @@
1818
from pptx.parts.presentation import PresentationPart
1919

2020

21-
def Presentation(pptx: str | IO[bytes] | None = None) -> presentation.Presentation:
21+
def Presentation(
22+
pptx: str | os.PathLike[str] | IO[bytes] | None = None,
23+
) -> presentation.Presentation:
2224
"""
2325
Return a |Presentation| object loaded from *pptx*, where *pptx* can be
24-
either a path to a ``.pptx`` file (a string) or a file-like object. If
25-
*pptx* is missing or ``None``, the built-in default presentation
26-
"template" is loaded.
26+
a path to a ``.pptx`` file (a |str| or any |os.PathLike| object such as
27+
|pathlib.Path|) or a file-like object. If *pptx* is missing or ``None``,
28+
the built-in default presentation "template" is loaded.
2729
"""
2830
if pptx is None:
2931
pptx = _default_pptx_path()
3032

33+
# ---accept os.PathLike (pathlib.Path, etc.) by coercing to str at the boundary---
34+
if hasattr(pptx, "__fspath__"):
35+
pptx = os.fspath(pptx)
36+
3137
presentation_part = Package.open(pptx).main_document_part
3238

3339
if not _is_pptx_package(presentation_part):

src/pptx/enum/dml.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ class MSO_PATTERN_TYPE(BaseXmlEnum):
351351
PERCENT_30 = (5, "pct30", "30% of the foreground color.")
352352
"""30% of the foreground color."""
353353

354-
ERCENT_40 = (6, "pct40", "40% of the foreground color.")
354+
PERCENT_40 = (6, "pct40", "40% of the foreground color.")
355355
"""40% of the foreground color."""
356356

357357
PERCENT_5 = (1, "pct5", "5% of the foreground color.")

src/pptx/parts/image.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,15 @@ def from_blob(cls, blob: bytes, filename: str | None = None) -> Image:
153153
return cls(blob, filename)
154154

155155
@classmethod
156-
def from_file(cls, image_file: str | IO[bytes]) -> Image:
156+
def from_file(cls, image_file: str | os.PathLike[str] | IO[bytes]) -> Image:
157157
"""Return a new |Image| object loaded from `image_file`.
158158
159-
`image_file` can be either a path (str) or a file-like object.
159+
`image_file` can be a path (|str| or any |os.PathLike| object such as
160+
|pathlib.Path|) or a file-like object.
160161
"""
162+
# ---accept os.PathLike (pathlib.Path etc.) by coercing to str---
163+
if hasattr(image_file, "__fspath__"):
164+
image_file = os.fspath(image_file)
161165
if isinstance(image_file, str):
162166
# treat image_file as a path
163167
with open(image_file, "rb") as f:

src/pptx/presentation.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
from typing import IO, TYPE_CHECKING, Iterable, cast
67

78
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
@@ -66,11 +67,15 @@ def notes_master(self) -> NotesMaster:
6667
"""
6768
return self.part.notes_master
6869

69-
def save(self, file: str | IO[bytes]):
70+
def save(self, file: str | os.PathLike[str] | IO[bytes]):
7071
"""Writes this presentation to `file`.
7172
72-
`file` can be either a file-path or a file-like object open for writing bytes.
73+
`file` can be a file-path (|str| or any |os.PathLike| object such as
74+
|pathlib.Path|) or a file-like object open for writing bytes.
7375
"""
76+
# ---accept os.PathLike (pathlib.Path etc.) by coercing to str---
77+
if hasattr(file, "__fspath__"):
78+
file = os.fspath(file)
7479
self.part.save(file)
7580

7681
@property

src/pptx/slide.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,10 +595,21 @@ class _Background(ElementProxy):
595595
Note that the presence of this object does not by itself imply an
596596
explicitly-defined background; a slide with an inherited background still
597597
has a |_Background| object.
598+
599+
Closes upstream issue #1126: prior to this fix, ``slide.background.element``
600+
returned the parent ``<p:cSld>`` element instead of the actual ``<p:bg>``
601+
background element. Power users introspecting the XML now get the right
602+
node. The ``<p:bg>`` element is materialized on construction (matching
603+
the legacy destructive behavior of accessing ``.fill``) — and since
604+
``slide.background`` is a |lazyproperty| upstream, this only fires on
605+
first access of the slide's background, not on slide load.
598606
"""
599607

600608
def __init__(self, cSld: CT_CommonSlideData):
601-
super(_Background, self).__init__(cSld)
609+
# ---resolve to the <p:bg> element (creating if needed) so that
610+
# `_element` and `.element` point at the right node per issue #1126
611+
bg = cSld.get_or_add_bg()
612+
super(_Background, self).__init__(bg)
602613
self._cSld = cSld
603614

604615
@lazyproperty

0 commit comments

Comments
 (0)