diff --git a/features/modernization-phase1.feature b/features/modernization-phase1.feature new file mode 100644 index 000000000..21379a18d --- /dev/null +++ b/features/modernization-phase1.feature @@ -0,0 +1,27 @@ +Feature: Modernization Phase 1 — PathLike, PERCENT_40, slide.background.element + In order to use python-pptx with modern Python idioms + As a developer using python-pptx + I need pathlib.Path support, the PERCENT_40 enum typo fixed, + and slide.background.element to return the actual element + + + Scenario: Open a presentation from a pathlib.Path + Given a freshly-saved presentation at a Path + When I call Presentation(path) with the Path + Then I get a presentation back + + + Scenario: Save a presentation to a pathlib.Path + Given a fresh presentation + When I save it to a Path + Then a non-empty .pptx file exists at that Path + + + Scenario: PERCENT_40 enum is exposed with the correct name + Then MSO_PATTERN_TYPE.PERCENT_40 exists with xml_value pct40 + And the broken name ERCENT_40 does not exist + + + Scenario: slide.background.element returns the element + Given a fresh slide on a fresh presentation + Then slide.background.element local-name is bg diff --git a/features/steps/modernization.py b/features/steps/modernization.py new file mode 100644 index 000000000..ab4a14d00 --- /dev/null +++ b/features/steps/modernization.py @@ -0,0 +1,82 @@ +"""Gherkin step implementations for Modernization Phase 1 (issue #29).""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from environment import scratch_dir # noqa: E402 + +from pptx import Presentation +from pptx.enum.dml import MSO_PATTERN_TYPE + + +# given =================================================== + + +@given("a freshly-saved presentation at a Path") +def given_freshly_saved_presentation(context): + target = Path(scratch_dir) / "modernization_seed.pptx" + target.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(target) + context.path = target + + +@given("a fresh presentation") +def given_fresh_presentation(context): + context.prs = Presentation() + + +@given("a fresh slide on a fresh presentation") +def given_fresh_slide(context): + prs = Presentation() + context.slide = prs.slides.add_slide(prs.slide_layouts[6]) + + +# when ==================================================== + + +@when("I call Presentation(path) with the Path") +def when_call_presentation_with_path(context): + context.prs = Presentation(context.path) + + +@when("I save it to a Path") +def when_save_to_path(context): + target = Path(scratch_dir) / "modernization_out.pptx" + target.parent.mkdir(parents=True, exist_ok=True) + context.prs.save(target) + context.saved_path = target + + +# then ==================================================== + + +@then("I get a presentation back") +def then_presentation_back(context): + assert context.prs is not None + + +@then("a non-empty .pptx file exists at that Path") +def then_non_empty_file_exists(context): + assert context.saved_path.exists() + assert context.saved_path.stat().st_size > 0 + + +@then("MSO_PATTERN_TYPE.PERCENT_40 exists with xml_value pct40") +def then_percent_40_correct(context): + assert MSO_PATTERN_TYPE.PERCENT_40.xml_value == "pct40" + + +@then("the broken name ERCENT_40 does not exist") +def then_ercent_40_absent(context): + assert hasattr(MSO_PATTERN_TYPE, "ERCENT_40") is False + + +@then("slide.background.element local-name is bg") +def then_background_element_is_bg(context): + from lxml import etree + + bg_elm = context.slide.background.element + local = etree.QName(bg_elm.tag).localname + assert local == "bg", f"expected 'bg', got '{local}'" diff --git a/src/pptx/api.py b/src/pptx/api.py index 892f425ab..daa3e8410 100644 --- a/src/pptx/api.py +++ b/src/pptx/api.py @@ -18,16 +18,22 @@ from pptx.parts.presentation import PresentationPart -def Presentation(pptx: str | IO[bytes] | None = None) -> presentation.Presentation: +def Presentation( + pptx: str | os.PathLike[str] | IO[bytes] | None = None, +) -> presentation.Presentation: """ Return a |Presentation| object loaded from *pptx*, where *pptx* can be - either a path to a ``.pptx`` file (a string) or a file-like object. If - *pptx* is missing or ``None``, the built-in default presentation - "template" is loaded. + a path to a ``.pptx`` file (a |str| or any |os.PathLike| object such as + |pathlib.Path|) or a file-like object. If *pptx* is missing or ``None``, + the built-in default presentation "template" is loaded. """ if pptx is None: pptx = _default_pptx_path() + # ---accept os.PathLike (pathlib.Path, etc.) by coercing to str at the boundary--- + if hasattr(pptx, "__fspath__"): + pptx = os.fspath(pptx) + presentation_part = Package.open(pptx).main_document_part if not _is_pptx_package(presentation_part): diff --git a/src/pptx/enum/dml.py b/src/pptx/enum/dml.py index cb24a262f..a994e4de8 100644 --- a/src/pptx/enum/dml.py +++ b/src/pptx/enum/dml.py @@ -351,7 +351,7 @@ class MSO_PATTERN_TYPE(BaseXmlEnum): PERCENT_30 = (5, "pct30", "30% of the foreground color.") """30% of the foreground color.""" - ERCENT_40 = (6, "pct40", "40% of the foreground color.") + PERCENT_40 = (6, "pct40", "40% of the foreground color.") """40% of the foreground color.""" PERCENT_5 = (1, "pct5", "5% of the foreground color.") diff --git a/src/pptx/parts/image.py b/src/pptx/parts/image.py index 9be5d02d6..5b4d19ab7 100644 --- a/src/pptx/parts/image.py +++ b/src/pptx/parts/image.py @@ -153,11 +153,15 @@ def from_blob(cls, blob: bytes, filename: str | None = None) -> Image: return cls(blob, filename) @classmethod - def from_file(cls, image_file: str | IO[bytes]) -> Image: + def from_file(cls, image_file: str | os.PathLike[str] | IO[bytes]) -> Image: """Return a new |Image| object loaded from `image_file`. - `image_file` can be either a path (str) or a file-like object. + `image_file` can be a path (|str| or any |os.PathLike| object such as + |pathlib.Path|) or a file-like object. """ + # ---accept os.PathLike (pathlib.Path etc.) by coercing to str--- + if hasattr(image_file, "__fspath__"): + image_file = os.fspath(image_file) if isinstance(image_file, str): # treat image_file as a path with open(image_file, "rb") as f: diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index 921111fea..426a50fd2 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterable, cast from pptx.opc.constants import RELATIONSHIP_TYPE as RT @@ -66,11 +67,15 @@ def notes_master(self) -> NotesMaster: """ return self.part.notes_master - def save(self, file: str | IO[bytes]): + def save(self, file: str | os.PathLike[str] | IO[bytes]): """Writes this presentation to `file`. - `file` can be either a file-path or a file-like object open for writing bytes. + `file` can be a file-path (|str| or any |os.PathLike| object such as + |pathlib.Path|) or a file-like object open for writing bytes. """ + # ---accept os.PathLike (pathlib.Path etc.) by coercing to str--- + if hasattr(file, "__fspath__"): + file = os.fspath(file) self.part.save(file) @property diff --git a/src/pptx/slide.py b/src/pptx/slide.py index a070ac81c..a66abf6cc 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -595,10 +595,21 @@ class _Background(ElementProxy): Note that the presence of this object does not by itself imply an explicitly-defined background; a slide with an inherited background still has a |_Background| object. + + Closes upstream issue #1126: prior to this fix, ``slide.background.element`` + returned the parent ```` element instead of the actual ```` + background element. Power users introspecting the XML now get the right + node. The ```` element is materialized on construction (matching + the legacy destructive behavior of accessing ``.fill``) — and since + ``slide.background`` is a |lazyproperty| upstream, this only fires on + first access of the slide's background, not on slide load. """ def __init__(self, cSld: CT_CommonSlideData): - super(_Background, self).__init__(cSld) + # ---resolve to the element (creating if needed) so that + # `_element` and `.element` point at the right node per issue #1126 + bg = cSld.get_or_add_bg() + super(_Background, self).__init__(bg) self._cSld = cSld @lazyproperty diff --git a/tests/test_modernization_phase1.py b/tests/test_modernization_phase1.py new file mode 100644 index 000000000..a9a44efd0 --- /dev/null +++ b/tests/test_modernization_phase1.py @@ -0,0 +1,191 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Modernization Phase 1 (issue #29). + +Phase 1 of the Modernization & Ergonomics epic. Bundles: + +- ``pathlib.Path`` (and any ``os.PathLike``) inputs accepted everywhere + python-pptx accepted str paths — closes upstream PR #1123. +- ``MSO_PATTERN_TYPE.PERCENT_40`` typo fix (was ``ERCENT_40``) — closes #1131. +- ``Slide.background.element`` returns the actual ```` element + rather than its parent ```` — closes upstream issue #1126. + +Deferred to Phase 2: ``Font.color`` no-mutate-on-read fix (closes +#1111/#1074), ``collections.abc`` import sweep, dev-tooling +modernization (uv / pyright strict). +""" + +from __future__ import annotations + +import io +from pathlib import Path + +from pptx import Presentation +from pptx.enum.dml import MSO_PATTERN_TYPE +from pptx.parts.image import Image +from pptx.util import Inches + +# --------------------------------------------------------------------------- +# PathLike support — Presentation, save, add_picture, Image.from_file +# --------------------------------------------------------------------------- + + +class DescribePathLikeForPresentationOpen(object): + def it_accepts_a_pathlib_Path(self, tmp_path): + # ---seed a deck so we have something to read back--- + seed = Presentation() + seed.save(str(tmp_path / "seed.pptx")) + + # ---Path goes in, Presentation comes out without TypeError--- + prs = Presentation(tmp_path / "seed.pptx") + assert len(prs.slides) == 0 # ---fresh deck + + def it_accepts_a_str_path_unchanged(self, tmp_path): + seed = Presentation() + seed.save(str(tmp_path / "seed.pptx")) + + prs = Presentation(str(tmp_path / "seed.pptx")) + assert prs is not None + + def it_accepts_a_BytesIO_stream_unchanged(self, tmp_path): + buf = io.BytesIO() + Presentation().save(buf) + buf.seek(0) + + prs = Presentation(buf) + assert prs is not None + + def it_accepts_a_subclass_of_PathLike(self, tmp_path): + class MyPath: + def __init__(self, p): + self._p = p + + def __fspath__(self): + return str(self._p) + + Presentation().save(str(tmp_path / "seed.pptx")) + prs = Presentation(MyPath(tmp_path / "seed.pptx")) + assert prs is not None + + +class DescribePathLikeForPresentationSave(object): + def it_accepts_a_pathlib_Path(self, tmp_path): + prs = Presentation() + target = tmp_path / "out.pptx" + + prs.save(target) + + assert target.exists() + assert target.stat().st_size > 0 + + def it_round_trips_through_Path_save_and_Path_open(self, tmp_path): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + target = tmp_path / "out.pptx" + + prs.save(target) + rt = Presentation(target) + + assert len(rt.slides) == 1 + + +class DescribePathLikeForAddPicture(object): + def it_accepts_a_pathlib_Path(self, tmp_path): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + png_path = Path(__file__).parent / "test_files" / "python-powered.png" + + picture = slide.shapes.add_picture(png_path, Inches(1), Inches(1)) + + assert picture is not None + + +class DescribePathLikeForImageFromFile(object): + def it_accepts_a_pathlib_Path(self): + png = Path(__file__).parent / "test_files" / "python-powered.png" + + image = Image.from_file(png) + + assert image is not None + assert len(image.blob) > 0 + + def it_accepts_a_str_path_unchanged(self): + png = Path(__file__).parent / "test_files" / "python-powered.png" + + image = Image.from_file(str(png)) + + assert image is not None + + def it_accepts_a_file_like_object_unchanged(self): + png = Path(__file__).parent / "test_files" / "python-powered.png" + with png.open("rb") as f: + image = Image.from_file(f) + + assert image is not None + assert len(image.blob) > 0 + + +# --------------------------------------------------------------------------- +# PERCENT_40 typo fix +# --------------------------------------------------------------------------- + + +class DescribePERCENT_40_TypoFix(object): + def it_exposes_PERCENT_40_with_correct_spelling(self): + # ---the previous broken name `ERCENT_40` should not exist anymore + assert hasattr(MSO_PATTERN_TYPE, "PERCENT_40") is True + assert hasattr(MSO_PATTERN_TYPE, "ERCENT_40") is False + + def it_carries_the_correct_xml_value_and_label(self): + member = MSO_PATTERN_TYPE.PERCENT_40 + # ---values are (value, xml_value, description) tuples on this enum + assert member.value == 6 + assert member.xml_value == "pct40" + + +# --------------------------------------------------------------------------- +# Slide.background._element / .element returns +# --------------------------------------------------------------------------- + + +class DescribeSlideBackgroundElement(object): + def it_returns_the_bg_element_not_cSld(self): + from lxml import etree + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + + bg_proxy = slide.background + + # ---both `.element` (public) and `._element` (private) point at + public_tag = etree.QName(bg_proxy.element.tag).localname + private_tag = etree.QName(bg_proxy._element.tag).localname + assert public_tag == "bg", f"expected 'bg', got '{public_tag}'" + assert private_tag == "bg", f"expected 'bg', got '{private_tag}'" + + def it_still_supports_setting_a_solid_fill_via_fill(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + + bg = slide.background + bg.fill.solid() + + # ---accessing .fill should not have raised; the fill is now solid + from pptx.enum.dml import MSO_FILL + + assert bg.fill.type == MSO_FILL.SOLID + + def it_round_trips_a_solid_fill_through_save_and_open(self, tmp_path): + from pptx.dml.color import RGBColor + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.background.fill.solid() + slide.background.fill.fore_color.rgb = RGBColor(0xAB, 0xCD, 0xEF) + + target = tmp_path / "bg.pptx" + prs.save(target) + rt = Presentation(target) + + assert rt.slides[0].background.fill.fore_color.rgb == RGBColor(0xAB, 0xCD, 0xEF)