From 94965d733928e1f7587f3fe8c988f35ba3ecd09b Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Fri, 8 May 2026 14:19:28 -0400 Subject: [PATCH] chore: pyright CI gate (public API) + Python 3.13 matrix + pyproject cleanup (Modernization Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: https://github.com/MHoroszowski/python-pptx/issues/29 (Phase 3 — dev-tooling) Phases 1 (#39) and 2 (#43) shipped the API ergonomics and bug fixes from issue #29. This PR covers the dev-tooling-modernization sub-bullets that were tractable in a single PR — `pyright` strict-mode CI gate on the public API, Python 3.13 in the test matrix, and cleanup of stale `pyproject.toml` config. CI changes - New `typecheck` job in `.github/workflows/ci.yml` runs `pyright` (installed alongside the editable package) on the public-API surface: `src/pptx/{__init__,api,presentation,util,exc,types}.py`. `pptx/__init__.py` is included because it's the literal entrypoint resolved by `from pptx import Presentation`. Pyright runs in strict mode (already configured in `pyproject.toml`'s `[tool.pyright]` section) and the gate fails on any error, satisfying issue #29's acceptance criterion of "zero errors on the public API". - Test matrix extended to include Python 3.13 (was 3.9 through 3.12). Public-API pyright fixes (zero errors after these) - `src/pptx/api.py` `Presentation()`: replaced `if hasattr(p, "__fspath__"): p = os.fspath(p)` (which doesn't narrow under pyright) with explicit `pkg_file: str | IO[bytes] = os.fspath(p) if isinstance(p, os.PathLike) else p`. Identical runtime behavior; fully narrowed for the type checker. - `src/pptx/presentation.py` `save()`: same shape change for the same reason. - `src/pptx/presentation.py`: added `# pyright: ignore[reportPrivateUsage]` on the deferred imports of `_Sections` (from `pptx.sections`) and `_PortContext` (from `pptx.parts.slide`) — both legitimately consumed at this seam by `Presentation.sections` and `Presentation.append_from`. The leading-underscore convention is documented intent ("internal"); pyright sees the rule and complains regardless. Suppression is the standard escape hatch. - `src/pptx/presentation.py`: dropped unused `duplicate_notes_slide_for` import from `append_from`. The `noqa: F401` was hiding an actually-unused symbol. `pyproject.toml` cleanup - Bumped `requires-python` from `>=3.8` to `>=3.9`. Python 3.8 reached end-of-life in 2024-10 and was never in the test matrix; this aligns the floor with what is actually exercised. PyPI users pinned to 3.8 will see a clean "no compatible version" message via wheel metadata (no runtime crash). Per Forge's NIT, the next release tag should bump the minor version (e.g. `1.0.x` → `1.1.0`) and call out the floor change in `HISTORY.rst`. - Dropped the `Python :: 3.8` classifier; added `Python :: 3.13`. - Removed the dead `[tool.black]` section. The fork standardized on `ruff format` in v1.2.0; black is no longer used anywhere in the toolchain (no `black` invocation in CI, in any Makefile, or in any developer doc). Skipped from issue #29 Phase 3 (deferred to separate PRs) - `uv` migration. Replacing the setuptools build backend, adding a uv lockfile, and reworking CI for uv is a significant standalone change worth its own PR. - Ruff selection strengthening (adding e.g. `B` flake8-bugbear or `RUF` Ruff-specific rules). Trial runs surface 49 + 82 findings respectively — most are real but each requires manual resolution. Defer to a follow-up PR that pairs the rule addition with the cleanup commit. - `pytest-syrupy` snapshot tests for XML fixtures (issue marks this optional). - `unittest`-style test conversion: already done in this fork. Verified by `grep -rln "import unittest|class.*TestCase" tests/` — empty result. Tests - Full pytest: `3456 passed in 4.99s` (no regressions; +0 vs Phase 2). - Full behave: `1041 scenarios passed, 0 failed` (no regressions). - Ruff: `ruff check src tests` → All checks passed; `ruff format --check` → no diff. - Pyright on public API: `0 errors, 0 warnings, 0 informations`. Refs #29 --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++++- pyproject.toml | 7 ++----- src/pptx/api.py | 17 ++++++++++------- src/pptx/presentation.py | 12 ++++++------ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2492df6d5..7f56c7b10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,37 @@ jobs: - name: ruff format check run: ruff format --check src tests + typecheck: + name: Typecheck (pyright strict — public API) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package and pyright + run: | + python -m pip install --upgrade pip pyright + python -m pip install -e . + # ---public-API surface: pptx/__init__.py is the literal entrypoint + # ---resolved by `from pptx import Presentation`, plus the modules + # ---it pulls in (api, presentation, util, exc, types). + # ---Strict-mode pyright on the broader codebase still surfaces several + # ---thousand findings (reportUnknownMemberType-heavy in chart and + # ---oxml.simpletypes); those are tracked as future work, not this gate. + - name: pyright (public-API entry points) + run: | + pyright src/pptx/__init__.py src/pptx/api.py src/pptx/presentation.py \ + src/pptx/util.py src/pptx/exc.py src/pptx/types.py + test: name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 2da7a2681..b19d84595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Office/Business :: Office Suites", "Topic :: Software Development :: Libraries", ] @@ -33,7 +33,7 @@ dynamic = ["version"] keywords = ["powerpoint", "ppt", "pptx", "openxml", "office"] license = { text = "MIT" } readme = "README.rst" -requires-python = ">=3.8" +requires-python = ">=3.9" [project.urls] Changelog = "https://github.com/MHoroszowski/python-pptx/blob/master/HISTORY.rst" @@ -42,9 +42,6 @@ Homepage = "https://github.com/MHoroszowski/python-pptx" Repository = "https://github.com/MHoroszowski/python-pptx" Upstream = "https://github.com/scanny/python-pptx" -[tool.black] -line-length = 100 - [tool.pyright] exclude = [ "**/__pycache__", diff --git a/src/pptx/api.py b/src/pptx/api.py index daa3e8410..69269ea33 100644 --- a/src/pptx/api.py +++ b/src/pptx/api.py @@ -27,18 +27,21 @@ def Presentation( |pathlib.Path|) or a file-like object. If *pptx* is missing or ``None``, the built-in default presentation "template" is loaded. """ + # ---accept os.PathLike (pathlib.Path, etc.) by coercing to str at the + # ---boundary; collapse the union to (str | IO[bytes]) for downstream--- + pkg_file: str | IO[bytes] if pptx is None: - pptx = _default_pptx_path() + pkg_file = _default_pptx_path() + elif isinstance(pptx, os.PathLike): + pkg_file = os.fspath(pptx) + else: + pkg_file = pptx - # ---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 + presentation_part = Package.open(pkg_file).main_document_part if not _is_pptx_package(presentation_part): tmpl = "file '%s' is not a PowerPoint file, content type is '%s'" - raise ValueError(tmpl % (pptx, presentation_part.content_type)) + raise ValueError(tmpl % (pkg_file, presentation_part.content_type)) return presentation_part.presentation diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index 426a50fd2..44db95f9e 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -73,10 +73,10 @@ def save(self, file: str | os.PathLike[str] | IO[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) + # ---accept os.PathLike (pathlib.Path etc.) by coercing to str at the + # ---boundary; collapse the union to (str | IO[bytes]) for downstream--- + pkg_file: str | IO[bytes] = os.fspath(file) if isinstance(file, os.PathLike) else file + self.part.save(pkg_file) @property def slide_height(self) -> Length | None: @@ -157,7 +157,7 @@ def sections(self): elements into existence; the wrapping XML is created on the first ``add_section`` call. """ - from pptx.sections import _Sections + from pptx.sections import _Sections # pyright: ignore[reportPrivateUsage] return _Sections(self) @@ -202,7 +202,7 @@ def append_from( Raises |IndexError| if any value in `slide_indexes` is out of range for ``other_pres.slides``. """ - from pptx.parts.slide import _PortContext, duplicate_notes_slide_for # noqa: F401 + from pptx.parts.slide import _PortContext # pyright: ignore[reportPrivateUsage] if slide_indexes is None: source_slides = list(other_pres.slides)