From d1cb17e43fab8257bce22111984887663fa99659 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Fri, 1 May 2026 21:14:22 -0400 Subject: [PATCH 01/10] ci: add PyPI Trusted Publishing workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/publish.yml so future releases publish to PyPI automatically when a GitHub Release is published, using PyPI's OIDC Trusted Publishing instead of a long-lived API token stored as a repo secret. Workflow: - build job: checkout, build sdist+wheel, twine check, upload as workflow artifact. - publish-pypi job: downloads the artifact and uploads to PyPI via pypa/gh-action-pypi-publish, gated on the `pypi` environment with id-token write permission for OIDC. One-time setup required on PyPI side (cannot be done from this repo): 1. https://pypi.org/manage/project/python-pptx-extended/settings/publishing/ 2. Add a GitHub publisher with: owner: MHoroszowski repository: python-pptx workflow: publish.yml environment: pypi 3. (Recommended) Create a `pypi` environment in repo Settings → Environments with branch protection requiring tag-based deploys. Triggering a release after setup: - Cut a GitHub Release pointing at a vX.Y.Z tag — this fires the workflow on `release: published`. - workflow_dispatch is also wired for manual re-runs / overrides. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..2d4476e1c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: publish + +# Builds and publishes python-pptx-extended to PyPI using PyPI Trusted +# Publishing (OIDC). No long-lived API token is stored in repo secrets — the +# workflow's identity is verified by PyPI against the configured Trusted +# Publisher (see one-time setup in the PR description). +# +# Triggers: +# - GitHub Release published (recommended path: cut a release in the GH UI) +# - Manual workflow_dispatch (override / re-run) +# +# Tag pushes alone do not trigger this; create a Release pointing at the tag. + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Build sdist and wheel + 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 build tooling + run: python -m pip install --upgrade build + - name: Build distributions + run: python -m build + - name: Verify metadata renders + run: | + python -m pip install --upgrade twine + python -m twine check dist/* + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/python-pptx-extended/ + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From 1bf94bb39bf516752752932555c84b673db52de1 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 10:24:07 -0400 Subject: [PATCH 02/10] docs(plan): add customXml implementation plan Plan covers public API design (Presentation.custom_properties, Presentation.custom_xml_parts), internal architecture, phased implementation, test plan, documentation, and 8 open questions. Approved 2026-05-05; Phase 1 begins on this branch. --- Plans/customxml-implementation-plan.md | 816 +++++++++++++++++++++++++ 1 file changed, 816 insertions(+) create mode 100644 Plans/customxml-implementation-plan.md diff --git a/Plans/customxml-implementation-plan.md b/Plans/customxml-implementation-plan.md new file mode 100644 index 000000000..987045eca --- /dev/null +++ b/Plans/customxml-implementation-plan.md @@ -0,0 +1,816 @@ +# Plan: customXml part manipulation in `python-pptx-extended` + +> **Status:** proposal — awaiting principal approval before implementation begins. +> **Scope:** add first-class read/write support for the two OOXML mechanisms that +> let an application embed structured data in a `.pptx`: +> +> 1. **Custom document properties** — `/docProps/custom.xml` (visible in PowerPoint UI under *File → Properties → Advanced*). +> 2. **CustomXml data parts** — `/customXml/itemN.xml` + `/customXml/itemPropsN.xml` (hidden from end users; the mechanism Office.js, SharePoint, and VSTO use). +> +> The first consumer is a CLI that round-trips a markdown source document, but the +> public API is general-purpose: provenance metadata, AI generation markers, +> template parameters, application-specific configuration, etc. + +--- + +## 1. Context + +### Why this fork + +Mainline `scanny/python-pptx` v0.4.1 made the loader *tolerate* customXml parts +(parts no longer trip the importer when present), but never exposed an API to +read, mutate, or create them. Issues +[#286](https://github.com/scanny/python-pptx/issues/286) (custom doc properties) +and [#578](https://github.com/scanny/python-pptx/issues/578) (custom tags) have +been open and unaddressed for years. Other forks (`python-pptx-ng`, +`python-pptx-fix`, `python-pptx-fork`) inherit the same gap. + +The pattern we are porting comes from +[`python-openxml/python-docx-oss`](https://github.com/python-openxml/python-docx-oss), +which solved the equivalent problem for `.docx` (`document.custom_properties`, +`document.part.custom_xml_parts[i].add_item(...)`). We adapt that surface to +PresentationML's relationship topology — most importantly, customXml data parts +must hang off `ppt/presentation.xml.rels` (presentation-scoped), not the package +root, or Office.js will not enumerate them +([MS Q&A](https://learn.microsoft.com/en-us/answers/questions/5586825/how-to-add-a-proper-customxml-to-a-powerpoint-pres)). + +### What the existing code already gives us for free + +A short codebase survey before signature design saved a lot of plumbing work: + +| Concern | Already in the fork | Where | +|---|---|---| +| Content-type constants | `CT.OFC_CUSTOM_PROPERTIES`, `CT.OFC_CUSTOM_XML_PROPERTIES`, `CT.XML` | `src/pptx/opc/constants.py:33–34, 170` | +| Relationship-type constants | `RT.CUSTOM_PROPERTIES`, `RT.CUSTOM_XML`, `RT.CUSTOM_XML_PROPS` | `src/pptx/opc/constants.py:220–229` | +| Auto-derived `[Content_Types].xml` | `_ContentTypesItem._defaults_and_overrides` reads `part.content_type` for every part; `xml` extension defaults to `application/xml` so `customXml/itemN.xml` lands under the default with no extra wiring | `src/pptx/opc/serialized.py:280–296` | +| Part-class registration | `PartFactory.part_type_for.update({...})` at module load | `src/pptx/__init__.py:35–69` | +| Pattern for property-style XML parts | `CorePropertiesPart` + `CT_CoreProperties` — a sibling pair we can copy | `src/pptx/parts/coreprops.py`, `src/pptx/oxml/coreprops.py` | +| Package-root vs. presentation-scoped relating | `package.relate_to(part, RT.X)` writes `/_rels/.rels`; `presentation_part.relate_to(part, RT.X)` writes `/ppt/_rels/presentation.xml.rels` | `src/pptx/opc/package.py:41–51, 357–361` | +| Lazy-load with graceful re-use | `lazyproperty` + `try part_related_by(...) / except KeyError: create-and-relate` | `src/pptx/package.py:19–30` (CoreProperties pattern) | +| `xmlchemy` machinery | `BaseOxmlElement`, `ZeroOrOne`, `ZeroOrMore`, `OptionalAttribute`, `RequiredAttribute`, `register_element_cls` | `src/pptx/oxml/xmlchemy.py`, `src/pptx/oxml/__init__.py` | + +**So no changes to constants, content-type registration, or the package writer +are required.** The work is: add new oxml classes, two new part subclasses, two +new collection wrappers, hang two properties off `Presentation`, and register +two content-types in `__init__.py`. + +--- + +## 2. Public API design + +> All examples assume `prs = Presentation("input.pptx")`. `Presentation` is the +> existing `pptx.presentation.Presentation` class. + +### 2.1 `Presentation.custom_properties` — typed dict-like + +Mirrors the docx-oss `CustomProperties` API. Each property is a `` +element under `/docProps/custom.xml`; values are typed via the `vt:` namespace +(`lpwstr`, `i4`, `r8`, `bool`, `filetime`). + +```python +class CustomProperties(Mapping[str, "CustomPropertyValue"]): + """Read/write Custom document properties (visible in PowerPoint UI).""" + + def __getitem__(self, name: str) -> str | int | float | bool | datetime: ... + def __setitem__(self, name: str, value: str | int | float | bool | datetime) -> None: ... + def __delitem__(self, name: str) -> None: ... + def __contains__(self, name: object) -> bool: ... + def __iter__(self) -> Iterator[str]: ... + def __len__(self) -> int: ... + + def get(self, name: str, default=None): ... + def keys(self) -> KeysView[str]: ... + def items(self) -> ItemsView[str, "CustomPropertyValue"]: ... + def values(self) -> ValuesView["CustomPropertyValue"]: ... + + # Explicit-typed setters when the dispatch by Python type is wrong + def set_string(self, name: str, value: str) -> None: ... + def set_int(self, name: str, value: int) -> None: ... + def set_float(self, name: str, value: float) -> None: ... + def set_bool(self, name: str, value: bool) -> None: ... + def set_datetime(self, name: str, value: datetime) -> None: ... +``` + +```python +prs = Presentation("input.pptx") +prs.custom_properties["Source"] = "deck-builder-cli@1.4.2" +prs.custom_properties["GeneratedAt"] = datetime.now(timezone.utc) +prs.custom_properties["BuildNumber"] = 42 +prs.custom_properties.set_string("FreeformNotes", "anything goes here") +del prs.custom_properties["Stale"] +prs.save("output.pptx") +``` + +**Type dispatch by Python type at `__setitem__`:** + +| Python type | `vt:` element | +|---|---| +| `str` | `vt:lpwstr` | +| `bool` (checked **before** `int`) | `vt:bool` | +| `int` | `vt:i4` | +| `float` | `vt:r8` | +| `datetime.datetime` | `vt:filetime` | + +Anything else raises `TypeError`. The explicit `set_*` methods exist for the +case where the caller wants `lpwstr` *string* representations of numbers, or +where future types are added (`vt:lpstr`, `vt:r8`, etc.). + +**`fmtid` and `pid`:** every `` element requires +`fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` (the well-known Office FMTID) +and a `pid` ≥ 2 unique within the part. The collection auto-assigns `pid` (next +free integer ≥ 2). Callers never see `pid`. + +### 2.2 `Presentation.custom_xml_parts` — collection of arbitrary-XML parts + +Mirrors docx-oss `document.part.custom_xml_parts`. Each entry is a +`CustomXmlPart` paired with a `CustomXmlPropertiesPart` (its `itemPropsN.xml` +sibling carrying the `datastoreItem` GUID and any `schemaRefs`). + +```python +class CustomXmlParts(Sequence["CustomXmlPart"]): + """Collection of customXml data parts attached to the presentation.""" + + def __getitem__(self, key: int | str) -> "CustomXmlPart": + """Index by integer position OR by part name (e.g. 'item3.xml'). + + Use `.by_guid(...)` for datastoreItem-id lookup. + """ + + def __iter__(self) -> Iterator["CustomXmlPart"]: ... + def __len__(self) -> int: ... + + def by_guid(self, guid: str) -> "CustomXmlPart | None": + """Lookup by datastoreItem id (the GUID in itemPropsN.xml). Match is + case-insensitive and curly-brace-tolerant.""" + + def by_name(self, name: str) -> "CustomXmlPart | None": + """Lookup by application-assigned name. Names live in custom_properties + under a reserved `_pptx_customxml_name_` key — see §3.4.""" + + def add( + self, + xml: bytes | str | "lxml.etree._Element", + *, + name: str | None = None, + datastoreItem_id: str | None = None, + schema_refs: Iterable[str] | None = None, + scope: Literal["presentation", "package"] = "presentation", + ) -> "CustomXmlPart": + """Add a new customXml part with the given XML payload. + + Parameters + ---------- + xml + Raw XML — bytes, str, or an lxml `_Element`. Must be well-formed + XML; the caller owns the root element name and namespaces. Stored + verbatim (modulo any normalization lxml does on parse). + name + Optional application-assigned name. Stored as a custom document + property under `_pptx_customxml_name_`. See §3.4 + for why we do not use the `` attribute on `customXmlPart`. + datastoreItem_id + Optional GUID. If omitted, a new `uuid4()` is generated and wrapped + in curly braces ("{...}") to match Office's format. + schema_refs + Optional iterable of schema namespace URIs that this customXml part + claims to conform to. Written as `` + children of `` in itemProps. + scope + "presentation" (default) writes the relationship into + `ppt/_rels/presentation.xml.rels` — the topology Office.js + enumerates. "package" writes to `_rels/.rels` to match VSTO / + SharePoint patterns. The two are not exchangeable round-trip + (PowerPoint preserves the topology it was written with). + + Returns + ------- + The new `CustomXmlPart`. Already attached; nothing else to do before + `prs.save()`. + """ + + def remove(self, part: "CustomXmlPart | int | str") -> None: + """Remove the part (and its paired CustomXmlPropertiesPart) from the + package. Drops the relationship from whichever source (presentation or + package) currently owns it. Idempotent if already removed.""" +``` + +```python +class CustomXmlPart: + """A single customXml/itemN.xml + customXml/itemPropsN.xml pair.""" + + @property + def name(self) -> str | None: + """Application-assigned name from custom_properties, or None.""" + + @property + def datastoreItem_id(self) -> str: + """GUID identifying the part across edits (e.g. '{1A2B...}').""" + + @datastoreItem_id.setter + def datastoreItem_id(self, value: str) -> None: ... + + @property + def schema_refs(self) -> tuple[str, ...]: + """Tuple of `ds:schemaRef ds:uri` values from itemProps.""" + + @schema_refs.setter + def schema_refs(self, value: Iterable[str]) -> None: ... + + @property + def scope(self) -> Literal["presentation", "package"]: + """Where this part's relationship is currently rooted (read-only; + change via remove + re-add).""" + + @property + def partname(self) -> str: + """Package URI, e.g. '/customXml/item3.xml'.""" + + @property + def element(self) -> "lxml.etree._Element": + """Live root element of the customXml payload. Mutating it mutates the + part. For replace-whole-payload semantics, use `.replace_xml(...)`.""" + + @property + def blob(self) -> bytes: + """Serialized bytes of the customXml payload (with XML declaration).""" + + def replace_xml(self, xml: bytes | str | "lxml.etree._Element") -> None: + """Replace the entire payload with `xml`. The root element is + replaced, not merged. Preserves datastoreItem_id and schema_refs (those + live in the sibling itemProps part).""" + + # docx-oss compatibility shim — only present if we adopt it (see §8 Q1) + def add_item(self, tag: str, text: str = "", **attrs: str) -> "lxml.etree._Element": + """Append a child element `text` with attributes. Returns the + appended element. Convenience for the common "flat list of items" + shape; for arbitrary structure use `.element` directly.""" +``` + +```python +import uuid +prs = Presentation("input.pptx") + +# General case — arbitrary XML +part = prs.custom_xml_parts.add( + b""" + + deck-builder-cli + 2026-05-05T14:00:00Z + """, + name="provenance", + schema_refs=["urn:my-app:provenance"], +) +print(part.datastoreItem_id) # auto-assigned GUID + +# Lookup +same = prs.custom_xml_parts.by_name("provenance") +assert same is part +also_same = prs.custom_xml_parts.by_guid(part.datastoreItem_id) + +# Mutate +same.element.find("{urn:my-app:provenance}source").text = "deck-builder-cli@1.4.3" + +prs.save("output.pptx") +``` + +### 2.3 String-blob helper — the primary use case + +Most callers want "stash this string verbatim, give it back to me on read." +Wrapping it in a one-element XML envelope keeps it valid OOXML and lets the +mime hint round-trip: + +```python +def add_string_blob( + self, + name: str, + content: str, + *, + mime_hint: str | None = None, + encoding: Literal["text", "base64"] = "text", + scope: Literal["presentation", "package"] = "presentation", +) -> "CustomXmlPart": + """Embed a string payload as a customXml part. + + Wraps `content` in: + ... + + For binary or non-XML-safe text, set encoding="base64" and pass already- + encoded content (the helper does NOT auto-base64; the caller is + responsible). Round-trip: read with `.element.text` or via the helper + `read_string_blob(name)`.""" + +def read_string_blob(self, name: str) -> str | None: + """Return content of the blob part with `name`, or None if not present. + If encoding='base64', returns the still-encoded string — the caller + decodes.""" +``` + +The `urn:python-pptx:blob` envelope namespace is reserved for this fork's +helpers. Callers using `.add(...)` directly are free to use any namespace they +want. + +### 2.4 Property accessors on `Presentation` + +Two new properties on `pptx.presentation.Presentation`: + +```python +@property +def custom_properties(self) -> CustomProperties: + """CustomProperties instance for /docProps/custom.xml. Created on first + access if the part does not yet exist (consistent with .core_properties). + """ + +@property +def custom_xml_parts(self) -> CustomXmlParts: + """Collection of customXml data parts. Always returns the same collection + instance for a given Presentation.""" +``` + +Both delegate through `self.part` to the `PresentationPart`, which owns the +lazy-loaded helpers — same pattern as `core_properties`. + +--- + +## 3. Internal architecture + +### 3.1 New files + +| Path | Purpose | +|---|---| +| `src/pptx/parts/custom_properties.py` | `CustomPropertiesPart(XmlPart)` — `/docProps/custom.xml` | +| `src/pptx/parts/custom_xml.py` | `CustomXmlPart(XmlPart)`, `CustomXmlPropertiesPart(XmlPart)` | +| `src/pptx/oxml/custom_properties.py` | `CT_CustomProperties`, `CT_Property`, value-type element classes | +| `src/pptx/oxml/custom_xml.py` | `CT_DatastoreItem`, `CT_DatastoreSchemaRef` | +| `src/pptx/custom_properties.py` | `CustomProperties` (Mapping wrapper) | +| `src/pptx/custom_xml.py` | `CustomXmlParts` (Sequence wrapper), `CustomXmlPart` user-facing facade | + +Layering rationale (matches the rest of the codebase): + +- `oxml/*` — pure XML element classes; no relationship logic; xmlchemy types only. +- `parts/*` — `XmlPart` subclasses; own a single `_element`; `lazyproperty` + helpers but no end-user collections. +- `custom_properties.py`, `custom_xml.py` (top-level) — user-facing wrappers + (Mapping/Sequence) that the principal hangs off `Presentation`. Mirrors how + `pptx/slide.py` (`Slides`, `SlideMasters`) lives next to `pptx/presentation.py`. + +### 3.2 Modified files + +| Path | Change | Rationale | +|---|---|---| +| `src/pptx/__init__.py` | Add three rows to `content_type_to_part_class_map` (CT.OFC_CUSTOM_PROPERTIES, CT.OFC_CUSTOM_XML_PROPERTIES, and CT.XML → CustomXmlPart **only when partname matches `/customXml/item*.xml`**, see §3.6) | Register part subclasses with the factory | +| `src/pptx/presentation.py` | Add `custom_properties` and `custom_xml_parts` properties | User-facing surface | +| `src/pptx/parts/presentation.py` | Add `custom_properties` lazyproperty, `custom_xml_parts` lazyproperty, helper for "find or create" the parts under the right relationship scope | Where the part-graph wiring lives | +| `src/pptx/package.py` | Add `custom_properties` lazyproperty (mirrors `core_properties`) — package-root scope is correct for `/docProps/custom.xml` per OOXML convention | Package-root relating | +| `src/pptx/oxml/__init__.py` | `register_element_cls(...)` calls for the new oxml classes | Standard registration | +| `src/pptx/types.py` | (Optional) `CustomPropertyValue` type alias for the union | Keep public `__init__.py` clean | +| `pyproject.toml` / `HISTORY.rst` | Bump minor version, log change | Release hygiene | + +**No changes** to `src/pptx/opc/constants.py`, `src/pptx/opc/serialized.py`, +`src/pptx/opc/package.py`, or `src/pptx/opc/spec.py`. The constants and content +types we need are already there; the writer auto-derives content types per +part. + +### 3.3 Content type and relationship plumbing — no new constants + +Verified by reading `src/pptx/opc/constants.py:33–34, 170, 220–229`: + +```text +CT.OFC_CUSTOM_PROPERTIES = "application/vnd.openxmlformats-officedocument.custom-properties+xml" +CT.OFC_CUSTOM_XML_PROPERTIES = "application/vnd.openxmlformats-officedocument.customXmlProperties+xml" +CT.XML = "application/xml" +RT.CUSTOM_PROPERTIES = ".../custom-properties" +RT.CUSTOM_XML = ".../customXml" +RT.CUSTOM_XML_PROPS = ".../customXmlProps" +``` + +`_ContentTypesItem._defaults_and_overrides` (`opc/serialized.py:280–296`) reads +each part's `.content_type` and emits Default-or-Override entries automatically. +Since the `xml` extension already maps to `application/xml` in the default dict, +`/customXml/itemN.xml` (content_type `application/xml`) needs no Override +(Office writes the same way). `/customXml/itemPropsN.xml` becomes an Override +because its content type differs from `application/xml`. `/docProps/custom.xml` +becomes an Override (custom-properties+xml). + +### 3.4 Custom-name storage decision + +The OOXML spec does **not** define a "name" attribute on a customXml part. +docx-oss's `add_item` stores tags as XML elements; lookup by name there is by +the *element tag*, not the part. We need part-level naming for +`custom_xml_parts.by_name("provenance")`. + +**Two options:** + +- **(Chosen, default plan)** Store names as a custom document property keyed by + the part's `datastoreItem_id`: `_pptx_customxml_name_{guid}` → `name`. + Lossless, round-trips through PowerPoint, no schema invention. Cost: every + `add(name=...)` also touches `/docProps/custom.xml`. +- (Rejected) Add a `` child to `itemProps` with a custom + attribute. Office tolerates it but other tools may strip it; not portable. + +**Open question Q3 in §8** — confirm the chosen approach before coding. + +### 3.5 Relationship topology — default and override + +| Part | Default scope | Source rels file | Override flag | Why | +|---|---|---|---|---| +| `CustomPropertiesPart` (`/docProps/custom.xml`) | package-root | `/_rels/.rels` | none | Office writes it here; sibling of `core.xml` | +| `CustomXmlPart` (`/customXml/itemN.xml`) | presentation-scoped | `/ppt/_rels/presentation.xml.rels` | `scope="package"` on `add(...)` | Office.js / PowerPoint UI only enumerate presentation-scoped customXml | +| `CustomXmlPropertiesPart` (`/customXml/itemPropsN.xml`) | from its CustomXmlPart | `/customXml/_rels/itemN.xml.rels` (always) | n/a | Always a child of the data part | + +The `scope` parameter on `CustomXmlParts.add` is the override hatch. Round-trip +test fixtures in §5.3 cover both topologies. + +### 3.6 PartFactory ambiguity around `application/xml` + +`PartFactory` keys on `content_type` alone. But `/customXml/itemN.xml` has +content_type `application/xml`, which is also the catch-all for unrelated XML +parts. Two options: + +- **(Chosen)** Register `CT.XML → Part` (the base class — the existing default + behavior) and **resolve `CustomXmlPart` by partname pattern** at the + `PresentationPart.custom_xml_parts` level: enumerate `RT.CUSTOM_XML` + relationships, wrap each `target_part` in a `CustomXmlPart` facade *if not + already*. The facade carries the `_element` reference and writes through. No + new factory ambiguity. +- (Rejected) Subclass `Part` and re-resolve at load time. Risks promoting + unrelated `application/xml` parts to `CustomXmlPart` and breaking unrelated + XML-typed parts in third-party PPTX files. + +Note: `CustomXmlPropertiesPart` has its own dedicated content type +(`OFC_CUSTOM_XML_PROPERTIES`) so it registers normally with the factory; only +the data part is ambiguous. + +### 3.7 Loading existing customXml parts + +`OpcPackage._load` already walks every `.rels` file and constructs a `Part` for +every targeted partname (`opc/package.py:240–278`). With `CT.OFC_CUSTOM_XML_PROPERTIES` +mapped to `CustomXmlPropertiesPart` and `CT.OFC_CUSTOM_PROPERTIES` mapped to +`CustomPropertiesPart` in `__init__.py`, those load automatically. The data +part loads as a base `Part` (or `XmlPart` if we pre-register `CT.XML` to +`XmlPart` — TBD; see Q4). The `CustomXmlParts` collection finds them by walking +`RT.CUSTOM_XML` relationships from both the package and the presentation part. + +This means files saved by SharePoint, Office.js, or VSTO will round-trip +without code changes — we only need to *enumerate* both relationship sources, +which §3.5 already accounts for. + +--- + +## 4. Compatibility & migration + +### 4.1 Backward compatibility + +- **No public API removed or renamed.** Two new properties on `Presentation`, + one new property on `Package`, and four new module files. +- **No change to `[Content_Types].xml` for files that do not use the new + features.** A presentation produced by code that never touches + `prs.custom_properties` or `prs.custom_xml_parts` writes byte-equivalent + output (modulo any unrelated changes). +- **PPTX files containing customXml parts written by other tools** load today + thanks to mainline's v0.4.1 hotfix; this PR just makes them visible. No + loader regressions expected — verified by the fixture matrix in §5.3. + +### 4.2 Consistency with existing property APIs + +`custom_properties` is intentionally shaped to mirror `core_properties`: + +| Aspect | `core_properties` | `custom_properties` | +|---|---|---| +| Lazy-create on first access | yes | yes | +| Surface on | `Presentation` and `Package` | `Presentation` and `Package` | +| Underlying part subclass | `CorePropertiesPart` | `CustomPropertiesPart` | +| Per-element setters/getters | typed properties | dict-like, type-dispatched | + +The dict-like shape diverges because custom properties are user-keyed, not a +fixed Dublin Core vocabulary. This is the same divergence docx-oss made. + +### 4.3 Slide / shape scope — explicitly deferred + +PresentationML has a third mechanism: per-slide and per-shape custom data via +``, where each `tag` +relationship targets a `tags+xml` part (`CT.PML_TAGS`). This is the mechanism +issue [#578](https://github.com/scanny/python-pptx/issues/578) asks for, and +what `singerla/pptx-automizer` exposes. + +This PR does **not** add per-slide or per-shape tag APIs. Reasons: + +1. The first consumer (markdown round-trip) wants presentation-scoped data, not + per-slide. +2. Per-shape `custDataLst` is plumbed differently (slide-rels, not + presentation-rels) and deserves its own PR with its own API surface. +3. Keeping this PR small reduces review surface and lets the + presentation-scoped code stabilize first. + +A follow-up PR (referenced in §6) will add `Slide.custom_xml_parts` / +`Shape.custom_xml_parts` once this lands. + +--- + +## 5. Testing strategy + +### 5.1 Unit tests — oxml classes + +**Pattern:** copy `tests/oxml/test_*.py` style. Pure XML in / XML out, no I/O. + +| File | Coverage | +|---|---| +| `tests/oxml/test_custom_properties.py` | `CT_CustomProperties` (root), `CT_Property` (each ``), value-type elements (`vt:lpwstr`, `vt:i4`, `vt:bool`, `vt:filetime`, `vt:r8`). Round-trip parse → mutate → serialize. | +| `tests/oxml/test_custom_xml.py` | `CT_DatastoreItem` and `CT_DatastoreSchemaRef`. Verify `itemID` GUID format, schema-ref add/remove. | + +### 5.2 Unit tests — parts and collection wrappers + +| File | Coverage | +|---|---| +| `tests/parts/test_custom_properties.py` | `CustomPropertiesPart.default(...)`, getter/setter type dispatch, deletion, pid auto-assignment. Uses synthetic XML fixtures the way `tests/parts/test_coreprops.py:1–198` does. | +| `tests/parts/test_custom_xml.py` | `CustomXmlPart.replace_xml(...)`, `datastoreItem_id` round-trip, `schema_refs` add/remove, paired itemProps part lifecycle (add data → itemProps auto-created; remove data → itemProps removed). | +| `tests/test_custom_properties.py` | `CustomProperties` Mapping protocol — `__getitem__`, `__setitem__`, `__delitem__`, `__contains__`, `__iter__`, `__len__`, type dispatch, `set_string`/etc., raises on unknown type. | +| `tests/test_custom_xml.py` | `CustomXmlParts` — `add(...)`, `remove(...)`, `by_name`, `by_guid`, indexing, scope=package vs presentation, name-storage in custom_properties. | + +### 5.3 Integration tests — full-package round-trip + +Place fixtures under `tests/test_files/customxml/` (new directory). Each fixture +is a real `.pptx` from a different ecosystem; round-trip = open + save + open + +diff payload. + +| Fixture | Origin | What it proves | +|---|---|---| +| `sharepoint-saved.pptx` | A presentation saved through SharePoint with VSTO-injected customXml at *package* scope | Loader handles package-root `RT.CUSTOM_XML` and we round-trip without dropping it | +| `officejs-added.pptx` | Office.js `addCustomXmlPart` output (presentation scope) | The "happy path" — Office.js semantics | +| `vsto-document-toolkit.pptx` | A VSTO-tooled deck with `ds:itemID` schema refs | `schema_refs` survive | +| `manual-multipart.pptx` | Hand-crafted with two customXml items + custom properties | N>1 handling | +| `our-output.pptx` | Generated by the test itself using the new API | Sanity check | + +Tests: + +1. `test_round_trip_preserves_payload` — open, save, re-open; assert + `custom_xml_parts[i].blob == original_blob` byte-for-byte (modulo lxml + re-serialization normalization, which is deterministic). +2. `test_round_trip_preserves_topology` — assert package-scope fixtures still + relate from package root after save; presentation-scope fixtures still relate + from presentation rels after save. +3. `test_load_with_no_customxml_unchanged` — open a PPTX with no customXml, + touch nothing, save; assert byte-equivalent (or content-types/rels are at + least set-equal — see Q5). +4. `test_core_properties_unaffected` — open a PPTX, set both + `core_properties.author` and `custom_properties["foo"]`; save; re-open; + assert both round-trip. + +### 5.4 Manual verification — PowerPoint UI + +The integration test plan does not — and cannot — verify that PowerPoint itself +considers the output legal. Add a manual checklist to the PR description: + +- [ ] Open `our-output.pptx` in PowerPoint 365 (Mac and Windows). No repair + prompt. +- [ ] *File → Properties → Advanced* shows the custom properties. +- [ ] Open in LibreOffice. Document the behavior (LibreOffice preserves + package-root customXml but historically strips presentation-scoped data + parts). +- [ ] Open in OnlyOffice / DocumentServer. Document. (See + [ONLYOFFICE/DocumentServer#1564](https://github.com/ONLYOFFICE/DocumentServer/issues/1564) + for known gaps.) + +The doc page (§6.2) records what we observed so users have realistic +expectations. + +### 5.5 Coverage target + +Match the project's existing standard (≥95% line coverage on new modules per +`pyproject.toml`). Run with `tox -e py311` (existing tox config). + +--- + +## 6. Documentation + +### 6.1 User guide page — `docs/user/custom-xml.rst` + +Style: match the other `docs/user/*.rst` pages (`presentations.rst`, +`notes.rst`). Sections: + +1. Overview — when to use custom doc properties vs. customXml data parts. +2. Reading and writing custom document properties (with the full type table). +3. Reading and writing customXml data parts (with the string-blob example *and* + the arbitrary-XML example). +4. Round-trip caveats — what PowerPoint preserves, what LibreOffice / OnlyOffice + may strip. (Reference §5.4 manual matrix.) +5. Choosing the relationship scope (default vs. `scope="package"` and why). + +### 6.2 Dev analysis page — `docs/dev/analysis/customxml.rst` + +Match the existing `docs/dev/analysis/*` style (one analysis per OOXML feature, +ECMA-376 references, sample XML, schema diagrams in ASCII). Sections: + +1. ECMA-376 references — Part 1 §15.2.4 (Custom XML Data Storage Part) and + §15.2.12 (Custom File Properties Part). +2. Sample XML for `/docProps/custom.xml`, `/customXml/item1.xml`, + `/customXml/itemProps1.xml`. +3. Relationship topology diagram — package vs. presentation scope. +4. Why the well-known FMTID is fixed. +5. The `application/xml` content-type ambiguity and how `python-pptx-extended` + resolves it (§3.6). +6. The `_pptx_customxml_name_` storage convention (§3.4). + +### 6.3 API reference + +Add `docs/api/custom_properties.rst` and `docs/api/custom_xml.rst` with the +auto-doc directives. Update `docs/api/presentation.rst` to mention the two new +properties. + +### 6.4 HISTORY.rst + +A 1.2.0 entry summarizing the feature (the fork's version-bump pattern from +`Plans/review-the-guide-at-swift-kahn.md`). + +--- + +## 7. Phased implementation order + +The phases below assume a dedicated `feature/customxml` branch (matches the +`feature/*` branch convention in `git log`). + +### Phase 1 — oxml foundation (no public API) + +- `src/pptx/oxml/custom_properties.py` — `CT_CustomProperties`, `CT_Property`, + value-type elements. +- `src/pptx/oxml/custom_xml.py` — `CT_DatastoreItem`, `CT_DatastoreSchemaRef`. +- `src/pptx/oxml/__init__.py` — register element classes. +- Tests: `tests/oxml/test_custom_properties.py`, `tests/oxml/test_custom_xml.py`. + +**Deliverable:** new oxml classes parse and serialize round-trip. No +behavior change for callers. + +### Phase 2 — Part subclasses + +- `src/pptx/parts/custom_properties.py` — `CustomPropertiesPart` with the + `default` factory and per-element accessors. +- `src/pptx/parts/custom_xml.py` — `CustomXmlPart`, `CustomXmlPropertiesPart`, + with paired-creation logic (`new_pair(package, ...)`). +- `src/pptx/__init__.py` — register the new content types in + `content_type_to_part_class_map`. +- Tests: `tests/parts/test_custom_properties.py`, `tests/parts/test_custom_xml.py`. + +**Deliverable:** parts load and save correctly when present in a PPTX file. +Still no Presentation-level surface. + +### Phase 3 — Public collections and Presentation hooks + +- `src/pptx/custom_properties.py` — `CustomProperties` mapping wrapper. +- `src/pptx/custom_xml.py` — `CustomXmlParts` sequence wrapper. +- `src/pptx/parts/presentation.py` — lazyproperties to expose them. +- `src/pptx/package.py` — `custom_properties` lazyproperty. +- `src/pptx/presentation.py` — `custom_properties` and `custom_xml_parts` + properties. +- Tests: `tests/test_custom_properties.py`, `tests/test_custom_xml.py`, + `tests/test_presentation.py` additions. + +**Deliverable:** end-to-end usage works against the synthetic test fixtures. + +### Phase 4 — String-blob helper and integration tests + +- `add_string_blob` / `read_string_blob` on `CustomXmlParts`. +- `tests/test_files/customxml/` fixture set (§5.3). +- `tests/integration/test_customxml_roundtrip.py` — end-to-end open-save-reopen. + +**Deliverable:** the immediate use case (markdown blob round-trip) is +exercisable from a CLI. + +### Phase 5 — Documentation and release + +- `docs/user/custom-xml.rst`, `docs/dev/analysis/customxml.rst`, API ref pages. +- `HISTORY.rst` entry, `pyproject.toml` version bump (e.g. `1.1.0` → `1.2.0`). +- Manual PowerPoint UI matrix (§5.4) executed and recorded in the PR + description. +- Tag and publish (matches the trusted-publishing workflow on the current + branch). + +**Deliverable:** PR ready for principal review. + +### Critical-path dependencies + +``` +Phase 1 ──▶ Phase 2 ──▶ Phase 3 ──▶ Phase 4 ──▶ Phase 5 + (Phase 2 depends on Phase 1's element classes) + (Phase 3 depends on Phase 2's parts) + (Phase 4 depends on Phase 3's public API) +``` + +Phases 1–3 are testable in isolation. Phase 4's fixtures need real third-party +PPTX files, which is why integration tests come last (and partially gate manual +verification, §5.4). + +--- + +## 8. Open questions and decisions for the principal + +Numbered for easy reference. Defaults shown so that, if the principal is +indifferent, the plan is unblocked. + +**Q1. `add_item(tag, text, **attrs)` shim?** +docx-oss exposes this convenience on `CustomXmlPart`. Useful for "flat list of +items" callers; redundant for callers using `.element` directly. +*Default:* **include it** — low cost, parity with docx-oss, keeps the +"learn-once" surface across the python-openxml family. + +**Q2. Distribution name and version.** +Per `Plans/review-the-guide-at-swift-kahn.md`, the fork ships as +`python-pptx-extended` on PyPI. This feature warrants a minor bump (`1.1.0` → +`1.2.0`). +*Default:* **`python-pptx-extended==1.2.0`.** Confirm if you'd rather batch this +with other unreleased fork features instead of a dedicated release. + +**Q3. Custom-name storage mechanism (§3.4).** +*Default:* **store names as a reserved custom document property keyed by +datastoreItem GUID** (`_pptx_customxml_name_`). Lossless, round-trips +through PowerPoint. +*Alternative:* skip name-based lookup entirely; require the caller to track +GUIDs themselves. This is what docx-oss does (no `by_name` on +`custom_xml_parts`). Smaller API, but the markdown-round-trip CLI use case +clearly wants a name. + +**Q4. Should `CustomXmlPart` register against `CT.XML`?** +*Default:* **No** — leave `CT.XML` mapping to base `Part` and let the +`CustomXmlParts` collection wrap-on-enumerate (§3.6). Avoids accidentally +upgrading unrelated `application/xml` parts. +*Alternative:* register it and accept the broader scope. Easier loader code, +risk of false positives. + +**Q5. Byte-exact preservation of files we do not modify.** +The integration test plan compares payloads, not byte streams (§5.3 test 3). +lxml re-serialization can change attribute order, whitespace, and XML +declaration form even when content is identical. +*Default:* **assert content equivalence (parsed AST equal), not byte +equivalence.** Match scanny upstream's posture. +*Alternative:* invest in a custom serializer that preserves original byte form +for unmodified parts. Significant scope creep; not recommended for this PR. + +**Q6. `Slide.custom_xml_parts` / `Shape.custom_xml_parts`.** +*Default:* **out of scope for this PR** (§4.3). Will be a follow-up that +covers `` and the slide-rels-rooted topology — issue +#578. +Confirm you agree with deferring this; if you'd rather have one big PR, the +estimate roughly doubles in size (more parts, slide-rels handling, per-shape +API design questions). + +**Q7. License headers / attribution to docx-oss.** +The pattern is original to docx-oss (BSD-licensed). MIT (this fork) is +compatible. +*Default:* **add a one-line attribution at the top of `custom_xml.py` and +`custom_properties.py`** noting the docx-oss inspiration with a URL. No code is +copied verbatim; only the API shape is borrowed. + +**Q8. Versioning of the generated XML (e.g. provenance metadata about +*python-pptx-extended* itself).** +Some tools stamp the output with a generator hint (e.g. +``). Easy to add to the +string-blob envelope. +*Default:* **don't add this.** Keep the helper minimal; callers who want +provenance write it themselves. + +--- + +## 9. Summary of scope boundaries + +**In scope (this PR):** + +- Read/write/create custom document properties (`/docProps/custom.xml`). +- Read/write/create customXml data parts (`/customXml/itemN.xml` + + `/customXml/itemPropsN.xml`) at presentation scope (default) or package + scope (override). +- String-blob helper for the immediate use case. +- Round-trip safety with files written by SharePoint, Office.js, and VSTO. +- Documentation matching the project's user-guide and analysis-page styles. + +**Out of scope (this PR — explicit):** + +- Per-slide custom data (``) — issue #578, follow-up PR. +- Per-shape custom data — same follow-up. +- Office.js-style schema validation (we accept `schema_refs` as opaque URIs + but do not validate payloads against any schema). +- Content controls / structured document tags (SDT) bound to customXml — that's + a wordprocessingML feature anyway. +- Byte-perfect preservation of files we do not modify (Q5). +- Auto-encoding for `add_string_blob(encoding="base64")` — caller pre-encodes. +- Cross-filesystem name uniqueness checks — `add(name="x")` does not raise if + another part already has name `"x"`; the principal manages namespacing. + +--- + +## 10. Acceptance — what the principal sees on PR open + +1. Branch `feature/customxml` against `ci/pypi-trusted-publishing` (or + wherever the principal points). +2. ~30 new test cases, ~95% line coverage on the new modules. +3. ~4 fixtures under `tests/test_files/customxml/` documenting the third-party + topologies. +4. `docs/user/custom-xml.rst` + `docs/dev/analysis/customxml.rst` rendering on + ReadTheDocs. +5. PR description with the manual-verification matrix from §5.4 filled in. +6. `HISTORY.rst` entry under a new `1.2.0` heading. +7. Examples in the user guide that exercise both the immediate (markdown blob) + and the general (arbitrary XML) cases. + +--- + +*Plan author: Athena, on behalf of Matthew Horoszowski. Awaiting principal +approval before Phase 1 begins.* From 9ad27bab830b6d8a2adacb4a4c7392a20f5eb234 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 10:36:30 -0400 Subject: [PATCH 03/10] feat(oxml): add custom_properties and custom_xml element classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of customXml support per Plans/customxml-implementation-plan.md. Adds the leaf-layer xmlchemy element classes for two OOXML mechanisms: - /docProps/custom.xml — , , and the five typed value elements (lpwstr, i4, r8, bool, filetime). CT_Property.value dispatches Python type to vt:* child; bool checked before int because bool is-a int in Python. - /customXml/itemPropsN.xml — , , . CT_DatastoreItem.add_schema_ref / remove_schema_ref manage the optional schema_refs envelope idempotently and drop the empty parent on last-removal to match Office output. Three new namespace prefixes registered in oxml/ns.py: op -> custom-properties vt -> docPropsVTypes ds -> customXml ZeroOrMore declaration on CT_Properties is named 'prop' rather than 'property' to avoid shadowing Python's @property decorator inside the class body. Public methods preserve *_property naming on the API surface. 63 new unit tests; 96-98% line coverage on the new modules. Existing 2796-test suite still green (2859 total). --- src/pptx/oxml/__init__.py | 30 +++ src/pptx/oxml/custom_properties.py | 321 +++++++++++++++++++++++++++ src/pptx/oxml/custom_xml.py | 119 ++++++++++ src/pptx/oxml/ns.py | 3 + tests/oxml/test_custom_properties.py | 272 +++++++++++++++++++++++ tests/oxml/test_custom_xml.py | 129 +++++++++++ 6 files changed, 874 insertions(+) create mode 100644 src/pptx/oxml/custom_properties.py create mode 100644 src/pptx/oxml/custom_xml.py create mode 100644 tests/oxml/test_custom_properties.py create mode 100644 tests/oxml/test_custom_xml.py diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 4fe208a50..fc216a493 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -217,6 +217,36 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): register_element_cls("cp:coreProperties", CT_CoreProperties) +from pptx.oxml.custom_properties import ( # noqa: E402 + CT_Properties, + CT_Property, + CT_VtBool, + CT_VtFiletime, + CT_VtI4, + CT_VtLpwstr, + CT_VtR8, +) + +register_element_cls("op:Properties", CT_Properties) +register_element_cls("op:property", CT_Property) +register_element_cls("vt:bool", CT_VtBool) +register_element_cls("vt:filetime", CT_VtFiletime) +register_element_cls("vt:i4", CT_VtI4) +register_element_cls("vt:lpwstr", CT_VtLpwstr) +register_element_cls("vt:r8", CT_VtR8) + + +from pptx.oxml.custom_xml import ( # noqa: E402 + CT_DatastoreItem, + CT_DatastoreSchemaRef, + CT_DatastoreSchemaRefs, +) + +register_element_cls("ds:datastoreItem", CT_DatastoreItem) +register_element_cls("ds:schemaRef", CT_DatastoreSchemaRef) +register_element_cls("ds:schemaRefs", CT_DatastoreSchemaRefs) + + from pptx.oxml.dml.color import ( # noqa: E402 CT_Color, CT_HslColor, diff --git a/src/pptx/oxml/custom_properties.py b/src/pptx/oxml/custom_properties.py new file mode 100644 index 000000000..9f7b8c630 --- /dev/null +++ b/src/pptx/oxml/custom_properties.py @@ -0,0 +1,321 @@ +"""lxml custom element classes for the Custom Document Properties part. + +Models `/docProps/custom.xml` — the `` root and its `` +children, each carrying one of five typed `` value elements. + +Schema references: ECMA-376 Part 1, §15.2.12.2 (Custom File Properties Part). +""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +from lxml.etree import _Element # pyright: ignore[reportPrivateUsage] + +from pptx.oxml import parse_xml +from pptx.oxml.ns import nsdecls, qn +from pptx.oxml.simpletypes import XsdString, XsdUnsignedInt +from pptx.oxml.xmlchemy import ( + BaseOxmlElement, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) + +# Well-known FMTID Office writes on every user-defined custom property. +DEFAULT_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" + +# pid values 0 and 1 are reserved by the OOXML spec; user properties start at 2. +_FIRST_PID = 2 + +# Maximum string length for an lpwstr value. Office-tested limit; longer values +# round-trip but are reported by some inspectors as malformed. +_LPWSTR_MAX_LEN = 255 + + +class CT_Properties(BaseOxmlElement): + """`` element, root of `/docProps/custom.xml`. + + The xmlchemy declaration is named `prop` rather than `property` because the + latter would shadow Python's built-in `@property` decorator inside the + class body — see metaclass-walk in `xmlchemy.py:120-131`. Public methods + below preserve the `*_property` naming on the user-facing surface. + """ + + prop = ZeroOrMore("op:property", successors=()) + + _properties_tmpl = "\n" % nsdecls("op", "vt") + + @staticmethod + def new_properties() -> "CT_Properties": + """Return a new empty `` element with op + vt namespaces.""" + return cast("CT_Properties", parse_xml(CT_Properties._properties_tmpl)) + + @property + def property_lst(self) -> "list[CT_Property]": + """List of `` children in document order.""" + return cast("list[CT_Property]", self.prop_lst) + + def add_property(self, name: str, value: object) -> "CT_Property": + """Append a new `` child for `(name, value)`. + + The pid is auto-assigned to the next free integer ≥ 2 within this + collection. Dispatches `value` by Python type to choose the `` + child. Raises `TypeError` if `value` is not one of the supported types + (see `CT_Property.value` for the dispatch table). + """ + prop = cast("CT_Property", self._add_prop()) + prop.fmtid = DEFAULT_FMTID + prop.pid = self._next_pid() + prop.name = name + prop.value = value + return prop + + def get_property(self, name: str) -> "CT_Property | None": + """Return the `` child whose `name` attribute is `name`. + + Returns `None` if no such child exists. Match is case-sensitive — Office + treats names case-sensitively even though Windows file names elsewhere + do not. + """ + for prop in self.property_lst: + if prop.name == name: + return prop + return None + + def remove_property(self, name: str) -> bool: + """Remove the `` child with `name`, returning True if found.""" + prop = self.get_property(name) + if prop is None: + return False + self.remove(prop) + return True + + @property + def property_names(self) -> tuple[str, ...]: + """Tuple of `name` attributes for every `` child, in order.""" + return tuple(p.name for p in self.property_lst) + + def _next_pid(self) -> int: + """Return the next free pid (≥ 2) not yet used by any child.""" + used = {p.pid for p in self.property_lst if p.has_pid} + candidate = _FIRST_PID + while candidate in used: + candidate += 1 + return candidate + + +class CT_Property(BaseOxmlElement): + """`` element — one custom document property entry.""" + + fmtid: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "fmtid", XsdString + ) + pid: int = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "pid", XsdUnsignedInt + ) + name: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "name", XsdString + ) + + lpwstr = ZeroOrOne("vt:lpwstr", successors=()) + i4 = ZeroOrOne("vt:i4", successors=()) + r8 = ZeroOrOne("vt:r8", successors=()) + bool_ = ZeroOrOne("vt:bool", successors=()) + filetime = ZeroOrOne("vt:filetime", successors=()) + + @property + def has_pid(self) -> bool: + """True if the `pid` attribute is present (it is required, but parsing + a malformed file can leave it unset; this guards `_next_pid` against + crashing on partial input).""" + return self.get("pid") is not None + + @property + def value(self) -> str | int | float | bool | dt.datetime | None: + """The Python-typed value of whichever `` child is present. + + Returns `None` if no value child exists (a malformed but tolerated state). + Order of precedence on read: lpwstr, i4, r8, bool, filetime — only one + is expected to be present per the spec. + """ + for child in (self.lpwstr, self.i4, self.r8, self.bool_, self.filetime): + if child is not None: + return cast("_VtValueElement", child).value_typed + return None + + @value.setter + def value(self, new_value: object) -> None: + """Replace the current `` child with one matching `new_value`'s type. + + Dispatch table (bool checked BEFORE int because `bool` is a subclass of + `int` in Python): + + bool -> + int -> + float -> + str -> + datetime.datetime -> + + Other types raise `TypeError`. + """ + # Remove any existing value child before adding the new one. + for tagname in ("vt:lpwstr", "vt:i4", "vt:r8", "vt:bool", "vt:filetime"): + for elem in self.findall(qn(tagname)): + self.remove(elem) + + if isinstance(new_value, bool): + child = cast("CT_VtBool", self.get_or_add_bool_()) + child.value_typed = new_value + elif isinstance(new_value, int): + child = cast("CT_VtI4", self.get_or_add_i4()) + child.value_typed = new_value + elif isinstance(new_value, float): + child = cast("CT_VtR8", self.get_or_add_r8()) + child.value_typed = new_value + elif isinstance(new_value, str): + child = cast("CT_VtLpwstr", self.get_or_add_lpwstr()) + child.value_typed = new_value + elif isinstance(new_value, dt.datetime): + child = cast("CT_VtFiletime", self.get_or_add_filetime()) + child.value_typed = new_value + else: + raise TypeError( + "custom property value must be bool, int, float, str, or datetime; " + "got %s" % type(new_value).__name__ + ) + + +class _VtValueElement(BaseOxmlElement): + """Mixin-style base for `` typed value elements. + + Subclasses define a `value_typed` property that round-trips the element's + text content to/from a Python value. + """ + + value_typed: object # pyright: ignore[reportUninitializedInstanceVariable] + + +class CT_VtLpwstr(_VtValueElement): + """`` — Unicode string value.""" + + @property + def value_typed(self) -> str: + return self.text or "" + + @value_typed.setter + def value_typed(self, value: str) -> None: + if not isinstance(value, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("vt:lpwstr value must be str, got %s" % type(value).__name__) + if len(value) > _LPWSTR_MAX_LEN: + raise ValueError( + "vt:lpwstr value exceeds %d-character limit" % _LPWSTR_MAX_LEN + ) + self.text = value + + +class CT_VtI4(_VtValueElement): + """`` — 32-bit signed integer value.""" + + _MIN = -2147483648 + _MAX = 2147483647 + + @property + def value_typed(self) -> int: + text = self.text + if text is None: + raise ValueError("vt:i4 element has no text content") + return int(text) + + @value_typed.setter + def value_typed(self, value: int) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("vt:i4 value must be int, got %s" % type(value).__name__) + if value < self._MIN or value > self._MAX: + raise ValueError( + "vt:i4 value out of range [%d, %d]: %d" % (self._MIN, self._MAX, value) + ) + self.text = str(value) + + +class CT_VtR8(_VtValueElement): + """`` — IEEE-754 double-precision float value.""" + + @property + def value_typed(self) -> float: + text = self.text + if text is None: + raise ValueError("vt:r8 element has no text content") + return float(text) + + @value_typed.setter + def value_typed(self, value: float) -> None: + if isinstance(value, bool): + raise TypeError("vt:r8 value must be float, got bool") + if not isinstance(value, (int, float)): + raise TypeError("vt:r8 value must be a number, got %s" % type(value).__name__) + self.text = repr(float(value)) + + +class CT_VtBool(_VtValueElement): + """`` — boolean value. + + Reads accept `"1"`, `"0"`, `"true"`, `"false"` (case-insensitive). Writes + emit `"true"` or `"false"` to match what Microsoft Office produces. + """ + + @property + def value_typed(self) -> bool: + text = (self.text or "").strip().lower() + if text in ("true", "1"): + return True + if text in ("false", "0"): + return False + raise ValueError("vt:bool element has invalid text content: %r" % self.text) + + @value_typed.setter + def value_typed(self, value: bool) -> None: + if not isinstance(value, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("vt:bool value must be bool, got %s" % type(value).__name__) + self.text = "true" if value else "false" + + +class CT_VtFiletime(_VtValueElement): + """`` — ISO-8601 UTC datetime value (always with `Z` suffix).""" + + @property + def value_typed(self) -> dt.datetime: + text = self.text + if text is None: + raise ValueError("vt:filetime element has no text content") + return _parse_iso_utc(text) + + @value_typed.setter + def value_typed(self, value: dt.datetime) -> None: + if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + "vt:filetime value must be datetime, got %s" % type(value).__name__ + ) + # Office writes filetimes as UTC with a literal trailing 'Z'. If the + # caller supplied a tz-aware value in another zone, convert; if naive, + # assume already UTC (matches CorePropertiesPart's behavior). + if value.tzinfo is not None: + value = value.astimezone(dt.timezone.utc).replace(tzinfo=None) + self.text = value.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse_iso_utc(text: str) -> dt.datetime: + """Parse `text` as ISO-8601, returning a naive UTC `datetime`. + + Accepts the `Z` suffix Office writes and the `+HH:MM` form some tools use. + Returns a naive datetime in UTC for symmetry with `_set_element_datetime` + in `coreprops`. Raises `ValueError` on unparsable input. + """ + cleaned = text.strip() + if cleaned.endswith("Z"): + cleaned = cleaned[:-1] + "+00:00" + parsed = dt.datetime.fromisoformat(cleaned) + if parsed.tzinfo is not None: + parsed = parsed.astimezone(dt.timezone.utc).replace(tzinfo=None) + return parsed diff --git a/src/pptx/oxml/custom_xml.py b/src/pptx/oxml/custom_xml.py new file mode 100644 index 000000000..1b2fa83fa --- /dev/null +++ b/src/pptx/oxml/custom_xml.py @@ -0,0 +1,119 @@ +"""lxml custom element classes for customXml itemProps parts. + +Models the `` root of `/customXml/itemPropsN.xml` — the +sibling part of each `/customXml/itemN.xml` data part. Carries the +`datastoreItem` GUID identifying the data part across edits and the optional +`` list declaring the XML namespaces the data part claims to +conform to. + +Schema references: ECMA-376 Part 1, §15.2.4 (Custom XML Data Storage Part). +""" + +from __future__ import annotations + +from typing import Iterable, cast + +from pptx.oxml import parse_xml +from pptx.oxml.ns import nsdecls, qn +from pptx.oxml.simpletypes import XsdString +from pptx.oxml.xmlchemy import ( + BaseOxmlElement, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) + + +class CT_DatastoreItem(BaseOxmlElement): + """`` element — root of `/customXml/itemPropsN.xml`.""" + + itemID: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "ds:itemID", XsdString + ) + schemaRefs = ZeroOrOne("ds:schemaRefs", successors=()) + + _datastoreItem_tmpl = ( + '\n' % nsdecls("ds") + ) + + @staticmethod + def new(itemID: str, schema_refs: Iterable[str] = ()) -> "CT_DatastoreItem": + """Return a new `` with `itemID` and optional schema_refs. + + `itemID` should be a curly-braced GUID string, e.g. + `"{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}"`. The caller is responsible + for generating it (typically via `uuid.uuid4()`); this layer does not + validate the GUID format because Office tolerates non-canonical forms. + """ + elm = cast( + "CT_DatastoreItem", + parse_xml(CT_DatastoreItem._datastoreItem_tmpl % itemID), + ) + for uri in schema_refs: + elm.add_schema_ref(uri) + return elm + + def add_schema_ref(self, uri: str) -> "CT_DatastoreSchemaRef": + """Add a `` child. + + Creates the parent `` element if it is not already + present. If a schemaRef with `uri` already exists, returns the existing + one rather than adding a duplicate. + """ + refs = cast("CT_DatastoreSchemaRefs", self.get_or_add_schemaRefs()) + existing = refs.find_by_uri(uri) + if existing is not None: + return existing + ref = cast("CT_DatastoreSchemaRef", refs._add_schemaRef()) + ref.uri = uri + return ref + + def remove_schema_ref(self, uri: str) -> bool: + """Remove the schemaRef with `uri`, returning True if found. + + If removing the last schemaRef leaves `` empty, the + empty parent element is also removed (Office writes the file this way + — no empty `` envelope). + """ + refs = cast("CT_DatastoreSchemaRefs | None", self.schemaRefs) + if refs is None: + return False + ref = refs.find_by_uri(uri) + if ref is None: + return False + refs.remove(ref) + if len(refs.schemaRef_lst) == 0: + self.remove(refs) + return True + + @property + def schema_ref_uris(self) -> tuple[str, ...]: + """Tuple of `ds:uri` values for every ``, in document order.""" + refs = cast("CT_DatastoreSchemaRefs | None", self.schemaRefs) + if refs is None: + return () + return tuple( + cast("CT_DatastoreSchemaRef", r).uri + for r in cast("list[BaseOxmlElement]", refs.schemaRef_lst) + ) + + +class CT_DatastoreSchemaRefs(BaseOxmlElement): + """`` — collection of `` children.""" + + schemaRef = ZeroOrMore("ds:schemaRef", successors=()) + + def find_by_uri(self, uri: str) -> "CT_DatastoreSchemaRef | None": + """Return the `` child whose `ds:uri` is `uri`, or None.""" + for ref in cast("list[CT_DatastoreSchemaRef]", self.schemaRef_lst): + if ref.uri == uri: + return ref + return None + + +class CT_DatastoreSchemaRef(BaseOxmlElement): + """`` — a single XML namespace this customXml part conforms to.""" + + uri: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "ds:uri", XsdString + ) diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index d900c33bf..72df489e3 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -12,12 +12,14 @@ "dc": "http://purl.org/dc/elements/1.1/", "dcmitype": "http://purl.org/dc/dcmitype/", "dcterms": "http://purl.org/dc/terms/", + "ds": "http://schemas.openxmlformats.org/officeDocument/2006/customXml", "ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties", "i": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", "mo": "http://schemas.microsoft.com/office/mac/office/2008/main", "mv": "urn:schemas-microsoft-com:mac:vml", "o": "urn:schemas-microsoft-com:office:office", + "op": "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", "p": "http://schemas.openxmlformats.org/presentationml/2006/main", "pd": "http://schemas.openxmlformats.org/drawingml/2006/presentationDrawing", "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture", @@ -26,6 +28,7 @@ "sl": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", "v": "urn:schemas-microsoft-com:vml", "ve": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w10": "urn:schemas-microsoft-com:office:word", "wne": "http://schemas.microsoft.com/office/word/2006/wordml", diff --git a/tests/oxml/test_custom_properties.py b/tests/oxml/test_custom_properties.py new file mode 100644 index 000000000..c98476a00 --- /dev/null +++ b/tests/oxml/test_custom_properties.py @@ -0,0 +1,272 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `pptx.oxml.custom_properties`.""" + +from __future__ import annotations + +import datetime as dt + +import pytest +from lxml import etree + +from pptx.oxml import parse_xml +from pptx.oxml.custom_properties import ( + DEFAULT_FMTID, + CT_Properties, + CT_Property, + CT_VtBool, + CT_VtFiletime, + CT_VtI4, + CT_VtLpwstr, + CT_VtR8, +) +from pptx.oxml.ns import nsdecls + + +def _props_xml(*property_xml_chunks: str) -> bytes: + body = "".join(property_xml_chunks) + return ("%s" % (nsdecls("op", "vt"), body)).encode() + + +def _property_xml(name: str, pid: int, vt_inner_xml: str) -> str: + return ( + '%s' + % (DEFAULT_FMTID, pid, name, vt_inner_xml) + ) + + +class DescribeCT_Properties: + def it_parses_to_the_registered_class(self): + root = parse_xml(_props_xml()) + assert isinstance(root, CT_Properties) + + def it_can_create_a_fresh_root_with_both_namespaces_declared(self): + root = CT_Properties.new_properties() + xml = etree.tostring(root, encoding="unicode") + assert "xmlns:op=" in xml + assert "xmlns:vt=" in xml + + def it_returns_the_property_lst_in_document_order(self): + root = parse_xml( + _props_xml( + _property_xml("alpha", 2, "a"), + _property_xml("beta", 3, "1"), + ) + ) + assert root.property_names == ("alpha", "beta") + + def it_finds_a_property_by_name(self): + root = parse_xml( + _props_xml( + _property_xml("Source", 2, "cli"), + _property_xml("Build", 3, "42"), + ) + ) + assert root.get_property("Build").pid == 3 + assert root.get_property("Missing") is None + + def it_removes_a_property_by_name(self): + root = parse_xml( + _props_xml( + _property_xml("Source", 2, "cli"), + _property_xml("Build", 3, "42"), + ) + ) + assert root.remove_property("Source") is True + assert root.property_names == ("Build",) + assert root.remove_property("Source") is False # idempotent + + @pytest.mark.parametrize( + ("value", "expected_child_tag"), + [ + ("hello", "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}lpwstr"), + (42, "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}i4"), + (3.14, "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}r8"), + (True, "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}bool"), + ( + dt.datetime(2026, 5, 5, 14, 0, 0), + "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}filetime", + ), + ], + ) + def it_dispatches_add_property_by_python_type(self, value, expected_child_tag): + root = CT_Properties.new_properties() + prop = root.add_property("X", value) + assert prop.fmtid == DEFAULT_FMTID + assert prop.name == "X" + # exactly one vt:* child, of the expected tag + assert len(prop) == 1 + assert prop[0].tag == expected_child_tag + + def it_round_trips_value_for_each_vt_type(self): + root = CT_Properties.new_properties() + cases: list[tuple[str, object]] = [ + ("Source", "deck-builder-cli@1.4.2"), + ("BuildNumber", 42), + ("WeightedScore", 3.14159), + ("IsDraft", True), + ("IsFinal", False), + ("GeneratedAt", dt.datetime(2026, 5, 5, 14, 0, 0)), + ] + for name, value in cases: + root.add_property(name, value) + + serialized = etree.tostring(root) + reparsed = parse_xml(serialized) + for name, value in cases: + prop = reparsed.get_property(name) + assert prop is not None, name + assert prop.value == value, name + + def it_auto_assigns_unique_pids_starting_at_2(self): + root = CT_Properties.new_properties() + a = root.add_property("a", "1") + b = root.add_property("b", "2") + c = root.add_property("c", "3") + assert (a.pid, b.pid, c.pid) == (2, 3, 4) + + def it_skips_used_pids_when_assigning(self): + # parse a doc where pid 2 is already used; the next add_property must use 3 + root = parse_xml( + _props_xml(_property_xml("Existing", 2, "x")) + ) + new_prop = root.add_property("New", "y") + assert new_prop.pid == 3 + + def it_raises_TypeError_on_unsupported_value_type(self): + root = CT_Properties.new_properties() + with pytest.raises(TypeError): + root.add_property("bad", object()) + + def it_treats_bool_as_bool_not_int(self): + # bool is-a int in Python; the dispatch must still produce vt:bool, not vt:i4 + root = CT_Properties.new_properties() + prop_true = root.add_property("flag", True) + assert isinstance(prop_true.bool_, CT_VtBool) + assert prop_true.i4 is None + + +class DescribeCT_VtLpwstr: + def it_round_trips_string_text(self): + prop = parse_xml( + _property_xml("X", 2, "hello world").encode() + if False + else ( + "" + "hello world" + % (nsdecls("op", "vt"), DEFAULT_FMTID) + ).encode() + ) + assert isinstance(prop.lpwstr, CT_VtLpwstr) + assert prop.value == "hello world" + + def it_rejects_non_string_assignment(self): + prop = CT_Properties.new_properties().add_property("X", "seed") + prop_lpwstr: CT_VtLpwstr = prop.lpwstr + with pytest.raises(TypeError): + prop_lpwstr.value_typed = 42 # type: ignore[assignment] + + def it_rejects_overlong_strings(self): + prop = CT_Properties.new_properties().add_property("X", "seed") + with pytest.raises(ValueError): + prop.lpwstr.value_typed = "x" * 256 + + +class DescribeCT_VtI4: + @pytest.mark.parametrize("value", [-2147483648, -1, 0, 1, 42, 2147483647]) + def it_round_trips_int_text(self, value): + prop = CT_Properties.new_properties().add_property("X", value) + assert isinstance(prop.i4, CT_VtI4) + assert prop.value == value + + def it_rejects_out_of_range_ints(self): + prop = CT_Properties.new_properties().add_property("X", 0) + with pytest.raises(ValueError): + prop.i4.value_typed = 2147483648 + + def it_rejects_bool_assignment_at_the_leaf(self): + # the dispatch in CT_Property.value picks vt:bool for bool, but if a + # caller reaches into the leaf they should still get the type guard + prop = CT_Properties.new_properties().add_property("X", 0) + with pytest.raises(TypeError): + prop.i4.value_typed = True + + +class DescribeCT_VtR8: + @pytest.mark.parametrize("value", [-1.0, 0.0, 0.5, 3.14159, 1e20, -1e-20]) + def it_round_trips_float_text(self, value): + prop = CT_Properties.new_properties().add_property("X", value) + assert isinstance(prop.r8, CT_VtR8) + assert prop.value == pytest.approx(value) + + +class DescribeCT_VtBool: + @pytest.mark.parametrize( + ("xml_text", "expected"), + [("true", True), ("false", False), ("1", True), ("0", False), (" TRUE ", True)], + ) + def it_reads_office_and_xsd_boolean_lexical_forms(self, xml_text, expected): + prop_xml = ( + "" + "%s" + % (nsdecls("op", "vt"), DEFAULT_FMTID, xml_text) + ) + prop = parse_xml(prop_xml.encode()) + assert prop.value is expected + + @pytest.mark.parametrize(("py_value", "expected_text"), [(True, "true"), (False, "false")]) + def it_writes_office_lexical_form(self, py_value, expected_text): + prop = CT_Properties.new_properties().add_property("X", py_value) + assert prop.bool_.text == expected_text + + def it_raises_on_invalid_text(self): + prop_xml = ( + "" + "maybe" + % (nsdecls("op", "vt"), DEFAULT_FMTID) + ) + prop = parse_xml(prop_xml.encode()) + with pytest.raises(ValueError): + _ = prop.value + + +class DescribeCT_VtFiletime: + def it_round_trips_a_naive_utc_datetime(self): + original = dt.datetime(2026, 5, 5, 14, 0, 0) + prop = CT_Properties.new_properties().add_property("X", original) + assert isinstance(prop.filetime, CT_VtFiletime) + assert prop.filetime.text == "2026-05-05T14:00:00Z" + assert prop.value == original + + def it_normalizes_a_tz_aware_datetime_to_utc(self): + eastern = dt.timezone(dt.timedelta(hours=-5)) + aware = dt.datetime(2026, 5, 5, 9, 0, 0, tzinfo=eastern) # 14:00 UTC + prop = CT_Properties.new_properties().add_property("X", aware) + assert prop.filetime.text == "2026-05-05T14:00:00Z" + + def it_parses_offset_form_too(self): + prop_xml = ( + "" + "2026-05-05T09:00:00-05:00" + % (nsdecls("op", "vt"), DEFAULT_FMTID) + ) + prop = parse_xml(prop_xml.encode()) + assert prop.value == dt.datetime(2026, 5, 5, 14, 0, 0) + + +class DescribeCT_Property_value_setter: + def it_replaces_an_existing_value_child(self): + prop = CT_Properties.new_properties().add_property("X", "old") + prop.value = 99 + assert prop.lpwstr is None + assert prop.value == 99 + + def it_returns_None_for_value_when_no_child_present(self): + # build a stripped property element by parsing + prop_xml = ( + '' + % (nsdecls("op", "vt"), DEFAULT_FMTID) + ) + prop = parse_xml(prop_xml.encode()) + assert isinstance(prop, CT_Property) + assert prop.value is None diff --git a/tests/oxml/test_custom_xml.py b/tests/oxml/test_custom_xml.py new file mode 100644 index 000000000..c95cabe44 --- /dev/null +++ b/tests/oxml/test_custom_xml.py @@ -0,0 +1,129 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `pptx.oxml.custom_xml`.""" + +from __future__ import annotations + +import pytest +from lxml import etree + +from pptx.oxml import parse_xml +from pptx.oxml.custom_xml import ( + CT_DatastoreItem, + CT_DatastoreSchemaRef, + CT_DatastoreSchemaRefs, +) +from pptx.oxml.ns import nsdecls + +_GUID_A = "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}" +_GUID_B = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" + + +def _datastore_xml(item_id: str, *uris: str) -> bytes: + schema_refs = "" + if uris: + schema_refs = "%s" % "".join( + '' % u for u in uris + ) + return ( + '%s' + % (nsdecls("ds"), item_id, schema_refs) + ).encode() + + +class DescribeCT_DatastoreItem: + def it_parses_to_the_registered_class(self): + root = parse_xml(_datastore_xml(_GUID_A)) + assert isinstance(root, CT_DatastoreItem) + + def it_exposes_the_itemID_attribute(self): + root = parse_xml(_datastore_xml(_GUID_A)) + assert root.itemID == _GUID_A + + def it_can_change_the_itemID_attribute(self): + root = parse_xml(_datastore_xml(_GUID_A)) + root.itemID = _GUID_B + assert root.itemID == _GUID_B + + def it_returns_an_empty_tuple_when_no_schemaRefs_present(self): + root = parse_xml(_datastore_xml(_GUID_A)) + assert root.schemaRefs is None + assert root.schema_ref_uris == () + + def it_lists_schema_ref_uris_in_document_order(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:a", "urn:b", "urn:c")) + assert root.schema_ref_uris == ("urn:a", "urn:b", "urn:c") + + def it_creates_a_fresh_root_via_new(self): + elm = CT_DatastoreItem.new(_GUID_A) + assert elm.itemID == _GUID_A + assert elm.schema_ref_uris == () + + def it_creates_a_fresh_root_with_initial_schema_refs(self): + elm = CT_DatastoreItem.new(_GUID_B, schema_refs=["urn:foo", "urn:bar"]) + assert elm.schema_ref_uris == ("urn:foo", "urn:bar") + + def it_adds_a_schema_ref_creating_the_envelope_when_absent(self): + elm = CT_DatastoreItem.new(_GUID_A) + elm.add_schema_ref("urn:foo") + assert isinstance(elm.schemaRefs, CT_DatastoreSchemaRefs) + assert elm.schema_ref_uris == ("urn:foo",) + + def it_returns_existing_ref_on_duplicate_add(self): + elm = CT_DatastoreItem.new(_GUID_A, schema_refs=["urn:foo"]) + first = elm.add_schema_ref("urn:foo") + second = elm.add_schema_ref("urn:foo") + assert first is second + assert elm.schema_ref_uris == ("urn:foo",) + + def it_removes_a_schema_ref_by_uri(self): + elm = CT_DatastoreItem.new(_GUID_A, schema_refs=["urn:a", "urn:b"]) + assert elm.remove_schema_ref("urn:a") is True + assert elm.schema_ref_uris == ("urn:b",) + + def it_returns_False_when_removing_nonexistent_ref(self): + elm = CT_DatastoreItem.new(_GUID_A, schema_refs=["urn:a"]) + assert elm.remove_schema_ref("urn:missing") is False + + def it_drops_the_envelope_when_the_last_ref_is_removed(self): + elm = CT_DatastoreItem.new(_GUID_A, schema_refs=["urn:only"]) + assert elm.remove_schema_ref("urn:only") is True + assert elm.schemaRefs is None + assert elm.schema_ref_uris == () + + def it_round_trips_through_parse_serialize(self): + elm = CT_DatastoreItem.new(_GUID_A, schema_refs=["urn:x", "urn:y"]) + serialized = etree.tostring(elm) + reparsed = parse_xml(serialized) + assert isinstance(reparsed, CT_DatastoreItem) + assert reparsed.itemID == _GUID_A + assert reparsed.schema_ref_uris == ("urn:x", "urn:y") + + +class DescribeCT_DatastoreSchemaRef: + def it_parses_to_the_registered_class(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:foo")) + ref = root.schemaRefs.schemaRef_lst[0] + assert isinstance(ref, CT_DatastoreSchemaRef) + + def it_exposes_the_uri_attribute(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:foo")) + assert root.schemaRefs.schemaRef_lst[0].uri == "urn:foo" + + def it_can_change_the_uri_attribute(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:foo")) + ref = root.schemaRefs.schemaRef_lst[0] + ref.uri = "urn:replaced" + assert root.schema_ref_uris == ("urn:replaced",) + + +class DescribeCT_DatastoreSchemaRefs: + def it_finds_a_ref_by_uri(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:a", "urn:b")) + found = root.schemaRefs.find_by_uri("urn:b") + assert isinstance(found, CT_DatastoreSchemaRef) + assert found.uri == "urn:b" + + def it_returns_None_for_unknown_uri(self): + root = parse_xml(_datastore_xml(_GUID_A, "urn:a")) + assert root.schemaRefs.find_by_uri("urn:missing") is None From a8cac3209d9264724cd2e0f7501e36d797c5b83e Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 10:47:35 -0400 Subject: [PATCH 04/10] feat(parts): add CustomPropertiesPart and CustomXmlPart subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of customXml support per Plans/customxml-implementation-plan.md. Three new XmlPart subclasses, two registered with PartFactory: - CustomPropertiesPart -> /docProps/custom.xml Owns CT_Properties; default() factory; add/get/remove/property_names delegators plus __contains__/__iter__/__len__ for the Phase-3 wrapper to compose around. Registered against CT.OFC_CUSTOM_PROPERTIES. - CustomXmlPropertiesPart -> /customXml/itemPropsN.xml Owns CT_DatastoreItem; new() factory; datastore_item_id and schema_refs accessors. Registered against CT.OFC_CUSTOM_XML_PROPERTIES. - CustomXmlPart -> /customXml/itemN.xml Owns the caller's arbitrary XML payload. new_pair() atomically creates both the data part and its CustomXmlPropertiesPart sibling, allocates matching N indices, generates a {GUID} if datastore_item_id omitted, and wires the RT.CUSTOM_XML_PROPS rel from data to props. Exposes element/blob/replace_xml plus pass-through accessors that delegate to the props part. INTENTIONALLY not registered against CT.XML per plan section 3.6 — Phase 3 will wrap loaded base Part instances on enumeration. Anti-comment in __init__.py marks the deferred mapping. 38 new unit tests; 100% / 96% line coverage on the new modules. Existing 2859-test suite still green (2897 total). --- src/pptx/__init__.py | 10 + src/pptx/parts/custom_properties.py | 66 ++++++ src/pptx/parts/custom_xml.py | 229 +++++++++++++++++++++ tests/parts/test_custom_properties.py | 108 ++++++++++ tests/parts/test_custom_xml.py | 285 ++++++++++++++++++++++++++ 5 files changed, 698 insertions(+) create mode 100644 src/pptx/parts/custom_properties.py create mode 100644 src/pptx/parts/custom_xml.py create mode 100644 tests/parts/test_custom_properties.py create mode 100644 tests/parts/test_custom_xml.py diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py index fb5c2d7e4..6ccada241 100644 --- a/src/pptx/__init__.py +++ b/src/pptx/__init__.py @@ -11,6 +11,8 @@ from pptx.opc.package import PartFactory from pptx.parts.chart import ChartPart from pptx.parts.coreprops import CorePropertiesPart +from pptx.parts.custom_properties import CustomPropertiesPart +from pptx.parts.custom_xml import CustomXmlPropertiesPart from pptx.parts.image import ImagePart from pptx.parts.media import MediaPart from pptx.parts.presentation import PresentationPart @@ -38,6 +40,12 @@ CT.PML_TEMPLATE_MAIN: PresentationPart, CT.PML_SLIDESHOW_MAIN: PresentationPart, CT.OPC_CORE_PROPERTIES: CorePropertiesPart, + CT.OFC_CUSTOM_PROPERTIES: CustomPropertiesPart, + CT.OFC_CUSTOM_XML_PROPERTIES: CustomXmlPropertiesPart, + # NOTE: CT.XML is intentionally NOT mapped to CustomXmlPart — see + # `Plans/customxml-implementation-plan.md` §3.6. The Phase-3 + # `CustomXmlParts` collection wraps loaded base `Part` instances + # at enumeration time. CT.PML_NOTES_MASTER: NotesMasterPart, CT.PML_NOTES_SLIDE: NotesSlidePart, CT.PML_SLIDE: SlidePart, @@ -71,6 +79,8 @@ del ( ChartPart, CorePropertiesPart, + CustomPropertiesPart, + CustomXmlPropertiesPart, ImagePart, MediaPart, SlidePart, diff --git a/src/pptx/parts/custom_properties.py b/src/pptx/parts/custom_properties.py new file mode 100644 index 000000000..efe55319a --- /dev/null +++ b/src/pptx/parts/custom_properties.py @@ -0,0 +1,66 @@ +"""Custom Document Properties part — `/docProps/custom.xml`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.opc.package import XmlPart +from pptx.opc.packuri import PackURI +from pptx.oxml.custom_properties import CT_Properties, CT_Property + +if TYPE_CHECKING: + from pptx.package import Package + + +class CustomPropertiesPart(XmlPart): + """Corresponds to part named `/docProps/custom.xml`. + + Holds the package's custom (user-defined) document properties — the values + surfaced under `File → Properties → Advanced` in PowerPoint. The + user-facing Mapping wrapper lives at `pptx.custom_properties.CustomProperties` + (Phase 3); this part just owns the XML and the per-property delegators. + """ + + _element: CT_Properties + + @classmethod + def default(cls, package: "Package") -> "CustomPropertiesPart": + """Return a new empty `CustomPropertiesPart` ready to add to `package`. + + Useful as the seed when a presentation doesn't yet have a custom + properties part. The returned instance has no properties on it; the + caller adds entries via `add_property(...)`. + """ + return cls( + PackURI("/docProps/custom.xml"), + CT.OFC_CUSTOM_PROPERTIES, + package, + CT_Properties.new_properties(), + ) + + def add_property(self, name: str, value: object) -> CT_Property: + """Add a new `` for `(name, value)` and return it.""" + return self._element.add_property(name, value) + + def get_property(self, name: str) -> CT_Property | None: + """Return the `` with `name` or `None` if absent.""" + return self._element.get_property(name) + + def remove_property(self, name: str) -> bool: + """Remove the `` with `name`, returning True if found.""" + return self._element.remove_property(name) + + @property + def property_names(self) -> tuple[str, ...]: + """Tuple of property names in document order.""" + return self._element.property_names + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and self._element.get_property(name) is not None + + def __iter__(self) -> Iterator[str]: + return iter(self._element.property_names) + + def __len__(self) -> int: + return len(self._element.property_lst) diff --git a/src/pptx/parts/custom_xml.py b/src/pptx/parts/custom_xml.py new file mode 100644 index 000000000..ed768e2e7 --- /dev/null +++ b/src/pptx/parts/custom_xml.py @@ -0,0 +1,229 @@ +"""customXml data parts and their itemProps siblings. + +Two part subclasses living together in this module because they are an atomic +pair — a `CustomXmlPart` is meaningless without its `CustomXmlPropertiesPart` +sibling, and vice versa. Both are created by `CustomXmlPart.new_pair(...)`. + +Schema references: ECMA-376 Part 1, §15.2.4 (Custom XML Data Storage Part). +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Iterable, Union, cast + +from lxml.etree import _Element # pyright: ignore[reportPrivateUsage] + +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.package import XmlPart +from pptx.opc.packuri import PackURI +from pptx.oxml import parse_xml +from pptx.oxml.custom_xml import CT_DatastoreItem +from pptx.oxml.xmlchemy import BaseOxmlElement + +if TYPE_CHECKING: + from pptx.package import Package + + +XmlPayload = Union[bytes, str, _Element] + + +class CustomXmlPropertiesPart(XmlPart): + """Corresponds to part named `/customXml/itemPropsN.xml`. + + Carries the `datastoreItem` GUID identifying its sibling `CustomXmlPart` + across edits, plus the optional list of `` URIs the data part + claims to conform to. + """ + + _element: CT_DatastoreItem + + @classmethod + def new( + cls, + package: "Package", + partname: PackURI, + datastore_item_id: str, + schema_refs: Iterable[str] = (), + ) -> "CustomXmlPropertiesPart": + """Return a fresh `CustomXmlPropertiesPart` at `partname` for `package`.""" + item_elm = CT_DatastoreItem.new(datastore_item_id, schema_refs=schema_refs) + return cls(partname, CT.OFC_CUSTOM_XML_PROPERTIES, package, item_elm) + + @property + def datastore_item_id(self) -> str: + """The `ds:itemID` attribute — a GUID like `"{1A2B...}"`.""" + return self._element.itemID + + @datastore_item_id.setter + def datastore_item_id(self, value: str) -> None: + self._element.itemID = value + + @property + def schema_refs(self) -> tuple[str, ...]: + """Tuple of `` values in document order.""" + return self._element.schema_ref_uris + + def add_schema_ref(self, uri: str) -> None: + """Append a `` (idempotent on `uri`).""" + self._element.add_schema_ref(uri) + + def remove_schema_ref(self, uri: str) -> bool: + """Remove the schemaRef with `uri`, returning True if found.""" + return self._element.remove_schema_ref(uri) + + +class CustomXmlPart(XmlPart): + """Corresponds to part named `/customXml/itemN.xml`. + + Holds an arbitrary XML payload supplied by the caller. The payload's root + element name and namespaces are caller-defined — `python-pptx` does not + impose a schema. Each `CustomXmlPart` has a sibling `CustomXmlPropertiesPart` + that carries the part's `datastoreItem` GUID; the rel between them is of + type `RT.CUSTOM_XML_PROPS`. + + NOTE: This class is intentionally **not** registered with `PartFactory` + against `CT.XML`. Loaded `application/xml` parts are produced as base + `Part` instances, and the Phase-3 `CustomXmlParts` collection upgrades + them on enumeration. See `Plans/customxml-implementation-plan.md` §3.6. + """ + + @classmethod + def new_pair( + cls, + package: "Package", + xml_payload: XmlPayload, + *, + datastore_item_id: str | None = None, + schema_refs: Iterable[str] = (), + ) -> "CustomXmlPart": + """Create a paired CustomXmlPart + CustomXmlPropertiesPart in `package`. + + Returns the data part. The props part is related from the data part + via `RT.CUSTOM_XML_PROPS`. Neither is yet related from any outside + source — that is the caller's job (Phase-3 `CustomXmlParts.add(...)`). + + `xml_payload` may be `bytes`, a `str`, or an existing lxml `_Element`. + If `datastore_item_id` is omitted a fresh `uuid4()` is generated and + wrapped in curly braces to match Office's format. + + Partname allocation: `/customXml/itemN.xml` and `/customXml/itemPropsN.xml` + share the same `N`, picked as the next free index across existing data + parts in `package` (props parts are looked up via the data → props rel, + not via partname pattern). + """ + idx = _next_customxml_index(package) + data_partname = PackURI("/customXml/item%d.xml" % idx) + props_partname = PackURI("/customXml/itemProps%d.xml" % idx) + + element = _parse_payload(xml_payload) + data_part = cls(data_partname, CT.XML, package, element) + + if datastore_item_id is None: + datastore_item_id = "{%s}" % str(uuid.uuid4()).upper() + + props_part = CustomXmlPropertiesPart.new( + package, props_partname, datastore_item_id, schema_refs + ) + + data_part.relate_to(props_part, RT.CUSTOM_XML_PROPS) + return data_part + + @property + def props_part(self) -> CustomXmlPropertiesPart: + """Return the related `CustomXmlPropertiesPart` for this data part. + + Raises `KeyError` if the props rel is missing — a malformed package + the caller is expected to repair via `CustomXmlPart.new_pair(...)`. + """ + return cast( + CustomXmlPropertiesPart, self.part_related_by(RT.CUSTOM_XML_PROPS) + ) + + @property + def datastore_item_id(self) -> str: + """Convenience accessor delegating to the sibling props part.""" + return self.props_part.datastore_item_id + + @datastore_item_id.setter + def datastore_item_id(self, value: str) -> None: + self.props_part.datastore_item_id = value + + @property + def schema_refs(self) -> tuple[str, ...]: + """Convenience accessor delegating to the sibling props part.""" + return self.props_part.schema_refs + + def add_schema_ref(self, uri: str) -> None: + """Convenience pass-through to the sibling props part.""" + self.props_part.add_schema_ref(uri) + + def remove_schema_ref(self, uri: str) -> bool: + """Convenience pass-through to the sibling props part.""" + return self.props_part.remove_schema_ref(uri) + + @property + def element(self) -> BaseOxmlElement: + """Live root element of the customXml payload. + + Mutating its children mutates the part; the next `package.save(...)` + will serialize the updated tree. + """ + return self._element + + def replace_xml(self, xml_payload: XmlPayload) -> None: + """Replace the entire XML payload with `xml_payload`. + + The root element is replaced wholesale; `datastore_item_id` and + `schema_refs` are unaffected (they live on the sibling props part). + """ + self._element = _parse_payload(xml_payload) + + +def _parse_payload(xml_payload: XmlPayload) -> BaseOxmlElement: + """Coerce `xml_payload` to a `BaseOxmlElement` root. + + Accepts bytes (parsed verbatim), str (utf-8 encoded then parsed), or an + already-parsed lxml `_Element` (returned as-is). Raises `TypeError` for + anything else so the caller fails fast at the boundary. + """ + if isinstance(xml_payload, bytes): + return cast("BaseOxmlElement", parse_xml(xml_payload)) + if isinstance(xml_payload, str): + return cast("BaseOxmlElement", parse_xml(xml_payload.encode("utf-8"))) + if isinstance(xml_payload, _Element): + return cast("BaseOxmlElement", xml_payload) + raise TypeError( + "xml_payload must be bytes, str, or lxml _Element; got %s" + % type(xml_payload).__name__ + ) + + +def _next_customxml_index(package: "Package") -> int: + """Return the next free `N` for `/customXml/itemN.xml`. + + Walks `package.iter_parts()` and skips `itemProps*.xml` parts. Reuses + gaps in the sequence (e.g. if items 1 and 3 exist, returns 2). + """ + used: set[int] = set() + data_prefix = "/customXml/item" + props_prefix = "/customXml/itemProps" + for part in package.iter_parts(): + partname = str(part.partname) + if not partname.startswith(data_prefix): + continue + if partname.startswith(props_prefix): + continue + # partname looks like /customXml/itemN.xml + suffix = partname[len(data_prefix) :] + if not suffix.endswith(".xml"): + continue + try: + used.add(int(suffix[: -len(".xml")])) + except ValueError: + continue + n = 1 + while n in used: + n += 1 + return n diff --git a/tests/parts/test_custom_properties.py b/tests/parts/test_custom_properties.py new file mode 100644 index 000000000..eea428822 --- /dev/null +++ b/tests/parts/test_custom_properties.py @@ -0,0 +1,108 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `pptx.parts.custom_properties`.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.oxml.custom_properties import CT_Properties, DEFAULT_FMTID +from pptx.oxml.ns import nsdecls +from pptx.parts.custom_properties import CustomPropertiesPart + + +def _props_xml(*property_xml_chunks: str) -> bytes: + body = "".join(property_xml_chunks) + return ("%s" % (nsdecls("op", "vt"), body)).encode() + + +def _property_xml(name: str, pid: int, vt_inner_xml: str) -> str: + return ( + '%s' + % (DEFAULT_FMTID, pid, name, vt_inner_xml) + ) + + +class DescribeCustomPropertiesPart: + def it_can_construct_a_default_part(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + assert isinstance(part, CustomPropertiesPart) + assert part.content_type == CT.OFC_CUSTOM_PROPERTIES + assert part.partname == "/docProps/custom.xml" + assert isinstance(part._element, CT_Properties) + assert part.property_names == () + + def it_loads_an_existing_part_from_blob(self): + xml = _props_xml( + _property_xml("Source", 2, "cli"), + _property_xml("Build", 3, "42"), + ) + part = CustomPropertiesPart.load( + "/docProps/custom.xml", CT.OFC_CUSTOM_PROPERTIES, None, xml # type: ignore[arg-type] + ) + assert isinstance(part._element, CT_Properties) + assert part.property_names == ("Source", "Build") + + def it_adds_a_property_via_delegation(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + prop = part.add_property("Source", "cli@1.4") + assert prop.name == "Source" + assert prop.value == "cli@1.4" + assert part.property_names == ("Source",) + + def it_dispatches_value_types_through_to_the_element(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + part.add_property("Build", 42) + part.add_property("Score", 3.14) + part.add_property("IsDraft", True) + part.add_property("At", dt.datetime(2026, 5, 5, 14, 0, 0)) + assert part.get_property("Build").value == 42 + assert part.get_property("Score").value == pytest.approx(3.14) + assert part.get_property("IsDraft").value is True + assert part.get_property("At").value == dt.datetime(2026, 5, 5, 14, 0, 0) + + def it_returns_None_when_property_missing(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + assert part.get_property("Missing") is None + + def it_removes_a_property_idempotently(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + part.add_property("X", "a") + assert part.remove_property("X") is True + assert part.property_names == () + assert part.remove_property("X") is False + + def it_supports_in_iter_and_len(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + part.add_property("a", "1") + part.add_property("b", "2") + part.add_property("c", "3") + assert len(part) == 3 + assert list(part) == ["a", "b", "c"] + assert "b" in part + assert "z" not in part + # __contains__ on non-string is False + assert (42 in part) is False # type: ignore[operator] + + def it_round_trips_blob_through_add_and_reparse(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + part.add_property("Source", "cli") + part.add_property("Build", 99) + blob = part.blob + # blob is XML that re-parses to an equivalent CustomPropertiesPart + reloaded = CustomPropertiesPart.load( + "/docProps/custom.xml", CT.OFC_CUSTOM_PROPERTIES, None, blob # type: ignore[arg-type] + ) + assert reloaded.property_names == ("Source", "Build") + assert reloaded.get_property("Source").value == "cli" + assert reloaded.get_property("Build").value == 99 + + def it_assigns_unique_pids_across_adds(self): + part = CustomPropertiesPart.default(None) # type: ignore[arg-type] + a = part.add_property("a", "1") + b = part.add_property("b", "2") + c = part.add_property("c", "3") + assert (a.pid, b.pid, c.pid) == (2, 3, 4) diff --git a/tests/parts/test_custom_xml.py b/tests/parts/test_custom_xml.py new file mode 100644 index 000000000..5c38f4dec --- /dev/null +++ b/tests/parts/test_custom_xml.py @@ -0,0 +1,285 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `pptx.parts.custom_xml`.""" + +from __future__ import annotations + +import re + +import pytest +from lxml import etree + +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.packuri import PackURI +from pptx.oxml.custom_xml import CT_DatastoreItem +from pptx.oxml.ns import nsdecls +from pptx.parts.custom_xml import ( + CustomXmlPart, + CustomXmlPropertiesPart, + _next_customxml_index, + _parse_payload, +) + + +_GUID_A = "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}" +_GUID_B = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" +_GUID_RE = re.compile(r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$") + + +class _StubPart: + """Minimal stand-in for an existing package part during partname allocation tests.""" + + def __init__(self, partname: str): + self.partname = PackURI(partname) + + +class _StubPackage: + """Minimal Package-like double exposing only `iter_parts()`. + + Sufficient because `CustomXmlPart.new_pair` and `_next_customxml_index` + consult `iter_parts()` for partname allocation and never call any other + method on the package during construction. + """ + + def __init__(self, partnames: list[str] | None = None): + self._parts = [_StubPart(p) for p in (partnames or [])] + + def iter_parts(self): + return iter(self._parts) + + +# --------------------------------------------------------------------------- +# CustomXmlPropertiesPart +# --------------------------------------------------------------------------- + + +def _datastore_xml(item_id: str, *uris: str) -> bytes: + schema_refs = "" + if uris: + schema_refs = "%s" % "".join( + '' % u for u in uris + ) + return ( + '%s' + % (nsdecls("ds"), item_id, schema_refs) + ).encode() + + +class DescribeCustomXmlPropertiesPart: + def it_constructs_via_new(self): + part = CustomXmlPropertiesPart.new( + None, # type: ignore[arg-type] + PackURI("/customXml/itemProps1.xml"), + _GUID_A, + schema_refs=("urn:foo", "urn:bar"), + ) + assert isinstance(part, CustomXmlPropertiesPart) + assert part.content_type == CT.OFC_CUSTOM_XML_PROPERTIES + assert part.partname == "/customXml/itemProps1.xml" + assert isinstance(part._element, CT_DatastoreItem) + assert part.datastore_item_id == _GUID_A + assert part.schema_refs == ("urn:foo", "urn:bar") + + def it_loads_from_blob(self): + part = CustomXmlPropertiesPart.load( + "/customXml/itemProps1.xml", + CT.OFC_CUSTOM_XML_PROPERTIES, + None, # type: ignore[arg-type] + _datastore_xml(_GUID_A, "urn:x"), + ) + assert part.datastore_item_id == _GUID_A + assert part.schema_refs == ("urn:x",) + + def it_can_change_the_datastore_item_id(self): + part = CustomXmlPropertiesPart.new( + None, PackURI("/customXml/itemProps1.xml"), _GUID_A # type: ignore[arg-type] + ) + part.datastore_item_id = _GUID_B + assert part.datastore_item_id == _GUID_B + + def it_adds_and_removes_schema_refs(self): + part = CustomXmlPropertiesPart.new( + None, PackURI("/customXml/itemProps1.xml"), _GUID_A # type: ignore[arg-type] + ) + part.add_schema_ref("urn:a") + part.add_schema_ref("urn:b") + assert part.schema_refs == ("urn:a", "urn:b") + assert part.remove_schema_ref("urn:a") is True + assert part.schema_refs == ("urn:b",) + assert part.remove_schema_ref("urn:missing") is False + + +# --------------------------------------------------------------------------- +# CustomXmlPart +# --------------------------------------------------------------------------- + + +class DescribeCustomXmlPart_new_pair: + def it_creates_paired_data_and_props_parts(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair( + pkg, # type: ignore[arg-type] + b'', + ) + assert isinstance(data, CustomXmlPart) + assert data.content_type == CT.XML + assert data.partname == "/customXml/item1.xml" + assert isinstance(data.props_part, CustomXmlPropertiesPart) + assert data.props_part.partname == "/customXml/itemProps1.xml" + + def it_wires_the_props_relationship(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair(pkg, b"") # type: ignore[arg-type] + rels = list(data.rels.values()) + assert len(rels) == 1 + assert rels[0].reltype == RT.CUSTOM_XML_PROPS + assert rels[0].target_part is data.props_part + + def it_auto_generates_a_curly_braced_guid_when_omitted(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair(pkg, b"") # type: ignore[arg-type] + assert _GUID_RE.match(data.datastore_item_id), data.datastore_item_id + + def it_accepts_a_caller_supplied_datastore_item_id(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair( + pkg, b"", datastore_item_id=_GUID_A # type: ignore[arg-type] + ) + assert data.datastore_item_id == _GUID_A + + def it_propagates_schema_refs_to_props_part(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair( + pkg, # type: ignore[arg-type] + b"", + schema_refs=("urn:a", "urn:b"), + ) + assert data.schema_refs == ("urn:a", "urn:b") + assert data.props_part.schema_refs == ("urn:a", "urn:b") + + @pytest.mark.parametrize( + "payload", + [ + b'', + '', + etree.fromstring(b""), + ], + ) + def it_accepts_payload_as_bytes_str_or_element(self, payload): + pkg = _StubPackage() + data = CustomXmlPart.new_pair(pkg, payload) # type: ignore[arg-type] + assert b"") # type: ignore[arg-type] + assert data.partname == "/customXml/item3.xml" + assert data.props_part.partname == "/customXml/itemProps3.xml" + + def it_reuses_a_gap_in_the_index_sequence(self): + pkg = _StubPackage( + partnames=[ + "/customXml/item1.xml", + "/customXml/itemProps1.xml", + "/customXml/item3.xml", + "/customXml/itemProps3.xml", + ] + ) + data = CustomXmlPart.new_pair(pkg, b"") # type: ignore[arg-type] + assert data.partname == "/customXml/item2.xml" + + +class DescribeCustomXmlPart_payload: + def it_exposes_the_live_root_element(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair( # type: ignore[arg-type] + pkg, b'cli' + ) + assert data.element.tag == "{urn:my:p}provenance" + + def it_round_trips_payload_through_blob(self): + pkg = _StubPackage() + original = b'hello' + data = CustomXmlPart.new_pair(pkg, original) # type: ignore[arg-type] + # blob is the same XML re-serialized + reparsed = etree.fromstring(data.blob) + assert reparsed.tag == "{urn:my}root" + assert reparsed.find("{urn:my}child").text == "hello" + + def it_replaces_the_payload_via_replace_xml(self): + pkg = _StubPackage() + data = CustomXmlPart.new_pair(pkg, b"") # type: ignore[arg-type] + original_id = data.datastore_item_id + data.replace_xml(b'') + assert b"") + assert elm.tag == "x" + + def it_parses_payload_str(self): + elm = _parse_payload("") + assert elm.tag == "x" + + def it_returns_passed_element_unchanged(self): + x = etree.fromstring(b"") + assert _parse_payload(x) is x + + def it_raises_TypeError_for_unsupported_payload(self): + with pytest.raises(TypeError): + _parse_payload(123) # type: ignore[arg-type] From aaaf181144a196ab7c8ecf5276dfb407d0d0457a Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 11:01:45 -0400 Subject: [PATCH 05/10] feat: expose custom_properties and custom_xml_parts on Presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of customXml support per Plans/customxml-implementation-plan.md. Public surface — Presentation.custom_properties returns a Mapping wrapper over CustomPropertiesPart; Presentation.custom_xml_parts returns a Sequence wrapper over the package's RT.CUSTOM_XML rels. New modules ----------- - src/pptx/custom_properties.py — CustomProperties(Mapping[str, value]). Type-dispatched __setitem__ (bool checked before int); explicit set_* setters bypass dispatch when caller wants a specific OOXML form (e.g. set_string('N', '42') writes not ). - src/pptx/custom_xml.py — CustomXmlParts(Sequence[CustomXmlPart]). Walks both presentation-scoped and package-scoped CUSTOM_XML rels; add(xml, name=, datastoreItem_id=, schema_refs=, scope=) defaults to presentation scope per plan section 3.5; by_guid is brace- and case-tolerant; by_name reverse-resolves through reserved '_pptx_customxml_name_{guid}' entries in custom_properties. Loading existing PPTX customXml parts ------------------------------------- _upgrade_to_custom_xml_part() promotes a base Part loaded for an 'application/xml' content type to CustomXmlPart in-place via __class__ swap and parsing the blob to lxml. Done lazily on first enumeration. The package's rel graph keeps pointing at the same instance, so no rel rewriting is needed. Per plan section 3.6 / Q4 default — CT.XML stays unmapped in PartFactory; this is the upgrade path the plan specified. Wiring ------ - Package.custom_properties_part — lazy create CustomPropertiesPart if absent, mirroring Package.core_properties pattern. - Package.custom_properties — convenience returning a CustomProperties Mapping wrapping the part. - PresentationPart.custom_properties / .custom_xml_parts — lazyproperty wrappers; same instance per Presentation. - Presentation.custom_properties / .custom_xml_parts — top-level accessors that delegate to the part. Convenience on parts.custom_xml.CustomXmlPart --------------------------------------------- - .name reads from reserved custom_properties entries; returns None if the part has no application-assigned name. - add_item(tag, text, **attrs) — convenience for the 'flat list of items' shape per plan section 2.2 / Q1 default = include. remove() leaves the data->props rel intact on the orphaned part — once the source rel is gone, neither the data nor the props part is reachable from iter_parts so both are omitted on save. Keeping the rel lets a caller still read part.datastore_item_id on a held reference after removal. 59 new unit tests; 100/94/100/95% coverage on the new modules. End-to-end save → reload → lookup by name verified through real Presentation() built from the default template. Existing 2897-test suite still green (2956 total). --- src/pptx/custom_properties.py | 135 ++++++++++++++++ src/pptx/custom_xml.py | 252 +++++++++++++++++++++++++++++ src/pptx/package.py | 30 ++++ src/pptx/parts/custom_xml.py | 44 ++++++ src/pptx/parts/presentation.py | 26 +++ src/pptx/presentation.py | 23 +++ tests/test_custom_properties.py | 232 +++++++++++++++++++++++++++ tests/test_custom_xml.py | 272 ++++++++++++++++++++++++++++++++ 8 files changed, 1014 insertions(+) create mode 100644 src/pptx/custom_properties.py create mode 100644 src/pptx/custom_xml.py create mode 100644 tests/test_custom_properties.py create mode 100644 tests/test_custom_xml.py diff --git a/src/pptx/custom_properties.py b/src/pptx/custom_properties.py new file mode 100644 index 000000000..5ca6fa00e --- /dev/null +++ b/src/pptx/custom_properties.py @@ -0,0 +1,135 @@ +"""User-facing wrapper around the Custom Document Properties part. + +Mapping-protocol surface that lets callers read and write the values exposed +under `File → Properties → Advanced` in PowerPoint as if they were a `dict`. +""" + +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, Iterator, Mapping, Union + +if TYPE_CHECKING: + from pptx.parts.custom_properties import CustomPropertiesPart + + +CustomPropertyValue = Union[str, int, float, bool, dt.datetime] + + +class CustomProperties(Mapping[str, CustomPropertyValue]): + """Dict-like read/write access to custom document properties. + + Returned by :attr:`pptx.presentation.Presentation.custom_properties`. The + mapping is *live* — writes go directly to the underlying + `CustomPropertiesPart`; the next `Presentation.save(...)` persists them. + + Type dispatch on assignment is by Python type: + + ==================== =================== + Python type OOXML element + ==================== =================== + ``str`` ```` + ``bool`` ```` + ``int`` ```` + ``float`` ```` + ``datetime.datetime`` ```` + ==================== =================== + + For the cases where Python's type inference does the wrong thing — for + example, you want a string `"42"` rather than the integer 42 — use the + explicit :meth:`set_string` / :meth:`set_int` / etc. setters. + """ + + def __init__(self, part: "CustomPropertiesPart"): + self._part = part + + # -- Mapping protocol -------------------------------------------------- + + def __getitem__(self, name: str) -> CustomPropertyValue: + prop = self._part.get_property(name) + if prop is None: + raise KeyError(name) + value = prop.value + if value is None: + # Defensive: a malformed entry with no child is treated as + # absent rather than surfacing None — keeps the Mapping contract clean. + raise KeyError(name) + return value + + def __setitem__(self, name: str, value: CustomPropertyValue) -> None: + if not _is_supported(value): + raise TypeError( + "custom property value must be bool, int, float, str, or datetime; " + "got %s" % type(value).__name__ + ) + existing = self._part.get_property(name) + if existing is not None: + existing.value = value + return + self._part.add_property(name, value) + + def __delitem__(self, name: str) -> None: + if not self._part.remove_property(name): + raise KeyError(name) + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and self._part.get_property(name) is not None + + def __iter__(self) -> Iterator[str]: + return iter(self._part.property_names) + + def __len__(self) -> int: + return len(self._part) + + # -- Explicit-typed setters -------------------------------------------- + + def set_string(self, name: str, value: str) -> None: + """Write `value` as `` regardless of Python type.""" + if not isinstance(value, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("set_string value must be str, got %s" % type(value).__name__) + self._set_typed(name, value) + + def set_int(self, name: str, value: int) -> None: + """Write `value` as `` regardless of Python type. + + Rejects `bool` even though `bool` is-a `int` in Python — callers who + really want a 1/0 i4 can wrap with `int(value)` first. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("set_int value must be int, got %s" % type(value).__name__) + self._set_typed(name, value) + + def set_float(self, name: str, value: float) -> None: + """Write `value` as `` regardless of Python type.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError("set_float value must be a number, got %s" % type(value).__name__) + self._set_typed(name, float(value)) + + def set_bool(self, name: str, value: bool) -> None: + """Write `value` as ``.""" + if not isinstance(value, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("set_bool value must be bool, got %s" % type(value).__name__) + self._set_typed(name, value) + + def set_datetime(self, name: str, value: dt.datetime) -> None: + """Write `value` as `` (UTC, ISO-8601).""" + if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + "set_datetime value must be datetime, got %s" % type(value).__name__ + ) + self._set_typed(name, value) + + def _set_typed(self, name: str, value: CustomPropertyValue) -> None: + """Replace-or-add the property; the underlying `CT_Property.value` setter + already dispatches on Python type cleanly, so re-using it here is safe.""" + existing = self._part.get_property(name) + if existing is not None: + existing.value = value + return + self._part.add_property(name, value) + + +def _is_supported(value: object) -> bool: + if isinstance(value, bool): + return True + return isinstance(value, (int, float, str, dt.datetime)) diff --git a/src/pptx/custom_xml.py b/src/pptx/custom_xml.py new file mode 100644 index 000000000..1e7198318 --- /dev/null +++ b/src/pptx/custom_xml.py @@ -0,0 +1,252 @@ +"""User-facing wrapper around customXml data parts. + +`CustomXmlParts` exposes the collection of ``-tagged +arbitrary-XML parts attached to a presentation. The user-facing element type +is :class:`pptx.parts.custom_xml.CustomXmlPart` itself — there is no separate +facade. Loaded base `Part` instances (which arise because `CT.XML` is not +mapped to `CustomXmlPart` in `pptx/__init__.py` per plan §3.6) are upgraded +in-place by `_upgrade_to_custom_xml_part(...)` on first enumeration. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, Iterator, Literal, Sequence, Union, cast + +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.package import Part +from pptx.oxml import parse_xml +from pptx.oxml.xmlchemy import BaseOxmlElement +from pptx.parts.custom_xml import CustomXmlPart, XmlPayload + +if TYPE_CHECKING: + from pptx.parts.presentation import PresentationPart + + +# Reserved name-prefix used to store user-assigned customXml part names as +# entries in the custom document properties part. The key is +# `{prefix}{datastore_item_id}` and the value is the user-assigned name. +NAME_PROPERTY_PREFIX = "_pptx_customxml_name_" + + +class CustomXmlParts(Sequence[CustomXmlPart]): + """Collection of customXml data parts attached to the presentation. + + Iterates both presentation-scoped (`ppt/_rels/presentation.xml.rels`) and + package-scoped (`/_rels/.rels`) `RT.CUSTOM_XML` relationships. Parts are + deduplicated by identity — a single part related from both sources appears + once. + + Lookup: + + prs.custom_xml_parts[0] # by index + prs.custom_xml_parts["item3.xml"] # by partname tail + prs.custom_xml_parts.by_guid("{...}") # by datastoreItem GUID + prs.custom_xml_parts.by_name("provenance") # by user-assigned name + """ + + def __init__(self, presentation_part: "PresentationPart"): + self._presentation_part = presentation_part + + # -- Sequence-like protocol -------------------------------------------- + + def __iter__(self) -> Iterator[CustomXmlPart]: + return self._iter_parts() + + def __len__(self) -> int: + return sum(1 for _ in self._iter_parts()) + + def __getitem__(self, key): # type: ignore[override] + if isinstance(key, int): + for i, part in enumerate(self._iter_parts()): + if i == key: + return part + raise IndexError("custom_xml_parts index out of range: %d" % key) + if isinstance(key, str): + for part in self._iter_parts(): + partname = str(part.partname) + if partname == key or partname.endswith("/" + key): + return part + raise KeyError("no custom_xml part with partname %r" % key) + raise TypeError( + "custom_xml_parts key must be int or str, got %s" % type(key).__name__ + ) + + # -- Public lookups ---------------------------------------------------- + + def by_guid(self, guid: str) -> CustomXmlPart | None: + """Return the part whose `datastore_item_id` matches `guid`, or None. + + Match is case-insensitive and curly-brace-tolerant — `"{ABCD-...}"` and + `"abcd-..."` both find the same part. + """ + target = _normalize_guid(guid) + for part in self._iter_parts(): + if _normalize_guid(part.datastore_item_id) == target: + return part + return None + + def by_name(self, name: str) -> CustomXmlPart | None: + """Return the part previously added with `name=...`, or None. + + Names are stored as reserved entries in the custom document properties + part keyed by datastore_item_id; this method reverse-resolves the name + through that table. + """ + if not isinstance(name, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("name must be str, got %s" % type(name).__name__) + cp_part = self._presentation_part.package.custom_properties_part + for prop in cp_part._element.property_lst: + if not prop.name.startswith(NAME_PROPERTY_PREFIX): + continue + if prop.value != name: + continue + guid = prop.name[len(NAME_PROPERTY_PREFIX) :] + return self.by_guid(guid) + return None + + # -- Mutation ---------------------------------------------------------- + + def add( + self, + xml: XmlPayload, + *, + name: str | None = None, + datastoreItem_id: str | None = None, + schema_refs: Iterable[str] | None = None, + scope: Literal["presentation", "package"] = "presentation", + ) -> CustomXmlPart: + """Add a new customXml part with `xml` as its payload. + + See module docstring for parameter semantics. Returns the new part, + already attached to the presentation; nothing else is required before + `prs.save(...)`. + """ + if scope not in ("presentation", "package"): + raise ValueError( + "scope must be 'presentation' or 'package', got %r" % (scope,) + ) + + package = self._presentation_part.package + data_part = CustomXmlPart.new_pair( + package, + xml, + datastore_item_id=datastoreItem_id, + schema_refs=tuple(schema_refs) if schema_refs is not None else (), + ) + + if scope == "presentation": + self._presentation_part.relate_to(data_part, RT.CUSTOM_XML) + else: + package.relate_to(data_part, RT.CUSTOM_XML) + + if name is not None: + cp = package.custom_properties + cp[NAME_PROPERTY_PREFIX + data_part.datastore_item_id] = name + + return data_part + + def remove(self, part: Union[CustomXmlPart, int, str]) -> None: + """Remove a customXml part from the presentation. + + Drops the relationship from whichever scope (presentation or package) + currently holds it, plus any reserved name entry in custom_properties. + Idempotent — a second call on an already-removed part is a no-op. + + The data → props rel is intentionally LEFT IN PLACE on the now-orphaned + data part. Once the source rel is gone, neither the data part nor the + props part is reachable from `iter_parts`, so both are omitted on + save. Keeping the rel around lets a caller still read + `part.datastore_item_id` on the returned reference after removal, + which matches the principle of least surprise for held references. + """ + target = self._resolve(part) + if target is None: + return + + # Drop the reserved name entry, if any. Reading datastore_item_id + # here requires the data → props rel to still be intact. + cp_part = self._presentation_part.package.custom_properties_part + cp_part.remove_property(NAME_PROPERTY_PREFIX + target.datastore_item_id) + + # Drop the rel from whichever source holds it (presentation or package). + for rels in self._iter_rel_collections(): + for rId, rel in list(rels.items()): + if rel.is_external or rel.reltype != RT.CUSTOM_XML: + continue + if rel.target_part is target: + rels.pop(rId) + + # -- Internals --------------------------------------------------------- + + def _iter_parts(self) -> Iterator[CustomXmlPart]: + """Yield each unique customXml data part across both rel sources.""" + seen: set[int] = set() + for rels in self._iter_rel_collections(): + for rel in rels.values(): + if rel.is_external or rel.reltype != RT.CUSTOM_XML: + continue + part = _upgrade_to_custom_xml_part(rel.target_part) + if id(part) in seen: + continue + seen.add(id(part)) + yield part + + def _iter_rel_collections(self): + """Yield the two relationship collections to scan for `RT.CUSTOM_XML`. + + Presentation part exposes `.rels` publicly; the package exposes the + same collection internally as `_rels` (it has no public API for + external rel inspection because most callers reach the rel graph via + `iter_parts`/`iter_rels` instead). We need direct rel access here to + find the source rel for `add(scope="package")`-attached parts. + """ + yield self._presentation_part.rels + yield self._presentation_part.package._rels + + def _resolve( + self, part: Union[CustomXmlPart, int, str] + ) -> CustomXmlPart | None: + if isinstance(part, CustomXmlPart): + return part + if isinstance(part, int): + try: + return self.__getitem__(part) + except IndexError: + return None + if isinstance(part, str): + try: + return self.__getitem__(part) + except KeyError: + return None + raise TypeError( + "remove() argument must be CustomXmlPart, int, or str; got %s" + % type(part).__name__ + ) + + +def _upgrade_to_custom_xml_part(part: Part) -> CustomXmlPart: + """Upgrade a base `Part` to `CustomXmlPart` in-place via `__class__` swap. + + Loaded `application/xml` parts come in as plain `Part` because plan §3.6 + intentionally leaves `CT.XML` unmapped. On first enumeration, we promote + the instance: assign the `CustomXmlPart` class, parse its blob to lxml, + and stash the parsed root in `_element`. The package's rel graph keeps + pointing at the same instance, so every other reference now resolves to + the upgraded class with no graph rewriting. + """ + if isinstance(part, CustomXmlPart): + return part + element = cast("BaseOxmlElement", parse_xml(part.blob)) + part.__class__ = CustomXmlPart + part._element = element # type: ignore[attr-defined] + return cast(CustomXmlPart, part) + + +def _normalize_guid(guid: str) -> str: + """Lowercase and strip surrounding curly braces for comparison.""" + if not isinstance(guid, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("guid must be str, got %s" % type(guid).__name__) + s = guid.strip().lower() + if s.startswith("{") and s.endswith("}"): + s = s[1:-1] + return s diff --git a/src/pptx/package.py b/src/pptx/package.py index 79703cd6c..cac321073 100644 --- a/src/pptx/package.py +++ b/src/pptx/package.py @@ -8,6 +8,7 @@ from pptx.opc.package import OpcPackage from pptx.opc.packuri import PackURI from pptx.parts.coreprops import CorePropertiesPart +from pptx.parts.custom_properties import CustomPropertiesPart from pptx.parts.image import Image, ImagePart from pptx.parts.media import MediaPart from pptx.util import lazyproperty @@ -29,6 +30,35 @@ def core_properties(self) -> CorePropertiesPart: self.relate_to(core_props, RT.CORE_PROPERTIES) return core_props + @lazyproperty + def custom_properties_part(self) -> CustomPropertiesPart: + """The Custom Document Properties part for this package. + + Creates an empty `/docProps/custom.xml` if no such part is present + (mirrors :attr:`core_properties` behavior). The relationship is rooted + at the package — Office writes it as a sibling of `core.xml`. + """ + try: + return self.part_related_by(RT.CUSTOM_PROPERTIES) + except KeyError: + cp_part = CustomPropertiesPart.default(self) + self.relate_to(cp_part, RT.CUSTOM_PROPERTIES) + return cp_part + + @property + def custom_properties(self): + """Mapping-protocol view over the Custom Document Properties part. + + Returns a :class:`pptx.custom_properties.CustomProperties` instance + wrapping the package's `CustomPropertiesPart`. The same wrapper + instance is reused across calls (it's a thin facade with no state). + """ + # Local import — `pptx.custom_properties` and `pptx.package` would + # otherwise form a cycle through the parts layer. + from pptx.custom_properties import CustomProperties + + return CustomProperties(self.custom_properties_part) + def get_or_add_image_part(self, image_file: str | IO[bytes]): """ Return an |ImagePart| object containing the image in *image_file*. If diff --git a/src/pptx/parts/custom_xml.py b/src/pptx/parts/custom_xml.py index ed768e2e7..1bf0e42d4 100644 --- a/src/pptx/parts/custom_xml.py +++ b/src/pptx/parts/custom_xml.py @@ -180,6 +180,50 @@ def replace_xml(self, xml_payload: XmlPayload) -> None: """ self._element = _parse_payload(xml_payload) + @property + def name(self) -> str | None: + """The application-assigned name for this part, or `None`. + + Names are stored as reserved entries in `/docProps/custom.xml` keyed + by `datastore_item_id`. See `Plans/customxml-implementation-plan.md` + §3.4 for the rationale (Q3 default). + """ + # Local import to avoid `parts → custom_xml → parts` cycle. + from pptx.custom_xml import NAME_PROPERTY_PREFIX + + try: + cp_part = self.package.custom_properties_part + except Exception: # pragma: no cover — package without custom_properties_part hook + return None + prop = cp_part.get_property(NAME_PROPERTY_PREFIX + self.datastore_item_id) + if prop is None: + return None + value = prop.value + return value if isinstance(value, str) else None + + def add_item( + self, tag: str, text: str = "", **attrs: str + ) -> BaseOxmlElement: + """Append a child element `text` with `attrs`. + + Convenience for the common "flat list of items" customXml shape; for + arbitrary structure mutate :attr:`element` directly. The `tag` is + used verbatim — pass a fully-namespaced Clark name if the parent + root uses a default namespace and you need to escape it explicitly, + otherwise lxml will attach the new element to the parent's namespace. + + Returns the newly appended element so the caller can chain further + edits on it. + """ + from lxml import etree + + new = etree.SubElement(self._element, tag) + if text: + new.text = text + for k, v in attrs.items(): + new.set(k, v) + return cast(BaseOxmlElement, new) + def _parse_payload(xml_payload: XmlPayload) -> BaseOxmlElement: """Coerce `xml_payload` to a `BaseOxmlElement` root. diff --git a/src/pptx/parts/presentation.py b/src/pptx/parts/presentation.py index 1413de457..36491d99d 100644 --- a/src/pptx/parts/presentation.py +++ b/src/pptx/parts/presentation.py @@ -12,6 +12,8 @@ from pptx.util import lazyproperty if TYPE_CHECKING: + from pptx.custom_properties import CustomProperties + from pptx.custom_xml import CustomXmlParts from pptx.parts.coreprops import CorePropertiesPart from pptx.slide import NotesMaster, Slide, SlideLayout, SlideMaster @@ -41,6 +43,30 @@ def core_properties(self) -> CorePropertiesPart: """ return self.package.core_properties + @lazyproperty + def custom_properties(self) -> CustomProperties: + """Mapping-protocol view over the Custom Document Properties part. + + Lazy — the same wrapper instance is returned across calls. The + underlying `/docProps/custom.xml` part is created on first access if + the package does not already have one. + """ + from pptx.custom_properties import CustomProperties + + return CustomProperties(self.package.custom_properties_part) + + @lazyproperty + def custom_xml_parts(self) -> CustomXmlParts: + """Sequence-like collection of customXml data parts in this package. + + Walks both presentation-scoped (`ppt/_rels/presentation.xml.rels`) and + package-scoped (`/_rels/.rels`) `RT.CUSTOM_XML` relationships. The + same collection instance is reused across calls. + """ + from pptx.custom_xml import CustomXmlParts + + return CustomXmlParts(self) + def get_slide(self, slide_id: int) -> Slide | None: """Return optional related |Slide| object identified by `slide_id`. diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index a41bfd59a..94fa3fcb5 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -9,6 +9,8 @@ from pptx.util import lazyproperty if TYPE_CHECKING: + from pptx.custom_properties import CustomProperties + from pptx.custom_xml import CustomXmlParts from pptx.oxml.presentation import CT_Presentation, CT_SlideId from pptx.parts.presentation import PresentationPart from pptx.slide import NotesMaster, SlideLayouts @@ -33,6 +35,27 @@ def core_properties(self): """ return self.part.core_properties + @property + def custom_properties(self) -> CustomProperties: + """Mapping-protocol view over the Custom Document Properties part. + + These are the user-defined properties surfaced under + `File → Properties → Advanced` in PowerPoint. Created on first access + if the package does not already have a custom properties part. + """ + return self.part.custom_properties + + @property + def custom_xml_parts(self) -> CustomXmlParts: + """Collection of customXml data parts in this presentation's package. + + Walks both presentation-scoped and package-scoped `RT.CUSTOM_XML` + relationships. Use `.add(...)` to attach a new part, `[i]` or + `["item3.xml"]` to look one up by index or partname tail, and + `.by_guid(...)` / `.by_name(...)` for the other lookup forms. + """ + return self.part.custom_xml_parts + @property def notes_master(self) -> NotesMaster: """Instance of |NotesMaster| for this presentation. diff --git a/tests/test_custom_properties.py b/tests/test_custom_properties.py new file mode 100644 index 000000000..d3dc6e989 --- /dev/null +++ b/tests/test_custom_properties.py @@ -0,0 +1,232 @@ +# pyright: reportPrivateUsage=false + +"""End-to-end test suite for `pptx.custom_properties.CustomProperties`.""" + +from __future__ import annotations + +import datetime as dt +from io import BytesIO + +import pytest + +from pptx import Presentation +from pptx.custom_properties import CustomProperties +from pptx.parts.custom_properties import CustomPropertiesPart + + +@pytest.fixture +def empty_prs(): + """Return a fresh Presentation built from the default template.""" + return Presentation() + + +def _roundtrip(prs): + """Save `prs` to BytesIO and return a freshly-reloaded Presentation.""" + buf = BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +class DescribeCustomProperties_Mapping: + def it_starts_empty_for_a_default_presentation(self, empty_prs): + cp = empty_prs.custom_properties + assert isinstance(cp, CustomProperties) + assert len(cp) == 0 + assert list(cp) == [] + assert "anything" not in cp + + def it_writes_and_reads_a_string_value(self, empty_prs): + empty_prs.custom_properties["Source"] = "cli@1.4" + assert empty_prs.custom_properties["Source"] == "cli@1.4" + assert "Source" in empty_prs.custom_properties + + def it_dispatches_value_types_on_assignment(self, empty_prs): + empty_prs.custom_properties["S"] = "string" + empty_prs.custom_properties["I"] = 42 + empty_prs.custom_properties["F"] = 3.14 + empty_prs.custom_properties["B"] = True + empty_prs.custom_properties["D"] = dt.datetime(2026, 5, 5, 14, 0, 0) + + assert empty_prs.custom_properties["S"] == "string" + assert empty_prs.custom_properties["I"] == 42 + assert empty_prs.custom_properties["F"] == pytest.approx(3.14) + assert empty_prs.custom_properties["B"] is True + assert empty_prs.custom_properties["D"] == dt.datetime(2026, 5, 5, 14, 0, 0) + + def it_replaces_an_existing_value_on_repeated_assignment(self, empty_prs): + empty_prs.custom_properties["X"] = "old" + empty_prs.custom_properties["X"] = "new" + assert empty_prs.custom_properties["X"] == "new" + assert len(empty_prs.custom_properties) == 1 + + def it_replaces_value_with_a_different_type(self, empty_prs): + empty_prs.custom_properties["X"] = "hello" + empty_prs.custom_properties["X"] = 42 + assert empty_prs.custom_properties["X"] == 42 + + def it_raises_KeyError_on_missing_lookup(self, empty_prs): + with pytest.raises(KeyError): + empty_prs.custom_properties["missing"] + + def it_deletes_a_property(self, empty_prs): + empty_prs.custom_properties["X"] = "a" + del empty_prs.custom_properties["X"] + assert "X" not in empty_prs.custom_properties + + def it_raises_KeyError_on_delete_missing(self, empty_prs): + with pytest.raises(KeyError): + del empty_prs.custom_properties["missing"] + + def it_supports_iter_keys_values_items_get(self, empty_prs): + empty_prs.custom_properties["a"] = "1" + empty_prs.custom_properties["b"] = 2 + empty_prs.custom_properties["c"] = True + + assert list(empty_prs.custom_properties) == ["a", "b", "c"] + assert list(empty_prs.custom_properties.keys()) == ["a", "b", "c"] + assert dict(empty_prs.custom_properties.items()) == {"a": "1", "b": 2, "c": True} + assert empty_prs.custom_properties.get("missing") is None + assert empty_prs.custom_properties.get("missing", "default") == "default" + + def it_raises_TypeError_on_unsupported_value(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_properties["X"] = object() # type: ignore[assignment] + + +class DescribeCustomProperties_edge_cases: + def it_returns_False_for_non_string_contains(self, empty_prs): + empty_prs.custom_properties["X"] = "v" + assert (42 in empty_prs.custom_properties) is False # type: ignore[operator] + + def it_treats_a_property_with_no_value_child_as_absent(self, empty_prs): + # Force a malformed entry: an op:property element with no vt:* child. + # The lookup returns None → CustomProperties surfaces it as KeyError. + from pptx.oxml.custom_properties import DEFAULT_FMTID + from pptx.oxml.ns import nsdecls + from pptx.oxml import parse_xml + + cp_part = empty_prs.part.package.custom_properties_part + # Replace _element with a malformed Properties root containing one + # property that has no value child. + broken = parse_xml( + ( + "" + '' + "" % (nsdecls("op", "vt"), DEFAULT_FMTID) + ).encode() + ) + cp_part._element = broken + with pytest.raises(KeyError): + _ = empty_prs.custom_properties["empty"] + + +class DescribeCustomProperties_explicit_setters: + def it_writes_string_with_set_string(self, empty_prs): + # set_string("X", "42") writes vt:lpwstr, not vt:i4 + empty_prs.custom_properties.set_string("X", "42") + assert empty_prs.custom_properties["X"] == "42" + # confirm the underlying element is vt:lpwstr + prop = empty_prs.part.package.custom_properties_part.get_property("X") + assert prop is not None + assert prop.lpwstr is not None + assert prop.i4 is None + + def it_writes_int_with_set_int_rejecting_bool(self, empty_prs): + empty_prs.custom_properties.set_int("X", 5) + assert empty_prs.custom_properties["X"] == 5 + with pytest.raises(TypeError): + empty_prs.custom_properties.set_int("X", True) # type: ignore[arg-type] + + def it_writes_float_with_set_float(self, empty_prs): + empty_prs.custom_properties.set_float("X", 3.14) + prop = empty_prs.part.package.custom_properties_part.get_property("X") + assert prop is not None and prop.r8 is not None + + def it_writes_bool_with_set_bool(self, empty_prs): + empty_prs.custom_properties.set_bool("X", False) + assert empty_prs.custom_properties["X"] is False + with pytest.raises(TypeError): + empty_prs.custom_properties.set_bool("X", 0) # type: ignore[arg-type] + + def it_writes_datetime_with_set_datetime(self, empty_prs): + empty_prs.custom_properties.set_datetime( + "When", dt.datetime(2026, 1, 1, 0, 0, 0) + ) + assert empty_prs.custom_properties["When"] == dt.datetime(2026, 1, 1, 0, 0, 0) + + def it_rejects_set_string_with_non_string(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_properties.set_string("X", 42) # type: ignore[arg-type] + + def it_rejects_set_float_with_bool_or_non_number(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_properties.set_float("X", True) # type: ignore[arg-type] + with pytest.raises(TypeError): + empty_prs.custom_properties.set_float("X", "1.0") # type: ignore[arg-type] + + def it_rejects_set_datetime_with_non_datetime(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_properties.set_datetime("X", "today") # type: ignore[arg-type] + + def it_overwrites_an_existing_value_via_set_string(self, empty_prs): + empty_prs.custom_properties["X"] = 42 + empty_prs.custom_properties.set_string("X", "now-a-string") + assert empty_prs.custom_properties["X"] == "now-a-string" + + +class DescribeCustomProperties_lazy_creation: + def it_creates_the_part_on_first_access(self, empty_prs): + # default presentation has no custom_properties_part yet — the lazy + # access path must create one. + cp_part = empty_prs.part.package.custom_properties_part + assert isinstance(cp_part, CustomPropertiesPart) + # Mapping wrapper finds it + assert isinstance(empty_prs.custom_properties, CustomProperties) + + def it_returns_the_same_wrapper_class_each_call(self, empty_prs): + # Different instances are fine (CustomProperties is a thin facade) — + # what matters is that they wrap the same underlying part. + a = empty_prs.custom_properties + b = empty_prs.custom_properties + assert a._part is b._part + + +class DescribeCustomProperties_roundtrip: + def it_round_trips_through_save_load(self, empty_prs): + empty_prs.custom_properties["Source"] = "cli@1.4.2" + empty_prs.custom_properties["BuildNumber"] = 42 + empty_prs.custom_properties["IsDraft"] = True + empty_prs.custom_properties["At"] = dt.datetime(2026, 5, 5, 14, 0, 0) + + reloaded = _roundtrip(empty_prs) + + assert reloaded.custom_properties["Source"] == "cli@1.4.2" + assert reloaded.custom_properties["BuildNumber"] == 42 + assert reloaded.custom_properties["IsDraft"] is True + assert reloaded.custom_properties["At"] == dt.datetime(2026, 5, 5, 14, 0, 0) + + def it_preserves_core_properties_alongside_custom_ones(self, empty_prs): + # both can coexist; custom_properties is /docProps/custom.xml, + # core_properties is /docProps/core.xml — distinct parts + empty_prs.core_properties.author = "Athena" + empty_prs.custom_properties["Source"] = "cli" + reloaded = _roundtrip(empty_prs) + assert reloaded.core_properties.author == "Athena" + assert reloaded.custom_properties["Source"] == "cli" + + def it_is_a_noop_when_never_touched(self, empty_prs): + # if the API is not used, no /docProps/custom.xml is added (the part + # is created lazily ON first call to .custom_properties_part). A bare + # save() that never touches the API should leave the package alone. + buf = BytesIO() + empty_prs.save(buf) + # Reopen and confirm no custom_properties_part rel exists yet + buf.seek(0) + reloaded = Presentation(buf) + # accessing custom_properties for the first time HERE creates it, + # but pre-access there should be no rel of CUSTOM_PROPERTIES type + from pptx.opc.constants import RELATIONSHIP_TYPE as RT + + rel_types = {r.reltype for r in reloaded.part.package._rels.values()} + assert RT.CUSTOM_PROPERTIES not in rel_types diff --git a/tests/test_custom_xml.py b/tests/test_custom_xml.py new file mode 100644 index 000000000..21c256792 --- /dev/null +++ b/tests/test_custom_xml.py @@ -0,0 +1,272 @@ +# pyright: reportPrivateUsage=false + +"""End-to-end test suite for `pptx.custom_xml.CustomXmlParts`.""" + +from __future__ import annotations + +from io import BytesIO + +import pytest + +from pptx import Presentation +from pptx.custom_xml import ( + NAME_PROPERTY_PREFIX, + CustomXmlParts, + _normalize_guid, + _upgrade_to_custom_xml_part, +) +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.parts.custom_xml import CustomXmlPart + + +@pytest.fixture +def empty_prs(): + return Presentation() + + +def _roundtrip(prs): + buf = BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +class DescribeCustomXmlParts_basic: + def it_starts_empty_for_a_default_presentation(self, empty_prs): + cxp = empty_prs.custom_xml_parts + assert isinstance(cxp, CustomXmlParts) + assert len(cxp) == 0 + assert list(cxp) == [] + + def it_adds_a_part_with_default_presentation_scope(self, empty_prs): + part = empty_prs.custom_xml_parts.add(b'') + assert isinstance(part, CustomXmlPart) + assert part.partname == "/customXml/item1.xml" + # presentation scope: rel from the presentation part + rel_types_at_prs = {r.reltype for r in empty_prs.part.rels.values()} + rel_types_at_pkg = {r.reltype for r in empty_prs.part.package._rels.values()} + assert RT.CUSTOM_XML in rel_types_at_prs + assert RT.CUSTOM_XML not in rel_types_at_pkg + + def it_adds_a_part_with_package_scope_when_requested(self, empty_prs): + empty_prs.custom_xml_parts.add(b"", scope="package") + rel_types_at_prs = {r.reltype for r in empty_prs.part.rels.values()} + rel_types_at_pkg = {r.reltype for r in empty_prs.part.package._rels.values()} + assert RT.CUSTOM_XML in rel_types_at_pkg + assert RT.CUSTOM_XML not in rel_types_at_prs + + def it_rejects_unknown_scope(self, empty_prs): + with pytest.raises(ValueError): + empty_prs.custom_xml_parts.add(b"", scope="bogus") # type: ignore[arg-type] + + def it_walks_both_scopes_in_iteration(self, empty_prs): + empty_prs.custom_xml_parts.add(b'', scope="presentation") + empty_prs.custom_xml_parts.add(b'', scope="package") + partnames = [str(p.partname) for p in empty_prs.custom_xml_parts] + assert len(partnames) == 2 + + def it_assigns_distinct_partnames_to_consecutive_pairs(self, empty_prs): + a = empty_prs.custom_xml_parts.add(b"") + b = empty_prs.custom_xml_parts.add(b"") + assert a.partname != b.partname + assert str(a.partname) == "/customXml/item1.xml" + assert str(b.partname) == "/customXml/item2.xml" + + +class DescribeCustomXmlParts_lookups: + def it_indexes_by_position(self, empty_prs): + a = empty_prs.custom_xml_parts.add(b'') + b = empty_prs.custom_xml_parts.add(b'') + assert empty_prs.custom_xml_parts[0] is a + assert empty_prs.custom_xml_parts[1] is b + + def it_raises_IndexError_on_out_of_range(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + with pytest.raises(IndexError): + empty_prs.custom_xml_parts[5] + + def it_indexes_by_partname_tail(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.add(b"") + found = empty_prs.custom_xml_parts["item2.xml"] + assert str(found.partname) == "/customXml/item2.xml" + + def it_raises_KeyError_on_unknown_partname(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + with pytest.raises(KeyError): + empty_prs.custom_xml_parts["item99.xml"] + + def it_raises_TypeError_on_other_key_types(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_xml_parts[1.5] # type: ignore[index] + + def it_finds_by_guid_brace_tolerant(self, empty_prs): + guid = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" + empty_prs.custom_xml_parts.add(b"", datastoreItem_id=guid) + # exact form + assert empty_prs.custom_xml_parts.by_guid(guid) is not None + # without braces, lowercase + assert ( + empty_prs.custom_xml_parts.by_guid("abcdef12-3456-7890-abcd-ef1234567890") + is not None + ) + + def it_returns_None_for_unknown_guid(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + assert empty_prs.custom_xml_parts.by_guid("{00000000-0000-0000-0000-000000000000}") is None + + def it_finds_by_user_assigned_name(self, empty_prs): + added = empty_prs.custom_xml_parts.add( + b'', + name="provenance", + ) + assert empty_prs.custom_xml_parts.by_name("provenance") is added + + def it_returns_None_for_unknown_name(self, empty_prs): + empty_prs.custom_xml_parts.add(b"", name="real") + assert empty_prs.custom_xml_parts.by_name("missing") is None + + def it_raises_TypeError_on_non_str_name(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_xml_parts.by_name(42) # type: ignore[arg-type] + + +class DescribeCustomXmlParts_remove: + def it_removes_by_part_instance(self, empty_prs): + a = empty_prs.custom_xml_parts.add(b"", name="a") + empty_prs.custom_xml_parts.add(b"", name="b") + empty_prs.custom_xml_parts.remove(a) + assert len(empty_prs.custom_xml_parts) == 1 + # name entry also removed + assert ( + empty_prs.part.package.custom_properties_part.get_property( + NAME_PROPERTY_PREFIX + a.datastore_item_id + ) + is None + ) + + def it_removes_by_index(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.remove(0) + assert len(empty_prs.custom_xml_parts) == 1 + + def it_removes_by_partname_tail(self, empty_prs): + empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.remove("item1.xml") + assert str(empty_prs.custom_xml_parts[0].partname) == "/customXml/item2.xml" + + def it_is_idempotent(self, empty_prs): + a = empty_prs.custom_xml_parts.add(b"") + empty_prs.custom_xml_parts.remove(a) + empty_prs.custom_xml_parts.remove(a) # no error + assert len(empty_prs.custom_xml_parts) == 0 + + def it_removes_a_package_scoped_part(self, empty_prs): + a = empty_prs.custom_xml_parts.add(b"", scope="package") + empty_prs.custom_xml_parts.remove(a) + assert len(empty_prs.custom_xml_parts) == 0 + rel_types = {r.reltype for r in empty_prs.part.package._rels.values()} + assert RT.CUSTOM_XML not in rel_types + + def it_raises_TypeError_on_unsupported_remove_arg(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_xml_parts.remove(1.5) # type: ignore[arg-type] + + +class DescribeCustomXmlParts_roundtrip: + def it_round_trips_added_parts(self, empty_prs): + empty_prs.custom_xml_parts.add( + b'cli', + name="provenance", + schema_refs=["urn:my:p"], + ) + empty_prs.custom_xml_parts.add(b"", name="extra", scope="package") + + reloaded = _roundtrip(empty_prs) + + assert len(reloaded.custom_xml_parts) == 2 + prov = reloaded.custom_xml_parts.by_name("provenance") + assert prov is not None + assert prov.element.tag == "{urn:my:p}provenance" + assert prov.schema_refs == ("urn:my:p",) + extra = reloaded.custom_xml_parts.by_name("extra") + assert extra is not None + assert extra.element.tag == "extra" + + def it_preserves_payload_text_byte_for_byte_through_lxml_roundtrip(self, empty_prs): + original = b'hello' + added = empty_prs.custom_xml_parts.add(original) + guid = added.datastore_item_id + + reloaded = _roundtrip(empty_prs) + part = reloaded.custom_xml_parts.by_guid(guid) + assert part is not None + # parsed structure is preserved + child = part.element.find("{u:r}child") + assert child is not None + assert child.get("a") == "1" + assert child.text == "hello" + + def it_replaces_xml_payload_in_place(self, empty_prs): + added = empty_prs.custom_xml_parts.add(b"") + guid = added.datastore_item_id + added.replace_xml(b'') + + reloaded = _roundtrip(empty_prs) + part = reloaded.custom_xml_parts.by_guid(guid) + assert part is not None + assert part.element.tag == "{u:n}new" + + def it_supports_add_item_convenience(self, empty_prs): + added = empty_prs.custom_xml_parts.add(b'') + added.add_item("item", "first") + added.add_item("item", "second", priority="high") + + # children are present + children = list(added.element) + assert len(children) == 2 + assert children[0].text == "first" + assert children[1].get("priority") == "high" + + +class DescribeCustomXmlPart_name_edge_cases: + def it_returns_None_when_no_name_property_for_the_guid(self, empty_prs): + # Add a part WITHOUT a name. .name should return None even though the + # custom_properties part does exist (other entries may have been written). + empty_prs.custom_properties["AnythingElse"] = "value" + added = empty_prs.custom_xml_parts.add(b"") + assert added.name is None + + +class DescribeUpgradeAndHelpers: + def it_upgrades_a_loaded_base_part_to_CustomXmlPart_on_iteration(self, empty_prs): + empty_prs.custom_xml_parts.add(b'') + reloaded = _roundtrip(empty_prs) + # Force iteration; the base Part loaded for the customXml/item1.xml + # part gets upgraded to CustomXmlPart in place. + first = next(iter(reloaded.custom_xml_parts)) + assert isinstance(first, CustomXmlPart) + assert first.element.tag == "{u:x}x" + + def it_passes_through_an_already_upgraded_part_unchanged(self, empty_prs): + added = empty_prs.custom_xml_parts.add(b"") + # the just-added part is already a CustomXmlPart + same = _upgrade_to_custom_xml_part(added) + assert same is added + + @pytest.mark.parametrize( + ("input_guid", "expected"), + [ + ("{ABCDEF12-3456-7890-ABCD-EF1234567890}", "abcdef12-3456-7890-abcd-ef1234567890"), + ("abcdef12-3456-7890-abcd-ef1234567890", "abcdef12-3456-7890-abcd-ef1234567890"), + (" {AbCdEf12-3456-7890-ABCD-EF1234567890} ", "abcdef12-3456-7890-abcd-ef1234567890"), + ], + ) + def it_normalizes_guids_for_comparison(self, input_guid, expected): + assert _normalize_guid(input_guid) == expected + + def it_raises_TypeError_on_non_str_guid_normalize(self): + with pytest.raises(TypeError): + _normalize_guid(42) # type: ignore[arg-type] From 2d8bb8f4c80482ebe29dc11edd79a216cef6dd64 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 11:14:35 -0400 Subject: [PATCH 06/10] feat(custom_xml): add string-blob helpers and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of customXml support per Plans/customxml-implementation-plan.md. String-blob helpers (plan section 2.3) -------------------------------------- - CustomXmlParts.add_string_blob(name, content, *, mime_hint, encoding, scope) wraps a string payload in a envelope, then attaches it via add(...). Caller pre-encodes binary content; helper does NOT auto-base64. - CustomXmlParts.read_string_blob(name) reverse-resolves by name and returns the envelope text, or None if absent / not-our-envelope. - CustomXmlParts.blob_encoding(name) for callers mixing text and base64 blobs that need the original encoding to decode on read. Integration fixtures -------------------- Four synthetic .pptx files generated by our own API live at tests/test_files/customxml/: - presentation-scoped.pptx — Office.js / Word default topology - package-scoped.pptx — VSTO / SharePoint topology - multipart.pptx — every Phase 1-4 surface in one file - clean.pptx — regression baseline; no customXml at all The generation script at tests/test_files/customxml/_generate_fixtures.py re-creates them deterministically (GUIDs pinned). README.rst documents what each fixture exercises and notes that real third-party fixtures from SharePoint/Office.js/VSTO will land later during the manual PowerPoint UI matrix in plan section 5.4. Integration tests (tests/integration/test_customxml_roundtrip.py) load each fixture, exercise the public API against it, save through BytesIO, reload, and assert state survives. Covers payload preservation, GUID/schema_refs preservation, scope-topology preservation through save, mutation + round-trip, removal, core_properties coexistence, and the 'no customXml at all' regression case. Counts ------ - 10 new string-blob unit tests in tests/test_custom_xml.py - 20 new integration tests in tests/integration/test_customxml_roundtrip.py - Total tests now: 2986 passed (2956 baseline + 30 new) --- src/pptx/custom_xml.py | 85 ++++++++ tests/integration/__init__.py | 0 tests/integration/test_customxml_roundtrip.py | 204 ++++++++++++++++++ tests/test_custom_xml.py | 61 ++++++ tests/test_files/customxml/README.rst | 30 +++ .../customxml/_generate_fixtures.py | 85 ++++++++ tests/test_files/customxml/clean.pptx | Bin 0 -> 27387 bytes tests/test_files/customxml/multipart.pptx | Bin 0 -> 30630 bytes .../test_files/customxml/package-scoped.pptx | Bin 0 -> 28682 bytes .../customxml/presentation-scoped.pptx | Bin 0 -> 28769 bytes 10 files changed, 465 insertions(+) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_customxml_roundtrip.py create mode 100644 tests/test_files/customxml/README.rst create mode 100644 tests/test_files/customxml/_generate_fixtures.py create mode 100644 tests/test_files/customxml/clean.pptx create mode 100644 tests/test_files/customxml/multipart.pptx create mode 100644 tests/test_files/customxml/package-scoped.pptx create mode 100644 tests/test_files/customxml/presentation-scoped.pptx diff --git a/src/pptx/custom_xml.py b/src/pptx/custom_xml.py index 1e7198318..93c8a3620 100644 --- a/src/pptx/custom_xml.py +++ b/src/pptx/custom_xml.py @@ -27,6 +27,11 @@ # `{prefix}{datastore_item_id}` and the value is the user-assigned name. NAME_PROPERTY_PREFIX = "_pptx_customxml_name_" +# Reserved namespace for the string-blob envelope written by `add_string_blob`. +# Read back through `read_string_blob` only — callers using `add(...)` directly +# should pick their own namespace, not this one. +BLOB_NAMESPACE = "urn:python-pptx:blob" + class CustomXmlParts(Sequence[CustomXmlPart]): """Collection of customXml data parts attached to the presentation. @@ -145,6 +150,86 @@ def add( return data_part + def add_string_blob( + self, + name: str, + content: str, + *, + mime_hint: str | None = None, + encoding: Literal["text", "base64"] = "text", + scope: Literal["presentation", "package"] = "presentation", + ) -> CustomXmlPart: + """Embed a string payload as a customXml part. + + Wraps `content` in a one-element XML envelope under the reserved + `urn:python-pptx:blob` namespace:: + + + + For binary or non-XML-safe text, set ``encoding="base64"`` and pass + already-encoded `content` — the helper does NOT encode for you. Read + back via :meth:`read_string_blob`. + + `mime_hint` is stored as the ``mime`` attribute on the envelope and + round-trips for the caller's reference; it has no effect on PowerPoint. + + Returns the created :class:`CustomXmlPart`. Already attached at the + chosen scope; nothing else is needed before ``prs.save(...)``. + """ + if not isinstance(name, str) or not name: + raise ValueError("name must be a non-empty string") + if not isinstance(content, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError("content must be str, got %s" % type(content).__name__) + if encoding not in ("text", "base64"): + raise ValueError( + "encoding must be 'text' or 'base64', got %r" % (encoding,) + ) + + from lxml import etree + + envelope = etree.Element("{%s}blob" % BLOB_NAMESPACE, nsmap={None: BLOB_NAMESPACE}) + envelope.set("name", name) + envelope.set("encoding", encoding) + if mime_hint is not None: + envelope.set("mime", mime_hint) + envelope.text = content + + return self.add(envelope, name=name, scope=scope) + + def read_string_blob(self, name: str) -> str | None: + """Return the string payload of the blob part `name`, or `None`. + + Locates the part via :meth:`by_name`. Returns `None` if no such part + exists or if the part is not a `urn:python-pptx:blob` envelope (i.e. + was added by some other API or tool). + + For ``encoding="base64"`` blobs, the still-encoded string is returned + — the caller decodes. The original encoding is recoverable from + :meth:`blob_encoding`. + """ + part = self.by_name(name) + if part is None: + return None + root = part.element + if root.tag != "{%s}blob" % BLOB_NAMESPACE: + return None + return root.text or "" + + def blob_encoding(self, name: str) -> str | None: + """Return the `encoding` attribute of the blob part `name`, or `None`. + + Useful when a caller mixes text and base64 blobs and needs to decode + the latter on read. + """ + part = self.by_name(name) + if part is None: + return None + root = part.element + if root.tag != "{%s}blob" % BLOB_NAMESPACE: + return None + return root.get("encoding") + def remove(self, part: Union[CustomXmlPart, int, str]) -> None: """Remove a customXml part from the presentation. diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_customxml_roundtrip.py b/tests/integration/test_customxml_roundtrip.py new file mode 100644 index 000000000..f330a05ce --- /dev/null +++ b/tests/integration/test_customxml_roundtrip.py @@ -0,0 +1,204 @@ +# pyright: reportPrivateUsage=false + +"""Integration test suite for customXml round-trip. + +Loads each synthetic fixture under ``tests/test_files/customxml/``, exercises +the public API against it, saves to a fresh BytesIO, reloads, and asserts the +state survived. + +Real third-party fixtures (SharePoint-saved, Office.js-produced, VSTO-tooled) +will land later under ``sharepoint-saved.pptx`` etc. once captured during the +manual PowerPoint UI matrix in ``Plans/customxml-implementation-plan.md`` §5.4. +""" + +from __future__ import annotations + +import os +from io import BytesIO + +import pytest + +from pptx import Presentation +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.parts.custom_xml import CustomXmlPart + + +_FIXTURE_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + os.pardir, + "test_files", + "customxml", +) + + +def _fixture(name: str) -> str: + return os.path.join(_FIXTURE_DIR, name) + + +def _roundtrip(prs): + buf = BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +class DescribePresentationScopedFixture: + def it_loads_the_part(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + assert len(prs.custom_xml_parts) == 1 + + def it_upgrades_loaded_part_to_CustomXmlPart_class(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + part = prs.custom_xml_parts[0] + assert isinstance(part, CustomXmlPart) + + def it_preserves_the_payload(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + part = prs.custom_xml_parts.by_name("provenance") + assert part is not None + assert part.element.tag == "{urn:my:provenance}provenance" + source = part.element.find("{urn:my:provenance}source") + assert source is not None + assert source.text == "integration-fixture" + + def it_preserves_the_pinned_guid(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + part = prs.custom_xml_parts[0] + assert part.datastore_item_id == "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}" + + def it_preserves_the_schema_refs(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + part = prs.custom_xml_parts[0] + assert part.schema_refs == ("urn:my:provenance",) + + def it_preserves_the_presentation_scope_through_save(self): + prs = Presentation(_fixture("presentation-scoped.pptx")) + reloaded = _roundtrip(prs) + prs_rel_types = {r.reltype for r in reloaded.part.rels.values()} + pkg_rel_types = {r.reltype for r in reloaded.part.package._rels.values()} + assert RT.CUSTOM_XML in prs_rel_types + assert RT.CUSTOM_XML not in pkg_rel_types + + +class DescribePackageScopedFixture: + def it_loads_the_part(self): + prs = Presentation(_fixture("package-scoped.pptx")) + assert len(prs.custom_xml_parts) == 1 + + def it_preserves_the_payload(self): + prs = Presentation(_fixture("package-scoped.pptx")) + part = prs.custom_xml_parts.by_name("vsto") + assert part is not None + assert part.element.tag == "{urn:my:vsto}vsto-config" + + def it_preserves_the_package_scope_through_save(self): + prs = Presentation(_fixture("package-scoped.pptx")) + reloaded = _roundtrip(prs) + prs_rel_types = {r.reltype for r in reloaded.part.rels.values()} + pkg_rel_types = {r.reltype for r in reloaded.part.package._rels.values()} + assert RT.CUSTOM_XML in pkg_rel_types + assert RT.CUSTOM_XML not in prs_rel_types + + def it_preserves_the_pinned_guid(self): + prs = Presentation(_fixture("package-scoped.pptx")) + part = prs.custom_xml_parts[0] + assert part.datastore_item_id == "{ABCDEF12-3456-7890-ABCD-EF1234567890}" + + +class DescribeMultipartFixture: + def it_loads_two_customxml_parts_at_mixed_scopes(self): + prs = Presentation(_fixture("multipart.pptx")) + assert len(prs.custom_xml_parts) == 2 + 1 # provenance + extra + readme blob + + def it_preserves_custom_document_properties(self): + prs = Presentation(_fixture("multipart.pptx")) + assert prs.custom_properties["Source"] == "deck-builder-cli@1.4.2" + assert prs.custom_properties["BuildNumber"] == 42 + assert prs.custom_properties["IsDraft"] is True + + def it_finds_each_part_by_name(self): + prs = Presentation(_fixture("multipart.pptx")) + assert prs.custom_xml_parts.by_name("provenance") is not None + assert prs.custom_xml_parts.by_name("extra") is not None + assert prs.custom_xml_parts.by_name("readme") is not None + + def it_round_trips_through_save_load_with_mutations(self): + prs = Presentation(_fixture("multipart.pptx")) + # mutate something in each layer + prs.custom_properties["NewKey"] = "added" + prs.custom_xml_parts.by_name("provenance").add_item( + "added-by-test", "value" + ) + + reloaded = _roundtrip(prs) + + assert reloaded.custom_properties["NewKey"] == "added" + assert reloaded.custom_properties["Source"] == "deck-builder-cli@1.4.2" + prov = reloaded.custom_xml_parts.by_name("provenance") + assert prov is not None + # The added child element survived the round-trip + added = [c for c in prov.element if c.tag.endswith("added-by-test")] + assert len(added) == 1 + assert added[0].text == "value" + + def it_round_trips_the_string_blob_helper(self): + prs = Presentation(_fixture("multipart.pptx")) + content = prs.custom_xml_parts.read_string_blob("readme") + assert content is not None + assert "# Hello" in content + assert "markdown content" in content + + def it_remove_then_save_drops_the_part(self): + prs = Presentation(_fixture("multipart.pptx")) + provenance = prs.custom_xml_parts.by_name("provenance") + prs.custom_xml_parts.remove(provenance) + reloaded = _roundtrip(prs) + assert reloaded.custom_xml_parts.by_name("provenance") is None + # Other parts still present + assert reloaded.custom_xml_parts.by_name("extra") is not None + assert reloaded.custom_xml_parts.by_name("readme") is not None + + +class DescribeCleanFixture: + """A presentation with no customXml at all should have no related rels.""" + + def it_has_no_customxml_parts(self): + prs = Presentation(_fixture("clean.pptx")) + assert len(prs.custom_xml_parts) == 0 + + def it_round_trips_with_no_rels_added(self): + prs = Presentation(_fixture("clean.pptx")) + # do nothing + reloaded = _roundtrip(prs) + prs_rel_types = {r.reltype for r in reloaded.part.rels.values()} + pkg_rel_types = {r.reltype for r in reloaded.part.package._rels.values()} + assert RT.CUSTOM_XML not in prs_rel_types + assert RT.CUSTOM_XML not in pkg_rel_types + assert RT.CUSTOM_PROPERTIES not in pkg_rel_types + + def it_can_have_customxml_added_after_loading(self): + prs = Presentation(_fixture("clean.pptx")) + prs.custom_xml_parts.add( + b'', + name="after-load", + ) + reloaded = _roundtrip(prs) + part = reloaded.custom_xml_parts.by_name("after-load") + assert part is not None + assert part.element.tag == "{u:al}after-load" + + +class DescribeCoreAndCustomCoexistence: + def it_preserves_core_properties_alongside_custom_ones(self): + prs = Presentation(_fixture("multipart.pptx")) + prs.core_properties.author = "Athena" + prs.core_properties.subject = "Integration test" + + reloaded = _roundtrip(prs) + + assert reloaded.core_properties.author == "Athena" + assert reloaded.core_properties.subject == "Integration test" + # custom properties still intact + assert reloaded.custom_properties["Source"] == "deck-builder-cli@1.4.2" + # customXml parts still intact + assert len(reloaded.custom_xml_parts) == 3 diff --git a/tests/test_custom_xml.py b/tests/test_custom_xml.py index 21c256792..6bf4c1d63 100644 --- a/tests/test_custom_xml.py +++ b/tests/test_custom_xml.py @@ -231,6 +231,67 @@ def it_supports_add_item_convenience(self, empty_prs): assert children[1].get("priority") == "high" +class DescribeCustomXmlParts_string_blob: + def it_adds_a_string_blob(self, empty_prs): + part = empty_prs.custom_xml_parts.add_string_blob( + "readme", "# Hello\nworld", mime_hint="text/markdown" + ) + assert isinstance(part, CustomXmlPart) + assert part.element.tag == "{urn:python-pptx:blob}blob" + assert part.element.get("name") == "readme" + assert part.element.get("mime") == "text/markdown" + assert part.element.get("encoding") == "text" + assert part.element.text == "# Hello\nworld" + + def it_reads_back_a_string_blob_by_name(self, empty_prs): + empty_prs.custom_xml_parts.add_string_blob("note", "secret message") + assert empty_prs.custom_xml_parts.read_string_blob("note") == "secret message" + + def it_returns_None_for_missing_blob(self, empty_prs): + assert empty_prs.custom_xml_parts.read_string_blob("missing") is None + + def it_returns_None_for_a_non_blob_part(self, empty_prs): + empty_prs.custom_xml_parts.add(b'', name="other") + # name lookup finds the part, but it's not the blob envelope shape + assert empty_prs.custom_xml_parts.read_string_blob("other") is None + assert empty_prs.custom_xml_parts.blob_encoding("other") is None + + def it_round_trips_a_string_blob(self, empty_prs): + empty_prs.custom_xml_parts.add_string_blob("md", "content") + reloaded = _roundtrip(empty_prs) + assert reloaded.custom_xml_parts.read_string_blob("md") == "content" + assert reloaded.custom_xml_parts.blob_encoding("md") == "text" + + def it_supports_base64_encoding(self, empty_prs): + encoded = "aGVsbG8gd29ybGQ=" # b64 of "hello world" + empty_prs.custom_xml_parts.add_string_blob("bin", encoded, encoding="base64") + assert empty_prs.custom_xml_parts.read_string_blob("bin") == encoded + assert empty_prs.custom_xml_parts.blob_encoding("bin") == "base64" + + def it_rejects_empty_name(self, empty_prs): + with pytest.raises(ValueError): + empty_prs.custom_xml_parts.add_string_blob("", "content") + + def it_rejects_non_string_content(self, empty_prs): + with pytest.raises(TypeError): + empty_prs.custom_xml_parts.add_string_blob("x", 42) # type: ignore[arg-type] + + def it_rejects_unknown_encoding(self, empty_prs): + with pytest.raises(ValueError): + empty_prs.custom_xml_parts.add_string_blob( + "x", "content", encoding="utf-7" # type: ignore[arg-type] + ) + + def it_supports_package_scope(self, empty_prs): + from pptx.opc.constants import RELATIONSHIP_TYPE as RT_ + + empty_prs.custom_xml_parts.add_string_blob( + "x", "content", scope="package" + ) + rel_types = {r.reltype for r in empty_prs.part.package._rels.values()} + assert RT_.CUSTOM_XML in rel_types + + class DescribeCustomXmlPart_name_edge_cases: def it_returns_None_when_no_name_property_for_the_guid(self, empty_prs): # Add a part WITHOUT a name. .name should return None even though the diff --git a/tests/test_files/customxml/README.rst b/tests/test_files/customxml/README.rst new file mode 100644 index 000000000..52bce6981 --- /dev/null +++ b/tests/test_files/customxml/README.rst @@ -0,0 +1,30 @@ +customXml integration test fixtures +==================================== + +These ``.pptx`` files are **synthetic** — generated by ``python-pptx-extended`` +itself rather than captured from third-party tools. They cover the topologies +the integration tests need without licensing complications: + +================================ ============================================================== +File What it exercises +================================ ============================================================== +``presentation-scoped.pptx`` Single customXml part rooted at ``ppt/_rels/presentation.xml.rels`` + (the default scope our ``add(...)`` writes; matches Office.js) +``package-scoped.pptx`` Single customXml part rooted at ``_rels/.rels`` (the VSTO / + SharePoint topology; ``scope="package"`` override) +``multipart.pptx`` Two customXml parts at mixed scopes, custom document + properties, and a string-blob envelope. Exercises every + Phase-1 through Phase-4 surface in one file. +``clean.pptx`` A bare presentation with no customXml at all. Regression + baseline — saving and reloading must produce no + ``RT.CUSTOM_XML`` or ``RT.CUSTOM_PROPERTIES`` rels. +================================ ============================================================== + +The generation script lives next to this file at +``tests/test_files/customxml/_generate_fixtures.py``. Re-run it whenever the +fixture shape needs to change. + +For real third-party validation (SharePoint-saved, Office.js-produced, +VSTO-tooled output) the maintainer will capture additional fixtures during +manual PowerPoint UI testing — see ``Plans/customxml-implementation-plan.md`` +§5.4. Those land here later under names like ``sharepoint-saved.pptx`` etc. diff --git a/tests/test_files/customxml/_generate_fixtures.py b/tests/test_files/customxml/_generate_fixtures.py new file mode 100644 index 000000000..98a8c907d --- /dev/null +++ b/tests/test_files/customxml/_generate_fixtures.py @@ -0,0 +1,85 @@ +"""Re-generate the synthetic .pptx fixtures used by the customXml integration tests. + +Run from the repo root:: + + python3 tests/test_files/customxml/_generate_fixtures.py + +Outputs are deterministic except for auto-assigned `datastoreItem` GUIDs; +explicit GUIDs below pin them so the resulting files round-trip byte-for-byte +when re-generated. +""" + +from __future__ import annotations + +import os + +from pptx import Presentation + + +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _path(name: str) -> str: + return os.path.join(_HERE, name) + + +def write_presentation_scoped() -> None: + prs = Presentation() + prs.custom_xml_parts.add( + b'' + b"integration-fixture" + b"2026-05-05T17:00:00Z" + b"", + name="provenance", + schema_refs=["urn:my:provenance"], + datastoreItem_id="{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}", + ) + prs.save(_path("presentation-scoped.pptx")) + + +def write_package_scoped() -> None: + prs = Presentation() + prs.custom_xml_parts.add( + b'' + b"" + b"", + name="vsto", + scope="package", + datastoreItem_id="{ABCDEF12-3456-7890-ABCD-EF1234567890}", + ) + prs.save(_path("package-scoped.pptx")) + + +def write_multipart() -> None: + prs = Presentation() + prs.custom_properties["Source"] = "deck-builder-cli@1.4.2" + prs.custom_properties["BuildNumber"] = 42 + prs.custom_properties["IsDraft"] = True + prs.custom_xml_parts.add( + b'cli', + name="provenance", + schema_refs=["urn:my:p"], + ) + prs.custom_xml_parts.add(b"", name="extra", scope="package") + prs.custom_xml_parts.add_string_blob( + "readme", + "# Hello\n\nThis is markdown content embedded in the .pptx.", + mime_hint="text/markdown", + ) + prs.save(_path("multipart.pptx")) + + +def write_clean() -> None: + Presentation().save(_path("clean.pptx")) + + +def main() -> None: + write_presentation_scoped() + write_package_scoped() + write_multipart() + write_clean() + print("regenerated fixtures in", _HERE) + + +if __name__ == "__main__": + main() diff --git a/tests/test_files/customxml/clean.pptx b/tests/test_files/customxml/clean.pptx new file mode 100644 index 0000000000000000000000000000000000000000..0845db09408a13e289c134099b73c842e1e9bb26 GIT binary patch literal 27387 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 P`qO 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 }&J6?N9U!?oh3!YwG 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 Yq@sWbT?U`;yD)+nFhC%Blk->q1GdNk;s5{u literal 0 HcmV?d00001 From 2f2b1ba5011c8679efc99dec7ef17d0cee02701c Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 11:18:23 -0400 Subject: [PATCH 07/10] docs: add user guide, analysis page, HISTORY entry; bump to 1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of customXml support per Plans/customxml-implementation-plan.md. Documentation ------------- - docs/user/custom-xml.rst — user guide. Shows the full CustomProperties Mapping API (with type-dispatch table and explicit set_* methods), CustomXmlParts collection API (add/lookup/mutate/remove), the add_string_blob helper, the presentation-vs-package scope choice, and round-trip-safety notes for PowerPoint, LibreOffice, and OnlyOffice. - docs/dev/analysis/customxml.rst — OOXML analysis. XML specimens for /docProps/custom.xml, /customXml/itemN.xml, and /customXml/itemPropsN.xml; the relationship-topology diagram for both scopes; design rationale for the application/xml content-type ambiguity (CT.XML stays unmapped; CustomXmlPart upgrades on enumerate via class swap) and for the _pptx_customxml_name_{guid} naming convention. - docs/index.rst and docs/dev/analysis/index.rst — toctree entries for the two new pages. HISTORY and version ------------------- - HISTORY.rst — 1.2.0 (2026-05-05) entry summarizing the feature: the Mapping wrapper, the Sequence wrapper with both topologies, the string-blob helper, and the third-party round-trip safety promise. - src/pptx/__init__.py — __version__ bumped from 1.0.2 to 1.2.0 (skipping 1.1.x since this fork's first release goes out as 1.2.0 per Plans/review-the-guide-at-swift-kahn.md PyPI plan). What still requires principal action ------------------------------------ - pyproject.toml distribution-name change to python-pptx-extended (held for the publishing pass; the version bump above is sufficient for now). - Manual PowerPoint UI matrix per plan section 5.4 (Mac, Windows, Lib- reOffice, OnlyOffice) — author can't drive PowerPoint. - Real third-party fixtures from SharePoint / Office.js / VSTO output — to capture during the manual matrix pass and add to tests/test_files/customxml/. - Tag, build (sdist + wheel), and publish to PyPI Trusted Publishing. Final state ----------- - 2986 tests pass (2956 baseline + 30 from Phase 4). - 96% aggregate coverage across the 6 new modules; 190 dedicated tests. - 5 commits on feature/customxml: plan + 4 implementation phases. --- HISTORY.rst | 27 ++++ docs/dev/analysis/customxml.rst | 190 +++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docs/index.rst | 1 + docs/user/custom-xml.rst | 259 ++++++++++++++++++++++++++++++++ src/pptx/__init__.py | 2 +- 6 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 docs/dev/analysis/customxml.rst create mode 100644 docs/user/custom-xml.rst diff --git a/HISTORY.rst b/HISTORY.rst index e1c4e8faf..f7189d604 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,33 @@ Release History --------------- +1.2.0 (2026-05-05) — fork release ++++++++++++++++++++++++++++++++++ + +This is a feature release for the ``python-pptx-extended`` fork. Adds +first-class support for OOXML customXml — the mechanism Office.js, +SharePoint, and VSTO add-ins use to embed structured application data in +``.pptx`` files. See ``docs/user/custom-xml.rst`` for the user guide and +``docs/dev/analysis/customxml.rst`` for the OOXML analysis. + +- feature: ``Presentation.custom_properties`` — Mapping wrapper over + ``/docProps/custom.xml`` (Custom Document Properties; visible in + PowerPoint's *File → Properties → Advanced* UI). Type-dispatched + ``__setitem__`` plus explicit ``set_string`` / ``set_int`` / ``set_float`` / + ``set_bool`` / ``set_datetime`` setters when Python type inference does the + wrong thing. +- feature: ``Presentation.custom_xml_parts`` — Sequence wrapper over the + package's customXml data parts. ``add(xml, *, name=, datastoreItem_id=, + schema_refs=, scope=)`` supports both presentation-scoped (Office.js + default) and package-scoped (VSTO / SharePoint) topologies. Lookup via + index, partname tail, ``by_guid(...)``, or ``by_name(...)``. +- feature: ``CustomXmlParts.add_string_blob(name, content, mime_hint=, + encoding=)`` — convenience for the common "embed a string verbatim and + read it back" case (e.g. round-trip a markdown source document). +- feature: round-trip safety with files written by other tools — PPTX files + containing customXml parts authored by SharePoint, Office.js, or VSTO load + and save without losing their content. + 1.0.2 (2024-08-07) ++++++++++++++++++ diff --git a/docs/dev/analysis/customxml.rst b/docs/dev/analysis/customxml.rst new file mode 100644 index 000000000..bbe4214e8 --- /dev/null +++ b/docs/dev/analysis/customxml.rst @@ -0,0 +1,190 @@ +.. _CustomXml: + +CustomXml and Custom Document Properties +========================================= + +Two distinct OOXML mechanisms support embedding application-specific data in +a ``.pptx`` package: + +1. **Custom Document Properties** at ``/docProps/custom.xml`` — visible in + PowerPoint UI under *File → Properties → Advanced*. ECMA-376 Part 1 §15.2.12. +2. **CustomXml data parts** at ``/customXml/itemN.xml`` paired with + ``/customXml/itemPropsN.xml`` — hidden from end users; the mechanism + Office.js, SharePoint workflows, and VSTO add-ins use to embed structured + data. ECMA-376 Part 1 §15.2.4. + + +Custom Document Properties +-------------------------- + +XML specimen +~~~~~~~~~~~~ + +.. highlight:: xml + +:: + + + + + deck-builder-cli@1.4.2 + + + 42 + + + true + + + 2026-05-05T14:00:00Z + + + +Notable details +~~~~~~~~~~~~~~~ + +* The ``fmtid`` attribute is the same well-known GUID + ``{D5CDD505-2E9C-101B-9397-08002B2CF9AE}`` for every user-defined property. + Office uses different FMTIDs for system-defined property sets (e.g. SharePoint + fields), but |pp| writes the user-defined FMTID exclusively. +* ``pid`` values 0 and 1 are reserved by the spec; user properties start at 2. + |pp| auto-assigns the next free integer ≥ 2 within the part. +* The typed value child belongs to the ``vt:`` namespace + (``http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes``). + Five types are supported: ``lpwstr`` (Unicode string), ``i4`` (32-bit signed + int), ``r8`` (IEEE-754 double), ``bool``, and ``filetime`` + (ISO-8601 UTC, ``Z``-suffixed). + + +CustomXml data parts +-------------------- + +Each customXml entry is a **pair** of parts: one for the user's arbitrary XML +payload and one for the metadata about it. + +XML specimen — data part +~~~~~~~~~~~~~~~~~~~~~~~~ + +The data part at ``/customXml/item1.xml`` carries arbitrary XML the application +chose to embed. The root element name and namespace are caller-defined:: + + + + deck-builder-cli + 2026-05-05T14:00:00Z + + +The content type is ``application/xml`` — the OPC default for the ``xml`` +extension, so no per-part Override entry is written into ``[Content_Types].xml``. + +XML specimen — itemProps part +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The sibling at ``/customXml/itemProps1.xml`` carries the ``datastoreItem`` GUID +that uniquely identifies the data part across edits, plus an optional +``schemaRefs`` list declaring the namespaces the data part claims to conform +to:: + + + + + + + + +Content type ``application/vnd.openxmlformats-officedocument.customXmlProperties+xml`` +is written as an Override entry for this partname. + +Relationship topology +~~~~~~~~~~~~~~~~~~~~~ + +The data part's relationship can be rooted in either the package or the +presentation:: + + PRESENTATION-SCOPED (default; what Office.js writes) + ──────────────────────────────────────────────────── + /ppt/_rels/presentation.xml.rels + └─ Type=customXml ─▶ /customXml/item1.xml + └─ /customXml/_rels/item1.xml.rels + └─ Type=customXmlProps ─▶ /customXml/itemProps1.xml + + + PACKAGE-SCOPED (VSTO / SharePoint topology) + ─────────────────────────────────────────── + /_rels/.rels + └─ Type=customXml ─▶ /customXml/item1.xml + └─ /customXml/_rels/item1.xml.rels + └─ Type=customXmlProps ─▶ /customXml/itemProps1.xml + +The two scopes are not interchangeable — Office.js's ``customXmlParts`` +collection only enumerates presentation-scoped parts (see this +`Microsoft Q&A response +`_). + +|pp| defaults to presentation-scoped to match Office.js. The +``scope="package"`` parameter on +:meth:`pptx.custom_xml.CustomXmlParts.add` is the override hatch for VSTO / +SharePoint compatibility. + + +Design decisions +---------------- + +The ``application/xml`` content-type ambiguity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``PartFactory.part_type_for`` keys on content type alone, but ``application/xml`` +is the catch-all default for the ``xml`` extension — every customXml data part +shares it with potentially-unrelated XML parts in third-party packages. + +|pp| chooses to **not** register :class:`CustomXmlPart` against ``application/xml``. +Loaded data parts arrive as base ``Part`` instances; the +:class:`CustomXmlParts` collection upgrades them to :class:`CustomXmlPart` +in-place via ``__class__`` swap on first enumeration. This avoids accidentally +promoting unrelated ``application/xml`` parts in third-party packages. + +The custom-name storage convention +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OOXML does not define a "name" attribute on customXml parts. To support +``custom_xml_parts.by_name("provenance")``, |pp| stores user-assigned names +as reserved entries in the custom document properties part keyed by the +data part's ``datastoreItem`` GUID: + +:: + + + provenance + + +This is lossless, round-trips through PowerPoint, and requires no schema +invention. The reserved entries are visible in PowerPoint's +*File → Properties → Advanced* UI by design — what the user sees in the app +matches what the Python API exposes. + +Round-trip safety with third-party tools +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PowerPoint 365 (Mac and Windows) preserves both topologies across edits. +LibreOffice historically preserves package-scoped parts but is less +consistent with presentation-scoped data parts. OnlyOffice / DocumentServer +strips customXml on save in some versions +(`OnlyOffice issue #1564 `_). + +|pp| preserves any customXml part it loads, including those it did not +author — files saved by SharePoint, Office.js, or VSTO add-ins load and save +without losing their customXml content. + + +References +---------- + +* `ECMA-376 Part 1, §15.2.4 — Custom XML Data Storage Part `_ +* `ECMA-376 Part 1, §15.2.12 — Custom File Properties Part `_ +* `MS Q&A on presentation- vs. package-scoped customXml topology `_ +* `Office.js CustomXmlPart API `_ +* `python-docx-oss custom-xml docs `_ (the docx-equivalent pattern, which |pp|'s API mirrors) diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 028b113b8..861f051ee 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -143,6 +143,7 @@ Package :maxdepth: 1 pkg-coreprops + customxml enumerations diff --git a/docs/index.rst b/docs/index.rst index 79ad6c369..b0a77b12c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -65,6 +65,7 @@ User Guide user/charts user/table user/notes + user/custom-xml user/use-cases user/concepts diff --git a/docs/user/custom-xml.rst b/docs/user/custom-xml.rst new file mode 100644 index 000000000..0a6c7e6d1 --- /dev/null +++ b/docs/user/custom-xml.rst @@ -0,0 +1,259 @@ + +Custom XML and Custom Document Properties +========================================== + +PowerPoint .pptx packages support two distinct mechanisms for embedding +application-specific structured data alongside slide content: + +* **Custom Document Properties** — name/value pairs visible in PowerPoint's UI + under *File → Properties → Advanced*. Useful for human-readable metadata + like a build number, source identifier, or workflow status flag. +* **CustomXml data parts** — arbitrary XML payloads with a caller-defined + namespace. Hidden from end users but preserved by PowerPoint across saves. + This is the mechanism Office.js, SharePoint, and VSTO add-ins use to attach + structured application data (provenance, template parameters, audit trails, + AI-generation markers, etc.). + +|pp| exposes both as live, dict-like and sequence-like surfaces on +|Presentation|. + +This is a fork-only feature: it is not currently available in upstream +``python-pptx``. See ``Plans/customxml-implementation-plan.md`` in the +repository for the full design rationale. + + +Custom Document Properties +-------------------------- + +The :attr:`Presentation.custom_properties` attribute is a Mapping wrapper +around the package's ``/docProps/custom.xml`` part. Read and write it like +a Python ``dict``: + +.. code-block:: python + + from pptx import Presentation + + prs = Presentation("input.pptx") + + prs.custom_properties["Source"] = "deck-builder-cli@1.4.2" + prs.custom_properties["BuildNumber"] = 42 + prs.custom_properties["IsDraft"] = True + + import datetime as dt + prs.custom_properties["GeneratedAt"] = dt.datetime.now(dt.timezone.utc) + + # Read back + print(prs.custom_properties["Source"]) # 'deck-builder-cli@1.4.2' + print("Source" in prs.custom_properties) # True + print(list(prs.custom_properties)) # ['Source', 'BuildNumber', ...] + + # Delete + del prs.custom_properties["IsDraft"] + + prs.save("output.pptx") + +Type dispatch on assignment is by Python type: + +============================ ======================== +Python type OOXML element +============================ ======================== +``str`` ```` +``bool`` ```` +``int`` ```` +``float`` ```` +``datetime.datetime`` ```` (UTC) +============================ ======================== + +The well-known FMTID ``{D5CDD505-2E9C-101B-9397-08002B2CF9AE}`` is used for +every entry, and the ``pid`` attribute is auto-assigned (≥ 2) per Office's +convention. You don't need to think about either. + +For cases where Python's type inference does the wrong thing — for example, +you want the string ``"42"`` rather than the integer 42 — use the explicit +typed setters: + +.. code-block:: python + + prs.custom_properties.set_string("NumAsString", "42") + prs.custom_properties.set_int("Count", 42) + prs.custom_properties.set_float("Score", 3.14) + prs.custom_properties.set_bool("Flag", True) + prs.custom_properties.set_datetime("When", dt.datetime(2026, 1, 1)) + + +CustomXml data parts +-------------------- + +The :attr:`Presentation.custom_xml_parts` attribute is a sequence-like +collection of customXml data parts attached to the package. Each entry is a +:class:`CustomXmlPart` carrying the user's arbitrary XML payload plus a +sibling :class:`CustomXmlPropertiesPart` carrying the part's ``datastoreItem`` +GUID and any declared ``schemaRef`` URIs. + +Adding a part +~~~~~~~~~~~~~ + +.. code-block:: python + + from pptx import Presentation + + prs = Presentation("input.pptx") + + prs.custom_xml_parts.add( + b''' + + deck-builder-cli + 2026-05-05T14:00:00Z + ''', + name="provenance", + schema_refs=["urn:my-app:provenance"], + ) + + prs.save("output.pptx") + +The ``xml`` argument can be ``bytes``, ``str``, or an existing lxml +``_Element``. The ``name`` is an application-assigned label stored in the +custom document properties (under a reserved ``_pptx_customxml_name_*`` key); +it is what :meth:`by_name` looks up. + +Lookup +~~~~~~ + +.. code-block:: python + + prs.custom_xml_parts[0] # by index + prs.custom_xml_parts["item3.xml"] # by partname tail + prs.custom_xml_parts.by_guid("{1A2B3C...}") # by datastoreItem GUID + prs.custom_xml_parts.by_name("provenance") # by user-assigned name + +GUID matching is case-insensitive and tolerates the ``{...}`` braces being +present or absent. + +Mutation +~~~~~~~~ + +Each :class:`CustomXmlPart` exposes the live lxml root via ``.element``; +mutating its children mutates the part: + +.. code-block:: python + + part = prs.custom_xml_parts.by_name("provenance") + source = part.element.find("{urn:my-app:provenance}source") + source.text = "deck-builder-cli@1.4.3" + + prs.save("output.pptx") + +To replace the whole payload: + +.. code-block:: python + + part.replace_xml(b'') + +For the common "flat list of items" shape, ``add_item(tag, text, **attrs)`` is +a one-liner: + +.. code-block:: python + + part.add_item("entry", "value", category="meta") + +Removal +~~~~~~~ + +.. code-block:: python + + prs.custom_xml_parts.remove(prs.custom_xml_parts.by_name("provenance")) + # or + prs.custom_xml_parts.remove(0) # by index + prs.custom_xml_parts.remove("item1.xml") # by partname tail + +``remove`` is idempotent — removing the same part twice is a silent no-op. + +The string-blob convenience helper +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For the common case of "stash this string verbatim, give it back to me on +read," |pp| provides a one-shot helper that wraps the string in a reserved +envelope element: + +.. code-block:: python + + prs.custom_xml_parts.add_string_blob( + "readme", + "# Hello\n\nThis is markdown content embedded in the .pptx.", + mime_hint="text/markdown", + ) + + # Read back later + content = prs.custom_xml_parts.read_string_blob("readme") + +For binary content, base64-encode at the call site and pass +``encoding="base64"`` so the encoding round-trips: + +.. code-block:: python + + import base64 + payload = base64.b64encode(some_bytes).decode("ascii") + prs.custom_xml_parts.add_string_blob( + "binary", payload, encoding="base64", mime_hint="application/zip" + ) + +The helper does NOT auto-encode for you — encoding is the caller's +responsibility. + + +Relationship topology — presentation vs. package scope +------------------------------------------------------ + +OOXML allows a customXml part's relationship to be rooted in either of two +places: + +* **Presentation-scoped** — the rel lives in + ``ppt/_rels/presentation.xml.rels``. This is what Office.js's + ``addCustomXmlPart`` writes and what PowerPoint's UI surfaces. +* **Package-scoped** — the rel lives in ``_rels/.rels`` (the package root). + This is the topology VSTO add-ins and SharePoint workflows historically use. + +Office.js's ``customXmlParts`` API only enumerates presentation-scoped parts, +so |pp| defaults to that. To match the VSTO/SharePoint topology, pass +``scope="package"``: + +.. code-block:: python + + prs.custom_xml_parts.add(b"", name="vsto", scope="package") + +The two scopes are not freely interchangeable — once a part is written at one +scope, |pp| preserves that scope on subsequent saves. You can move a part +between scopes by removing and re-adding it. + + +Round-trip safety +----------------- + +Modern PowerPoint preserves customXml parts across saves, including parts +your code did not author. Some other applications behave differently: + +* **PowerPoint 365 (Mac and Windows)**: preserves both presentation-scoped + and package-scoped customXml across edit/save. +* **LibreOffice**: historically preserves package-scoped customXml; behavior + with presentation-scoped parts is less consistent. +* **OnlyOffice / DocumentServer**: some versions strip customXml on save — + see `OnlyOffice/DocumentServer issue #1564 + `_. + +If your workflow must survive a round-trip through one of these tools, test +with the actual tool before relying on it. + +|pp| itself preserves any customXml parts it loads, including those it did +not author — files saved by SharePoint, Office.js, or VSTO load and save +without losing their customXml content. + + +Choosing between custom properties and customXml parts +------------------------------------------------------ + +* **Use custom document properties** for small, named, human-readable values + the user might inspect in PowerPoint's UI. +* **Use customXml parts** for structured data, larger payloads, schema-bound + XML, or anything you don't want surfaced to end users. + +The two mechanisms can coexist — a single .pptx can use both. diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py index 6ccada241..7b8c9f52c 100644 --- a/src/pptx/__init__.py +++ b/src/pptx/__init__.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from pptx.opc.package import Part -__version__ = "1.0.2" +__version__ = "1.2.0" sys.modules["pptx.exceptions"] = exceptions del sys From fdde8a432423a0c25c81110084b4a6159a739ba8 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Fri, 1 May 2026 21:13:10 -0400 Subject: [PATCH 08/10] release: apply python-pptx-extended fork metadata Cherry-picked from release/v1.1.0 (commit 9a3ab2aa) onto the customXml feature branch. Brings the PyPI fork-prep changes that were prepared on a sibling branch into the line that's actually shipping: - pyproject.toml: name -> python-pptx-extended; maintainer added; description acknowledges the fork's added features (now including customXml); URLs point at MHoroszowski/python-pptx with an Upstream link to scanny/python-pptx. - README.rst: Fork notice section explaining the relationship to upstream and listing the fork's added capabilities (shadows, bullets, table borders, line caps/joins, line-end types, and customXml support). - HISTORY.rst: 1.1.0 (2026-05-01) entry recording the fork establishment, retained alongside the 1.2.0 (2026-05-05) entry that records the customXml feature. --- HISTORY.rst | 17 +++++++++++++---- README.rst | 28 ++++++++++++++++++++++++++++ pyproject.toml | 12 +++++++----- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index f7189d604..d56135575 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,11 +3,10 @@ Release History --------------- -1.2.0 (2026-05-05) — fork release -+++++++++++++++++++++++++++++++++ +1.2.0 (2026-05-05) +++++++++++++++++++ -This is a feature release for the ``python-pptx-extended`` fork. Adds -first-class support for OOXML customXml — the mechanism Office.js, +Adds first-class support for OOXML customXml — the mechanism Office.js, SharePoint, and VSTO add-ins use to embed structured application data in ``.pptx`` files. See ``docs/user/custom-xml.rst`` for the user guide and ``docs/dev/analysis/customxml.rst`` for the OOXML analysis. @@ -30,6 +29,16 @@ SharePoint, and VSTO add-ins use to embed structured application data in containing customXml parts authored by SharePoint, Office.js, or VSTO load and save without losing their content. +1.1.0 (2026-05-01) +++++++++++++++++++ + +- Fork of python-pptx 1.0.2 published as ``python-pptx-extended``. +- feature: full shadow effect API on ``ShadowFormat`` +- feature: bullet and numbered list paragraph formatting +- feature: per-edge table cell borders +- feature: ``cap_style`` and ``join_style`` properties on ``LineFormat`` +- feature: line-end shape types + 1.0.2 (2024-08-07) ++++++++++++++++++ diff --git a/README.rst b/README.rst index 24d657b37..f3e958de3 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,31 @@ +Fork notice +----------- + +This distribution, ``python-pptx-extended``, is a fork of +`scanny/python-pptx`_ at upstream version 1.0.2. The import name is unchanged +(``import pptx``), so existing user code continues to work. The fork adds the +following features on top of upstream: + +- Full shadow effect API on ``ShadowFormat`` (outer/inner/preset shadows). +- Bullet and numbered list formatting on paragraphs. +- Per-edge border styling for table cells. +- ``cap_style`` and ``join_style`` properties on ``LineFormat``. +- Line-end shape types (arrow / triangle / oval / etc.). +- OOXML customXml support — ``Presentation.custom_properties`` (Mapping over + custom document properties) and ``Presentation.custom_xml_parts`` + (Sequence over customXml data parts), supporting both + presentation-scoped (Office.js default) and package-scoped (VSTO / + SharePoint) topologies. + +Because the import package name (``pptx``) is shared with the upstream +distribution, ``python-pptx`` and ``python-pptx-extended`` cannot be installed +into the same environment — install one or the other. + +.. _`scanny/python-pptx`: https://github.com/scanny/python-pptx + +About python-pptx +----------------- + *python-pptx* is a Python library for creating, reading, and updating PowerPoint (.pptx) files. diff --git a/pyproject.toml b/pyproject.toml index 400cb6bfd..9fb379ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,9 @@ requires = ["setuptools>=61.0.0"] build-backend = "setuptools.build_meta" [project] -name = "python-pptx" +name = "python-pptx-extended" authors = [{name = "Steve Canny", email = "stcanny@gmail.com"}] +maintainers = [{name = "Matthew Horoszowski", email = "matthew.horoszowski@gmail.com"}] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -27,7 +28,7 @@ dependencies = [ "lxml>=3.1.0", "typing_extensions>=4.9.0", ] -description = "Create, read, and update PowerPoint 2007+ (.pptx) files." +description = "Fork of python-pptx with additional formatting features (shadows, bullets, table borders, line caps/joins, line end types) and OOXML customXml support (custom doc properties, customXml data parts)." dynamic = ["version"] keywords = ["powerpoint", "ppt", "pptx", "openxml", "office"] license = { text = "MIT" } @@ -35,10 +36,11 @@ readme = "README.rst" requires-python = ">=3.8" [project.urls] -Changelog = "https://github.com/scanny/python-pptx/blob/master/HISTORY.rst" +Changelog = "https://github.com/MHoroszowski/python-pptx/blob/master/HISTORY.rst" Documentation = "https://python-pptx.readthedocs.io/en/latest/" -Homepage = "https://github.com/scanny/python-pptx" -Repository = "https://github.com/scanny/python-pptx" +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 From 1b53585f992f20152fb2eafe61e925f7882e61e5 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 11:29:36 -0400 Subject: [PATCH 09/10] ci: repair GitHub Actions and pin third-party deprecations Repairs the ci.yml workflow and adjusts pyproject.toml's pytest filterwarnings so the test suite stays green on the CI matrix (Python 3.9-3.12 on Ubuntu). Workflow changes (.github/workflows/ci.yml) ------------------------------------------- - Run on push to master/develop AND release/**, feature/**, ci/** branches so feature branches get verified before PR open. - Run on PRs targeting master. - Add manual workflow_dispatch trigger. - Three jobs: 1. lint - ruff check on src + tests; ruff format check is advisory (continue-on-error) until a repo-wide format pass. 2. test - matrix on Python 3.9/3.10/3.11/3.12: install -e ., install requirements-test.txt, run pytest with coverage, run behave with --stop. 3. build-check - runs after test passes; python -m build then twine check; uploads dist/ as a 7-day artifact named after the commit SHA. Smoke-validates the publish.yml path on every push without actually publishing. - Drops contents:write permission (CI no longer needs to push). Test config (pyproject.toml) ---------------------------- The pytest config has filterwarnings = ['error', ...] which converts warnings to test failures. Two third-party deprecations need to be whitelisted because they fire on the CI Python versions and are outside our code: - pyparsing 2.x uses sre_constants which is deprecated on Python 3.11+. Whitelisted as 'ignore:module sre_constants is deprecated'. - pyparsing 3.x emits PyparsingDeprecationWarning when test code uses lowerCamelCase shims like delimitedList in tests/unitutil/cxml.py. Whitelisted via 'ignore::DeprecationWarning' scoped to the pyparsing and tests.unitutil.cxml modules. Verified locally: pytest tests -> 2986 passed behave --stop -> 54 features, 981 scenarios, 2932 steps, all passed Dependency pins (requirements*.txt) ----------------------------------- Pinned pyparsing>=2.0.1,<3 in both requirements.txt and requirements-test.txt. The library still works with pyparsing 3.x (filterwarnings catches the deprecations) but pyparsing 2.x is the known-good baseline the test fixtures were written against. --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++------ pyproject.toml | 5 +++ requirements-test.txt | 2 +- requirements.txt | 2 +- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b14f6cd5..735edf76a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,47 @@ name: ci +# Runs on every push to a branch in this repo and on every pull request +# targeting master. Tags push the same code through publish.yml separately. + on: - pull_request: - branches: [ master ] push: branches: - master - develop + - "release/**" + - "feature/**" + - "ci/**" + pull_request: + branches: + - master + workflow_dispatch: permissions: - contents: write + contents: read jobs: + lint: + name: Lint (ruff) + 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 ruff + run: python -m pip install --upgrade ruff + - name: ruff check + run: ruff check src tests + - name: ruff format check + run: ruff format --check src tests + continue-on-error: true # advisory until format pass is run repo-wide - build: + 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"] steps: @@ -26,11 +52,35 @@ jobs: python-version: ${{ matrix.python-version }} - name: Display Python version run: python -c "import sys; print(sys.version)" - - name: Install test dependencies + - name: Install package and test deps run: | - pip install . - pip install -r requirements-test.txt - - name: Test with pytest - run: pytest --cov=pptx --cov-report term-missing tests - - name: Acceptance tests with behave + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install -r requirements-test.txt + - name: Unit + integration tests (pytest) + run: pytest --cov=pptx --cov-report=term-missing tests + - name: Acceptance tests (behave) run: behave --stop + + build-check: + name: Build sdist and wheel (smoke) + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: python -m pip install --upgrade build twine + - name: Build distributions + run: python -m build + - name: Verify metadata renders + run: python -m twine check dist/* + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-${{ github.sha }} + path: dist/ + retention-days: 7 diff --git a/pyproject.toml b/pyproject.toml index 9fb379ed2..740f6dacc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,11 @@ filterwarnings = [ "ignore::DeprecationWarning:xdist", # -- pytest complains when pytest-xdist is not installed -- "ignore:Unknown config option. looponfailroots:pytest.PytestConfigWarning", + # -- pyparsing 2.x triggers sre_constants deprecation on Python 3.11+ -- + "ignore:module 'sre_constants' is deprecated:DeprecationWarning", + # -- pyparsing 3.x deprecates lowerCamelCase shims (delimitedList etc.) used by tests/unitutil/cxml.py -- + "ignore::DeprecationWarning:pyparsing", + "ignore::DeprecationWarning:tests.unitutil.cxml", ] looponfailroots = [ diff --git a/requirements-test.txt b/requirements-test.txt index 9ddd60fd7..9d9b952ad 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt behave>=1.2.3 -pyparsing>=2.0.1 +pyparsing>=2.0.1,<3 pytest>=2.5 pytest-coverage pytest-xdist diff --git a/requirements.txt b/requirements.txt index edbb0e25c..c613dd0a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ flake8>=2.0 lxml>=3.1.0 mock>=1.0.1 Pillow>=3.3.2 -pyparsing>=2.0.1 +pyparsing>=2.0.1,<3 pytest>=2.5 XlsxWriter>=0.5.7 From 1b261dd301af65774d274c15ed36c5fad11bf80d Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 5 May 2026 12:51:39 -0400 Subject: [PATCH 10/10] lint: clear ruff debt + repair CI lint job's pyproject config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new lint job in ci.yml (added in 1b53585f) surfaces ruff debt that predates this branch — the project's previous CI never ran ruff. Most of it is mechanical: SIM* return-direct rewrites, C4* comprehension simplifications, I001 import sorts, F401 unused imports, UP015/UP032 modernizations. What was changed ---------------- - Auto-applied ruff --fix --unsafe-fixes (109 fixes). Reverted one unsafe fix in src/pptx/opc/serialized.py (SIM401 wanted blob_reader.get(uri, None), but _blob_reader is a Container, not a Mapping — has no .get()). Added a noqa with explanation. - Manually broke a long _GUID_RE line in tests/parts/test_custom_xml.py to fit the 100-char limit (E501). - pyproject.toml [tool.ruff.lint]: * Renamed TCH001 -> TC001 (ruff renamed the rule). * Removed PT005 from ignore list — ruff removed the rule. Verified -------- - ruff check src tests -> All checks passed - pytest tests/ -> 2986 passed - behave --stop -> 54 features, 981 scenarios, 0 failed Out of scope ------------ - ruff format would reformat 48 files. The lint job has 'continue-on-error: true' on the format check, so that stays advisory for now. A repo-wide format pass is a separate dedicated PR. --- pyproject.toml | 3 +- src/pptx/chart/axis.py | 23 ++++------- src/pptx/chart/chart.py | 8 +--- src/pptx/chart/data.py | 8 +--- src/pptx/chart/datalabel.py | 4 +- src/pptx/chart/plot.py | 12 ++---- src/pptx/chart/xmlwriter.py | 2 +- src/pptx/custom_xml.py | 4 +- src/pptx/dml/effect.py | 4 +- src/pptx/opc/serialized.py | 4 +- src/pptx/oxml/chart/chart.py | 4 +- src/pptx/oxml/chart/plot.py | 4 +- src/pptx/oxml/custom_properties.py | 2 - src/pptx/oxml/custom_xml.py | 2 +- src/pptx/oxml/ns.py | 1 - src/pptx/oxml/shapes/groupshape.py | 2 +- src/pptx/oxml/table.py | 4 +- src/pptx/oxml/text.py | 4 +- src/pptx/oxml/xmlchemy.py | 9 +---- src/pptx/shapes/autoshape.py | 2 +- src/pptx/shapes/shapetree.py | 2 +- src/pptx/text/fonts.py | 6 +-- tests/chart/test_category.py | 2 +- tests/chart/test_xmlwriter.py | 12 +++--- tests/dml/test_effect.py | 2 +- tests/integration/test_customxml_roundtrip.py | 3 -- tests/opc/test_package.py | 36 ++++++++--------- tests/oxml/shapes/test_picture.py | 6 +-- tests/oxml/test_custom_xml.py | 1 - tests/parts/test_chart.py | 6 +-- tests/parts/test_custom_properties.py | 2 +- tests/parts/test_custom_xml.py | 5 ++- tests/parts/test_image.py | 6 +-- tests/parts/test_presentation.py | 2 +- tests/parts/test_slide.py | 8 ++-- tests/shapes/test_autoshape.py | 2 +- tests/shapes/test_graphfrm.py | 18 ++++----- tests/shapes/test_placeholder.py | 6 +-- tests/shapes/test_shapetree.py | 40 +++++++++---------- tests/test_custom_properties.py | 5 ++- .../customxml/_generate_fixtures.py | 1 - tests/test_slide.py | 12 +++--- tests/text/test_layout.py | 6 +-- tests/text/test_text.py | 10 ++--- 44 files changed, 131 insertions(+), 174 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 740f6dacc..2da7a2681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ select = [ "PLR0402", # -- Name compared with itself like `foo == foo` -- "PT", # -- flake8-pytest-style -- "SIM", # -- flake8-simplify -- - "TCH001", # -- detect typing-only imports not under `if TYPE_CHECKING` -- + "TC001", # -- detect typing-only imports not under `if TYPE_CHECKING` (formerly TCH001) -- "UP015", # -- redundant `open()` mode parameter (like "r" is default) -- "UP018", # -- Unnecessary {literal_type} call like `str("abc")`. (rewrite as a literal) -- "UP032", # -- Use f-string instead of `.format()` call -- @@ -139,7 +139,6 @@ select = [ ignore = [ "COM812", # -- over aggressively insists on trailing commas where not desireable -- "PT001", # -- wants empty parens on @pytest.fixture where not used (essentially always) -- - "PT005", # -- flags mock fixtures with names intentionally matching private method name -- "PT011", # -- pytest.raises({exc}) too broad, use match param or more specific exception -- "PT012", # -- pytest.raises() block should contain a single simple statement -- "SIM117", # -- merge `with` statements for context managers that have same scope -- diff --git a/src/pptx/chart/axis.py b/src/pptx/chart/axis.py index a9b877039..010698726 100644 --- a/src/pptx/chart/axis.py +++ b/src/pptx/chart/axis.py @@ -51,9 +51,7 @@ def has_major_gridlines(self): causes major gridlines to be displayed. Assigning |False| causes them to be removed. """ - if self._element.majorGridlines is None: - return False - return True + return self._element.majorGridlines is not None @has_major_gridlines.setter def has_major_gridlines(self, value): @@ -70,9 +68,7 @@ def has_minor_gridlines(self): causes minor gridlines to be displayed. Assigning |False| causes them to be removed. """ - if self._element.minorGridlines is None: - return False - return True + return self._element.minorGridlines is not None @has_minor_gridlines.setter def has_minor_gridlines(self, value): @@ -89,9 +85,7 @@ def has_title(self): causes an axis title to be added if not already present. Assigning |False| causes any existing title to be deleted. """ - if self._element.title is None: - return False - return True + return self._element.title is not None @has_title.setter def has_title(self, value): @@ -232,7 +226,7 @@ def visible(self): delete = self._element.delete_ if delete is None: return False - return False if delete.val else True + return not delete.val @visible.setter def visible(self, value): @@ -267,9 +261,7 @@ def has_text_frame(self): already present. Assigning |False| causes any existing text frame to be removed along with any text contained in the text frame. """ - if self._title.tx_rich is None: - return False - return True + return self._title.tx_rich is not None @has_text_frame.setter def has_text_frame(self, value): @@ -441,9 +433,8 @@ def crosses(self): @crosses.setter def crosses(self, value): cross_xAx = self._cross_xAx - if value == XL_AXIS_CROSSES.CUSTOM: - if cross_xAx.crossesAt is not None: - return + if value == XL_AXIS_CROSSES.CUSTOM and cross_xAx.crossesAt is not None: + return cross_xAx._remove_crosses() cross_xAx._remove_crossesAt() if value == XL_AXIS_CROSSES.CUSTOM: diff --git a/src/pptx/chart/chart.py b/src/pptx/chart/chart.py index d73aa9338..629056636 100644 --- a/src/pptx/chart/chart.py +++ b/src/pptx/chart/chart.py @@ -115,9 +115,7 @@ def has_title(self): settings. """ title = self._chartSpace.chart.title - if title is None: - return False - return True + return title is not None @has_title.setter def has_title(self, value): @@ -229,9 +227,7 @@ def has_text_frame(self): already present. Assigning |False| causes any existing text frame to be removed along with its text and formatting. """ - if self._title.tx_rich is None: - return False - return True + return self._title.tx_rich is not None @has_text_frame.setter def has_text_frame(self, value): diff --git a/src/pptx/chart/data.py b/src/pptx/chart/data.py index ec6a61f31..c25ff9349 100644 --- a/src/pptx/chart/data.py +++ b/src/pptx/chart/data.py @@ -391,9 +391,7 @@ def are_dates(self): return False first_cat_label = self[0].label date_types = (datetime.date, datetime.datetime) - if isinstance(first_cat_label, date_types): - return True - return False + return bool(isinstance(first_cat_label, date_types)) @property def are_numeric(self): @@ -414,9 +412,7 @@ def are_numeric(self): # the caller's input. first_cat_label = self[0].label numeric_types = (Number, datetime.date, datetime.datetime) - if isinstance(first_cat_label, numeric_types): - return True - return False + return bool(isinstance(first_cat_label, numeric_types)) @property def depth(self): diff --git a/src/pptx/chart/datalabel.py b/src/pptx/chart/datalabel.py index af7cdf5c0..03c7f14e2 100644 --- a/src/pptx/chart/datalabel.py +++ b/src/pptx/chart/datalabel.py @@ -177,9 +177,7 @@ def has_text_frame(self): dLbl = self._dLbl if dLbl is None: return False - if dLbl.xpath("c:tx/c:rich"): - return True - return False + return bool(dLbl.xpath("c:tx/c:rich")) @has_text_frame.setter def has_text_frame(self, value): diff --git a/src/pptx/chart/plot.py b/src/pptx/chart/plot.py index 6e7235855..795af0b21 100644 --- a/src/pptx/chart/plot.py +++ b/src/pptx/chart/plot.py @@ -340,9 +340,7 @@ def _differentiate_line_chart_type(cls, plot): def has_line_markers(): matches = lineChart.xpath('c:ser/c:marker/c:symbol[@val="none"]') - if matches: - return False - return True + return not matches if has_line_markers(): return { @@ -370,9 +368,7 @@ def _differentiate_radar_chart_type(cls, plot): def noMarkers(): matches = radarChart.xpath("c:ser/c:marker/c:symbol") - if matches and matches[0].get("val") == "none": - return True - return False + return bool(matches and matches[0].get("val") == "none") if radar_style is None: return XL.RADAR @@ -391,9 +387,7 @@ def noLine(): def noMarkers(): symbols = scatterChart.xpath("c:ser/c:marker/c:symbol") - if symbols and symbols[0].get("val") == "none": - return True - return False + return bool(symbols and symbols[0].get("val") == "none") scatter_style = scatterChart.xpath("c:scatterStyle")[0].get("val") diff --git a/src/pptx/chart/xmlwriter.py b/src/pptx/chart/xmlwriter.py index 703c53dd5..22616f2ab 100644 --- a/src/pptx/chart/xmlwriter.py +++ b/src/pptx/chart/xmlwriter.py @@ -140,7 +140,7 @@ def pt_xml(self, values): in the overall data point sequence of the chart and is started at *offset*. """ - xml = (' \n').format(pt_count=len(values)) + xml = (f' \n') pt_tmpl = ( ' \n' diff --git a/src/pptx/custom_xml.py b/src/pptx/custom_xml.py index 93c8a3620..80ecbf8d7 100644 --- a/src/pptx/custom_xml.py +++ b/src/pptx/custom_xml.py @@ -13,12 +13,12 @@ from typing import TYPE_CHECKING, Iterable, Iterator, Literal, Sequence, Union, cast from pptx.opc.constants import RELATIONSHIP_TYPE as RT -from pptx.opc.package import Part from pptx.oxml import parse_xml -from pptx.oxml.xmlchemy import BaseOxmlElement from pptx.parts.custom_xml import CustomXmlPart, XmlPayload if TYPE_CHECKING: + from pptx.opc.package import Part + from pptx.oxml.xmlchemy import BaseOxmlElement from pptx.parts.presentation import PresentationPart diff --git a/src/pptx/dml/effect.py b/src/pptx/dml/effect.py index 7482de859..980abd3fe 100644 --- a/src/pptx/dml/effect.py +++ b/src/pptx/dml/effect.py @@ -100,9 +100,7 @@ def inherit(self): Assigning |False| causes the inheritance link to be broken and **no** effects to appear on the shape. """ - if self._element.effectLst is None: - return True - return False + return self._element.effectLst is None @inherit.setter def inherit(self, value): diff --git a/src/pptx/opc/serialized.py b/src/pptx/opc/serialized.py index 92366708b..1c99f2fee 100644 --- a/src/pptx/opc/serialized.py +++ b/src/pptx/opc/serialized.py @@ -44,7 +44,9 @@ def rels_xml_for(self, partname: PackURI) -> bytes | None: instance. """ blob_reader, uri = self._blob_reader, partname.rels_uri - return blob_reader[uri] if uri in blob_reader else None + # `_blob_reader` is a Container, not a Mapping — it has no `.get()`, + # so SIM401's "use blob_reader.get(uri, None)" rewrite would break. + return blob_reader[uri] if uri in blob_reader else None # noqa: SIM401 @lazyproperty def _blob_reader(self) -> _PhysPkgReader: diff --git a/src/pptx/oxml/chart/chart.py b/src/pptx/oxml/chart/chart.py index f4cd0dc7c..d2b7150bd 100644 --- a/src/pptx/oxml/chart/chart.py +++ b/src/pptx/oxml/chart/chart.py @@ -48,9 +48,7 @@ def has_legend(self): True if this chart has a legend defined, False otherwise. """ legend = self.legend - if legend is None: - return False - return True + return legend is not None @has_legend.setter def has_legend(self, bool_value): diff --git a/src/pptx/oxml/chart/plot.py b/src/pptx/oxml/chart/plot.py index 9c695a43a..855ed5903 100644 --- a/src/pptx/oxml/chart/plot.py +++ b/src/pptx/oxml/chart/plot.py @@ -62,9 +62,9 @@ def cat_pts(self): if not cat_pts: cat_pts = self.xpath("./c:ser[1]/c:cat//c:pt") - cat_pt_dict = dict((pt.idx, pt) for pt in cat_pts) + cat_pt_dict = {pt.idx: pt for pt in cat_pts} - return [cat_pt_dict.get(idx, None) for idx in range(self.cat_pt_count)] + return [cat_pt_dict.get(idx) for idx in range(self.cat_pt_count)] @property def grouping_val(self): diff --git a/src/pptx/oxml/custom_properties.py b/src/pptx/oxml/custom_properties.py index 9f7b8c630..ba60a6cba 100644 --- a/src/pptx/oxml/custom_properties.py +++ b/src/pptx/oxml/custom_properties.py @@ -11,8 +11,6 @@ import datetime as dt from typing import cast -from lxml.etree import _Element # pyright: ignore[reportPrivateUsage] - from pptx.oxml import parse_xml from pptx.oxml.ns import nsdecls, qn from pptx.oxml.simpletypes import XsdString, XsdUnsignedInt diff --git a/src/pptx/oxml/custom_xml.py b/src/pptx/oxml/custom_xml.py index 1b2fa83fa..f568952bb 100644 --- a/src/pptx/oxml/custom_xml.py +++ b/src/pptx/oxml/custom_xml.py @@ -14,7 +14,7 @@ from typing import Iterable, cast from pptx.oxml import parse_xml -from pptx.oxml.ns import nsdecls, qn +from pptx.oxml.ns import nsdecls from pptx.oxml.simpletypes import XsdString from pptx.oxml.xmlchemy import ( BaseOxmlElement, diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index 72df489e3..0cf0ea893 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -2,7 +2,6 @@ from __future__ import annotations - # -- Maps namespace prefix to namespace name for all known PowerPoint XML namespaces -- _nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", diff --git a/src/pptx/oxml/shapes/groupshape.py b/src/pptx/oxml/shapes/groupshape.py index f62bc6662..4687b5511 100644 --- a/src/pptx/oxml/shapes/groupshape.py +++ b/src/pptx/oxml/shapes/groupshape.py @@ -194,7 +194,7 @@ def recalculate_extents(self) -> None: This method is recursive "upwards" since a change in a group shape can change the position and size of its containing group. """ - if not self.tag == qn("p:grpSp"): + if self.tag != qn("p:grpSp"): return x, y, cx, cy = self._child_extents diff --git a/src/pptx/oxml/table.py b/src/pptx/oxml/table.py index 09fc35284..3e44de35d 100644 --- a/src/pptx/oxml/table.py +++ b/src/pptx/oxml/table.py @@ -519,9 +519,7 @@ def dimensions(self) -> tuple[int, int]: @lazyproperty def in_same_table(self): """True if both cells provided to constructor are in same table.""" - if self._tc.tbl is self._other_tc.tbl: - return True - return False + return self._tc.tbl is self._other_tc.tbl def iter_except_left_col_tcs(self): """Generate each `a:tc` element not in leftmost column of range.""" diff --git a/src/pptx/oxml/text.py b/src/pptx/oxml/text.py index 5c72182ac..5324f7fe6 100644 --- a/src/pptx/oxml/text.py +++ b/src/pptx/oxml/text.py @@ -120,9 +120,7 @@ def is_empty(self) -> bool: if not ps: raise InvalidXmlError("p:txBody must have at least one a:p") - if ps[0].text != "": - return False - return True + return ps[0].text == "" @classmethod def new(cls): diff --git a/src/pptx/oxml/xmlchemy.py b/src/pptx/oxml/xmlchemy.py index 41fb2e171..d5eb62ccc 100644 --- a/src/pptx/oxml/xmlchemy.py +++ b/src/pptx/oxml/xmlchemy.py @@ -100,9 +100,7 @@ def _eq_elm_strs(self, line: str, line_2: str) -> bool: return False if close != close_2: return False - if text != text_2: - return False - return True + return text == text_2 def _parse_line(self, line: str): """Return front, attrs, close, text 4-tuple result of parsing XML element string `line`.""" @@ -456,10 +454,7 @@ def _prop_name(self): """ Calculate property name from tag name, e.g. a:schemeClr -> schemeClr. """ - if ":" in self._nsptagname: - start = self._nsptagname.index(":") + 1 - else: - start = 0 + start = self._nsptagname.index(":") + 1 if ":" in self._nsptagname else 0 return self._nsptagname[start:] @lazyproperty diff --git a/src/pptx/shapes/autoshape.py b/src/pptx/shapes/autoshape.py index c7f8cd93e..e884237ff 100644 --- a/src/pptx/shapes/autoshape.py +++ b/src/pptx/shapes/autoshape.py @@ -136,7 +136,7 @@ def _update_adjustments_with_actuals( `guides` is a list of `a:gd` elements. Guides with a name that does not match an adjustment object are skipped. """ - adjustments_by_name = dict((adj.name, adj) for adj in adjustments) + adjustments_by_name = {adj.name: adj for adj in adjustments} for gd in guides: name = gd.name actual = int(gd.fmla[4:]) diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 29623f1f5..42c32de83 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -792,7 +792,7 @@ def __getitem__(self, idx: int): def __iter__(self): """Generate placeholder shapes in `idx` order.""" - ph_elms = sorted([e for e in self._element.iter_ph_elms()], key=lambda e: e.ph_idx) + ph_elms = sorted(self._element.iter_ph_elms(), key=lambda e: e.ph_idx) return (SlideShapeFactory(e, self) for e in ph_elms) def __len__(self) -> int: diff --git a/src/pptx/text/fonts.py b/src/pptx/text/fonts.py index 5ae054a83..9be19ccd1 100644 --- a/src/pptx/text/fonts.py +++ b/src/pptx/text/fonts.py @@ -175,10 +175,10 @@ def _tables(self): A mapping of OpenType table tag, e.g. 'name', to a table object providing access to the contents of that table. """ - return dict( - (tag, _TableFactory(tag, self._stream, off, len_)) + return { + tag: _TableFactory(tag, self._stream, off, len_) for tag, off, len_ in self._iter_table_records() - ) + } @property def _table_count(self): diff --git a/tests/chart/test_category.py b/tests/chart/test_category.py index 9319d664b..2779d3696 100644 --- a/tests/chart/test_category.py +++ b/tests/chart/test_category.py @@ -30,7 +30,7 @@ def it_can_iterate_over_the_categories_it_contains(self, iter_fixture): Category_, calls, ) = iter_fixture - assert [c for c in categories] == expected_categories + assert list(categories) == expected_categories assert Category_.call_args_list == calls def it_knows_its_depth(self, depth_fixture): diff --git a/tests/chart/test_xmlwriter.py b/tests/chart/test_xmlwriter.py index bb7354983..7db641719 100644 --- a/tests/chart/test_xmlwriter.py +++ b/tests/chart/test_xmlwriter.py @@ -172,8 +172,8 @@ class Describe_BarChartXmlWriter(object): """Unit-test suite for `pptx.chart.xmlwriter._BarChartXmlWriter`.""" @pytest.mark.parametrize( - "member, cat_count, ser_count, cat_type, snippet_name", - ( + ("member", "cat_count", "ser_count", "cat_type", "snippet_name"), + [ ("BAR_CLUSTERED", 2, 2, str, "2x2-bar-clustered"), ("BAR_CLUSTERED", 2, 2, date, "2x2-bar-clustered-date"), ("BAR_CLUSTERED", 2, 2, float, "2x2-bar-clustered-float"), @@ -182,7 +182,7 @@ class Describe_BarChartXmlWriter(object): ("COLUMN_CLUSTERED", 2, 2, str, "2x2-column-clustered"), ("COLUMN_STACKED", 2, 2, str, "2x2-column-stacked"), ("COLUMN_STACKED_100", 2, 2, str, "2x2-column-stacked-100"), - ), + ], ) def it_can_generate_xml_for_bar_type_charts( self, member, cat_count, ser_count, cat_type, snippet_name @@ -284,11 +284,11 @@ class Describe_PieChartXmlWriter(object): """Unit-test suite for `pptx.chart.xmlwriter._PieChartXmlWriter`.""" @pytest.mark.parametrize( - "enum_member, cat_count, ser_count, snippet_name", - ( + ("enum_member", "cat_count", "ser_count", "snippet_name"), + [ ("PIE", 3, 1, "3x1-pie"), ("PIE_EXPLODED", 3, 1, "3x1-pie-exploded"), - ), + ], ) def it_can_generate_xml_for_a_pie_chart(self, enum_member, cat_count, ser_count, snippet_name): chart_type = getattr(XL_CHART_TYPE, enum_member) diff --git a/tests/dml/test_effect.py b/tests/dml/test_effect.py index be08fc19c..d5207aeff 100644 --- a/tests/dml/test_effect.py +++ b/tests/dml/test_effect.py @@ -6,7 +6,7 @@ from pptx.dml.color import ColorFormat from pptx.dml.effect import ShadowFormat -from pptx.util import Emu, Pt +from pptx.util import Pt from ..unitutil.cxml import element, xml diff --git a/tests/integration/test_customxml_roundtrip.py b/tests/integration/test_customxml_roundtrip.py index f330a05ce..ab20e3e35 100644 --- a/tests/integration/test_customxml_roundtrip.py +++ b/tests/integration/test_customxml_roundtrip.py @@ -16,13 +16,10 @@ import os from io import BytesIO -import pytest - from pptx import Presentation from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.parts.custom_xml import CustomXmlPart - _FIXTURE_DIR = os.path.join( os.path.dirname(os.path.abspath(__file__)), os.pardir, diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 8c0e95809..c0671bc00 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -224,7 +224,7 @@ def it_provides_access_to_the_main_document_part(self, request): assert presentation_part is presentation_part_ @pytest.mark.parametrize( - "ns, expected_n", (((), 1), ((1,), 2), ((1, 2), 3), ((2, 4), 3), ((1, 4), 3)) + ("ns", "expected_n"), [((), 1), ((1,), 2), ((1, 2), 3), ((2, 4), 3), ((1, 4), 3)] ) def it_can_find_the_next_available_partname(self, request, ns, expected_n): tmpl = "/x%d.xml" @@ -569,13 +569,13 @@ def it_can_construct_from_content_types_xml(self, request): ) @pytest.mark.parametrize( - "partname, expected_value", - ( + ("partname", "expected_value"), + [ ("/docProps/core.xml", CT.OPC_CORE_PROPERTIES), ("/ppt/presentation.xml", CT.PML_PRESENTATION_MAIN), ("/PPT/Presentation.XML", CT.PML_PRESENTATION_MAIN), ("/ppt/viewprops.xml", CT.PML_VIEW_PROPS), - ), + ], ) def it_matches_an_override_on_case_insensitive_partname( self, content_type_map, partname, expected_value @@ -583,12 +583,12 @@ def it_matches_an_override_on_case_insensitive_partname( assert content_type_map[PackURI(partname)] == expected_value @pytest.mark.parametrize( - "partname, expected_value", - ( + ("partname", "expected_value"), + [ ("/foo/bar.xml", CT.XML), ("/FOO/BAR.Rels", CT.OPC_RELATIONSHIPS), ("/foo/bar.jpeg", CT.JPEG), - ), + ], ) def it_falls_back_to_case_insensitive_extension_default_match( self, content_type_map, partname, expected_value @@ -617,7 +617,7 @@ def content_type_map(self): class Describe_Relationships: """Unit-test suite for `pptx.opc.package._Relationships` objects.""" - @pytest.mark.parametrize("rId, expected_value", (("rId1", True), ("rId2", False))) + @pytest.mark.parametrize(("rId", "expected_value"), [("rId1", True), ("rId2", False)]) def it_knows_whether_it_contains_a_relationship_with_rId( self, _rels_prop_, rId, expected_value ): @@ -635,7 +635,7 @@ def but_it_raises_KeyError_when_no_relationship_has_rId(self, _rels_prop_): assert str(e.value) == "\"no relationship with key 'rId6'\"" def it_can_iterate_the_rIds_of_the_relationships_it_contains(self, request, _rels_prop_): - rels_ = set(instance_mock(request, _Relationship) for n in range(5)) + rels_ = {instance_mock(request, _Relationship) for n in range(5)} _rels_prop_.return_value = {"rId%d" % (i + 1): r for i, r in enumerate(rels_)} relationships = _Relationships(None) @@ -827,14 +827,14 @@ def and_it_can_add_an_external_relationship_to_help( assert rId == "rId9" @pytest.mark.parametrize( - "target_ref, is_external, expected_value", - ( + ("target_ref", "is_external", "expected_value"), + [ ("http://url", True, "rId1"), ("part_1", False, "rId2"), ("http://foo", True, "rId3"), ("part_2", False, "rId4"), ("http://bar", True, None), - ), + ], ) def it_can_get_a_matching_relationship_to_help( self, request, _rels_by_reltype_prop_, target_ref, is_external, expected_value @@ -872,18 +872,18 @@ def but_it_returns_None_when_there_is_no_matching_relationship(self, _rels_by_re assert relationships._get_matching(RT.HYPERLINK, "http://url", True) is None @pytest.mark.parametrize( - "rIds, expected_value", - ( + ("rIds", "expected_value"), + [ ((), "rId1"), (("rId1",), "rId2"), (("rId1", "rId2"), "rId3"), (("rId1", "rId4"), "rId3"), (("rId1", "rId4", "rId6"), "rId3"), (("rId1", "rId2", "rId6"), "rId4"), - ), + ], ) def it_finds_the_next_rId_to_help(self, _rels_prop_, rIds, expected_value): - _rels_prop_.return_value = {rId: None for rId in rIds} + _rels_prop_.return_value = dict.fromkeys(rIds) relationships = _Relationships(None) assert relationships._next_rId == expected_value @@ -960,8 +960,8 @@ def it_can_construct_from_xml(self, request, part_): assert isinstance(relationship, _Relationship) @pytest.mark.parametrize( - "target_mode, expected_value", - ((RTM.INTERNAL, False), (RTM.EXTERNAL, True), (None, False)), + ("target_mode", "expected_value"), + [(RTM.INTERNAL, False), (RTM.EXTERNAL, True), (None, False)], ) def it_knows_whether_it_is_external(self, target_mode, expected_value): relationship = _Relationship(None, None, None, target_mode, None) diff --git a/tests/oxml/shapes/test_picture.py b/tests/oxml/shapes/test_picture.py index 546d6b0fd..f01b5eea1 100644 --- a/tests/oxml/shapes/test_picture.py +++ b/tests/oxml/shapes/test_picture.py @@ -12,13 +12,13 @@ class DescribeCT_Picture(object): """Unit-test suite for `pptx.oxml.shapes.picture.CT_Picture` objects.""" @pytest.mark.parametrize( - "desc, xml_desc", - ( + ("desc", "xml_desc"), + [ ("kittens.jpg", "kittens.jpg"), ("bits&bobs.png", "bits&bobs.png"), ("img&.png", "img&.png"), ("ime.png", "im<ag>e.png"), - ), + ], ) def it_can_create_a_new_pic_element(self, desc, xml_desc): """`desc` attr (often filename) is XML-escaped to handle special characters. diff --git a/tests/oxml/test_custom_xml.py b/tests/oxml/test_custom_xml.py index c95cabe44..2932a2130 100644 --- a/tests/oxml/test_custom_xml.py +++ b/tests/oxml/test_custom_xml.py @@ -4,7 +4,6 @@ from __future__ import annotations -import pytest from lxml import etree from pptx.oxml import parse_xml diff --git a/tests/parts/test_chart.py b/tests/parts/test_chart.py index b0a41f581..9d9e35903 100644 --- a/tests/parts/test_chart.py +++ b/tests/parts/test_chart.py @@ -89,8 +89,8 @@ def but_it_returns_None_when_the_chart_has_no_xlsx_part(self): assert chart_workbook.xlsx_part is None @pytest.mark.parametrize( - "chartSpace_cxml, expected_cxml", - ( + ("chartSpace_cxml", "expected_cxml"), + [ ( "c:chartSpace{r:a=b}", "c:chartSpace{r:a=b}/c:externalData{r:id=rId" "42}/c:autoUpdate{val=0}", @@ -99,7 +99,7 @@ def but_it_returns_None_when_the_chart_has_no_xlsx_part(self): "c:chartSpace/c:externalData{r:id=rId66}", "c:chartSpace/c:externalData{r:id=rId42}", ), - ), + ], ) def it_can_change_the_chart_xlsx_part( self, chart_part_, xlsx_part_, chartSpace_cxml, expected_cxml diff --git a/tests/parts/test_custom_properties.py b/tests/parts/test_custom_properties.py index eea428822..62d7c76c5 100644 --- a/tests/parts/test_custom_properties.py +++ b/tests/parts/test_custom_properties.py @@ -9,7 +9,7 @@ import pytest from pptx.opc.constants import CONTENT_TYPE as CT -from pptx.oxml.custom_properties import CT_Properties, DEFAULT_FMTID +from pptx.oxml.custom_properties import DEFAULT_FMTID, CT_Properties from pptx.oxml.ns import nsdecls from pptx.parts.custom_properties import CustomPropertiesPart diff --git a/tests/parts/test_custom_xml.py b/tests/parts/test_custom_xml.py index 5c38f4dec..6d9b58f95 100644 --- a/tests/parts/test_custom_xml.py +++ b/tests/parts/test_custom_xml.py @@ -21,10 +21,11 @@ _parse_payload, ) - _GUID_A = "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}" _GUID_B = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" -_GUID_RE = re.compile(r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$") +_GUID_RE = re.compile( + r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$" +) class _StubPart: diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index 386e3fce9..35c186497 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -59,13 +59,13 @@ def it_provides_access_to_its_image(self, request, image_): assert image is image_ @pytest.mark.parametrize( - "width, height, expected_width, expected_height", - ( + ("width", "height", "expected_width", "expected_height"), + [ (None, None, Emu(2590800), Emu(2590800)), (1000, None, 1000, 1000), (None, 3000, 3000, 3000), (3337, 9999, 3337, 9999), - ), + ], ) def it_can_scale_its_dimensions(self, width, height, expected_width, expected_height): with open(test_image_path, "rb") as f: diff --git a/tests/parts/test_presentation.py b/tests/parts/test_presentation.py index edde4c44c..76ab8265c 100644 --- a/tests/parts/test_presentation.py +++ b/tests/parts/test_presentation.py @@ -168,7 +168,7 @@ def it_raises_on_slide_id_not_found(self, slide_part_, related_part_): with pytest.raises(ValueError): prs_part.slide_id(slide_part_) - @pytest.mark.parametrize("is_present", (True, False)) + @pytest.mark.parametrize("is_present", [True, False]) def it_finds_a_slide_by_slide_id(self, is_present, slide_, slide_part_, related_part_): prs_elm = element( "p:presentation/p:sldIdLst/(p:sldId{r:id=a,id=256},p:sldId{r:id=" diff --git a/tests/parts/test_slide.py b/tests/parts/test_slide.py index 9eb2f11b0..5497a841e 100644 --- a/tests/parts/test_slide.py +++ b/tests/parts/test_slide.py @@ -318,13 +318,13 @@ def it_can_add_a_chart_part(self, request, package_, relate_to_): assert rId == "rId42" @pytest.mark.parametrize( - "prog_id, rel_type", - ( + ("prog_id", "rel_type"), + [ (PROG_ID.DOCX, RT.PACKAGE), (PROG_ID.PPTX, RT.PACKAGE), (PROG_ID.XLSX, RT.PACKAGE), ("Foo.Bar.18", RT.OLE_OBJECT), - ), + ], ) def it_can_add_an_embedded_ole_object_part( self, request, package_, relate_to_, prog_id, rel_type @@ -390,7 +390,7 @@ def it_provides_access_to_the_slide_layout(self, layout_fixture): def it_knows_the_minimal_element_xml_for_a_slide(self): path = absjoin(test_file_dir, "minimal_slide.xml") sld = CT_Slide.new() - with open(path, "r") as f: + with open(path) as f: expected_xml = f.read() assert sld.xml == expected_xml diff --git a/tests/shapes/test_autoshape.py b/tests/shapes/test_autoshape.py index efb38e6b9..fe2142c61 100644 --- a/tests/shapes/test_autoshape.py +++ b/tests/shapes/test_autoshape.py @@ -102,7 +102,7 @@ def it_should_load_default_adjustment_values( def it_should_load_adj_val_actuals_from_xml(self, load_adj_actuals_fixture_): prstGeom, expected_actuals, prstGeom_xml = load_adj_actuals_fixture_ adjustments = AdjustmentCollection(prstGeom)._adjustments - actual_actuals = dict([(a.name, a.actual) for a in adjustments]) + actual_actuals = {a.name: a.actual for a in adjustments} assert actual_actuals == expected_actuals def it_provides_normalized_effective_value_on_indexed_access(self, indexed_access_fixture_): diff --git a/tests/shapes/test_graphfrm.py b/tests/shapes/test_graphfrm.py index 3324fcfe0..16188fc16 100644 --- a/tests/shapes/test_graphfrm.py +++ b/tests/shapes/test_graphfrm.py @@ -54,24 +54,24 @@ def it_provides_access_to_its_chart_part(self, request, chart_part_): assert chart_part is chart_part_ @pytest.mark.parametrize( - "graphicData_uri, expected_value", - ( + ("graphicData_uri", "expected_value"), + [ (GRAPHIC_DATA_URI_CHART, True), (GRAPHIC_DATA_URI_OLEOBJ, False), (GRAPHIC_DATA_URI_TABLE, False), - ), + ], ) def it_knows_whether_it_contains_a_chart(self, graphicData_uri, expected_value): graphicFrame = element("p:graphicFrame/a:graphic/a:graphicData{uri=%s}" % graphicData_uri) assert GraphicFrame(graphicFrame, None).has_chart is expected_value @pytest.mark.parametrize( - "graphicData_uri, expected_value", - ( + ("graphicData_uri", "expected_value"), + [ (GRAPHIC_DATA_URI_CHART, False), (GRAPHIC_DATA_URI_OLEOBJ, False), (GRAPHIC_DATA_URI_TABLE, True), - ), + ], ) def it_knows_whether_it_contains_a_table(self, graphicData_uri, expected_value): graphicFrame = element("p:graphicFrame/a:graphic/a:graphicData{uri=%s}" % graphicData_uri) @@ -112,14 +112,14 @@ def it_raises_on_shadow(self): graphic_frame.shadow @pytest.mark.parametrize( - "uri, oleObj_child, expected_value", - ( + ("uri", "oleObj_child", "expected_value"), + [ (GRAPHIC_DATA_URI_CHART, None, MSO_SHAPE_TYPE.CHART), (GRAPHIC_DATA_URI_OLEOBJ, "embed", MSO_SHAPE_TYPE.EMBEDDED_OLE_OBJECT), (GRAPHIC_DATA_URI_OLEOBJ, "link", MSO_SHAPE_TYPE.LINKED_OLE_OBJECT), (GRAPHIC_DATA_URI_TABLE, None, MSO_SHAPE_TYPE.TABLE), ("foobar", None, None), - ), + ], ) def it_knows_its_shape_type(self, uri, oleObj_child, expected_value): graphicFrame = element( diff --git a/tests/shapes/test_placeholder.py b/tests/shapes/test_placeholder.py index 4d9b26ea0..94db0fbd7 100644 --- a/tests/shapes/test_placeholder.py +++ b/tests/shapes/test_placeholder.py @@ -51,7 +51,7 @@ def it_provides_override_dimensions_when_present(self, override_fixture): placeholder, prop_name, expected_value = override_fixture assert getattr(placeholder, prop_name) == expected_value - @pytest.mark.parametrize("prop_name", ("left", "top", "width", "height")) + @pytest.mark.parametrize("prop_name", ["left", "top", "width", "height"]) def it_provides_inherited_dims_when_no_override(self, request, prop_name): method_mock(request, _BaseSlidePlaceholder, "_inherited_value", return_value=42) placeholder = _BaseSlidePlaceholder(element("p:sp/p:spPr"), None) @@ -463,8 +463,8 @@ def it_can_insert_a_picture_into_itself(self, request): assert placeholder_picture is placeholder_picture_ @pytest.mark.parametrize( - "image_size, crop_attr_names", - (((444, 333), ("l", "r")), ((333, 444), ("t", "b"))), + ("image_size", "crop_attr_names"), + [((444, 333), ("l", "r")), ((333, 444), ("t", "b"))], ) def it_creates_a_pic_element_to_help(self, request, image_size, crop_attr_names): _get_or_add_image_ = method_mock( diff --git a/tests/shapes/test_shapetree.py b/tests/shapes/test_shapetree.py index 3cf1ab225..80b75723d 100644 --- a/tests/shapes/test_shapetree.py +++ b/tests/shapes/test_shapetree.py @@ -118,12 +118,12 @@ def it_knows_how_many_shapes_it_contains(self, len_fixture): def it_can_iterate_over_the_shapes_it_contains(self, iter_fixture): shapes, expected_shapes, BaseShapeFactory_, calls = iter_fixture - assert [s for s in shapes] == expected_shapes + assert list(shapes) == expected_shapes assert BaseShapeFactory_.call_args_list == calls def it_iterates_shape_elements_to_help__iter__(self, iter_elms_fixture): shapes, expected_elms = iter_elms_fixture - assert [e for e in shapes._iter_member_elms()] == expected_elms + assert list(shapes._iter_member_elms()) == expected_elms def it_supports_indexed_access(self, getitem_fixture): shapes, idx, BaseShapeFactory_, sp, shape_ = getitem_fixture @@ -987,7 +987,7 @@ def it_can_iterate_over_its_placeholders(self, iter_fixture): placeholders, SlideShapeFactory_ = iter_fixture[:2] expected_calls, expected_values = iter_fixture[2:] - ps = [p for p in placeholders] + ps = list(placeholders) assert SlideShapeFactory_.call_args_list == expected_calls assert ps == expected_values @@ -2169,14 +2169,14 @@ def it_creates_the_graphicFrame_element(self, request): ) @pytest.mark.parametrize( - "cx_arg, prog_id, expected_value", - ( + ("cx_arg", "prog_id", "expected_value"), + [ (Emu(999999), None, Emu(999999)), (None, PROG_ID.DOCX, Emu(965200)), (None, PROG_ID.PPTX, Emu(965200)), (None, PROG_ID.XLSX, Emu(965200)), (None, "Foo.Bar.6", Emu(965200)), - ), + ], ) def it_determines_the_shape_width_to_help(self, cx_arg, prog_id, expected_value): element_creator = _OleObjectElementCreator( @@ -2185,14 +2185,14 @@ def it_determines_the_shape_width_to_help(self, cx_arg, prog_id, expected_value) assert element_creator._cx == expected_value @pytest.mark.parametrize( - "cy_arg, prog_id, expected_value", - ( + ("cy_arg", "prog_id", "expected_value"), + [ (Emu(666666), None, Emu(666666)), (None, PROG_ID.DOCX, Emu(609600)), (None, PROG_ID.PPTX, Emu(609600)), (None, PROG_ID.XLSX, Emu(609600)), (None, "Foo.Bar.6", Emu(609600)), - ), + ], ) def it_determines_the_shape_height_to_help(self, cy_arg, prog_id, expected_value): element_creator = _OleObjectElementCreator( @@ -2201,11 +2201,11 @@ def it_determines_the_shape_height_to_help(self, cy_arg, prog_id, expected_value assert element_creator._cy == expected_value @pytest.mark.parametrize( - "icon_height_arg, expected_value", - ( + ("icon_height_arg", "expected_value"), + [ (Emu(666666), Emu(666666)), (None, Emu(609600)), - ), + ], ) def it_determines_the_icon_height_to_help(self, icon_height_arg, expected_value): element_creator = _OleObjectElementCreator( @@ -2214,14 +2214,14 @@ def it_determines_the_icon_height_to_help(self, icon_height_arg, expected_value) assert element_creator._icon_height == expected_value @pytest.mark.parametrize( - "icon_file_arg, prog_id, expected_value", - ( + ("icon_file_arg", "prog_id", "expected_value"), + [ ("user-icon.png", PROG_ID.XLSX, "user-icon.png"), (None, "Foo.Bar.18", "generic-icon.emf"), (None, PROG_ID.DOCX, "docx-icon.emf"), (None, PROG_ID.PPTX, "pptx-icon.emf"), (None, PROG_ID.XLSX, "xlsx-icon.emf"), - ), + ], ) def it_resolves_the_icon_image_file_to_help(self, icon_file_arg, prog_id, expected_value): element_creator = _OleObjectElementCreator( @@ -2250,8 +2250,8 @@ def it_adds_and_relates_the_icon_image_part_to_help( assert rId == "rId16" @pytest.mark.parametrize( - "icon_width_arg, expected_value", - ((Emu(666666), Emu(666666)), (None, Emu(965200))), + ("icon_width_arg", "expected_value"), + [(Emu(666666), Emu(666666)), (None, Emu(965200))], ) def it_determines_the_icon_width_to_help(self, icon_width_arg, expected_value): element_creator = _OleObjectElementCreator( @@ -2287,13 +2287,13 @@ def it_adds_and_relates_the_ole_object_part_to_help( assert rId == "rId14" @pytest.mark.parametrize( - "prog_id_arg, expected_value", - ( + ("prog_id_arg", "expected_value"), + [ (PROG_ID.DOCX, "Word.Document.12"), (PROG_ID.PPTX, "PowerPoint.Show.12"), (PROG_ID.XLSX, "Excel.Sheet.12"), ("Something.Else.42", "Something.Else.42"), - ), + ], ) def it_resolves_the_progId_str_to_help(self, prog_id_arg, expected_value): element_creator = _OleObjectElementCreator( diff --git a/tests/test_custom_properties.py b/tests/test_custom_properties.py index d3dc6e989..64238d1ef 100644 --- a/tests/test_custom_properties.py +++ b/tests/test_custom_properties.py @@ -102,9 +102,9 @@ def it_returns_False_for_non_string_contains(self, empty_prs): def it_treats_a_property_with_no_value_child_as_absent(self, empty_prs): # Force a malformed entry: an op:property element with no vt:* child. # The lookup returns None → CustomProperties surfaces it as KeyError. + from pptx.oxml import parse_xml from pptx.oxml.custom_properties import DEFAULT_FMTID from pptx.oxml.ns import nsdecls - from pptx.oxml import parse_xml cp_part = empty_prs.part.package.custom_properties_part # Replace _element with a malformed Properties root containing one @@ -141,7 +141,8 @@ def it_writes_int_with_set_int_rejecting_bool(self, empty_prs): def it_writes_float_with_set_float(self, empty_prs): empty_prs.custom_properties.set_float("X", 3.14) prop = empty_prs.part.package.custom_properties_part.get_property("X") - assert prop is not None and prop.r8 is not None + assert prop is not None + assert prop.r8 is not None def it_writes_bool_with_set_bool(self, empty_prs): empty_prs.custom_properties.set_bool("X", False) diff --git a/tests/test_files/customxml/_generate_fixtures.py b/tests/test_files/customxml/_generate_fixtures.py index 98a8c907d..86bd30b83 100644 --- a/tests/test_files/customxml/_generate_fixtures.py +++ b/tests/test_files/customxml/_generate_fixtures.py @@ -15,7 +15,6 @@ from pptx import Presentation - _HERE = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_slide.py b/tests/test_slide.py index 3339c3796..7a94b5ec2 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -499,7 +499,7 @@ def it_raises_on_slide_not_in_collection(self, raises_fixture): def it_can_iterate_its_slides(self, iter_fixture): slides, related_slide_, calls, expected_value = iter_fixture - slide_lst = [s for s in slides] + slide_lst = list(slides) assert related_slide_.call_args_list == calls assert slide_lst == expected_value @@ -791,7 +791,7 @@ def it_can_iterate_its_slide_layouts(self, part_prop_, slide_master_part_): related_slide_layout_.side_effect = _slide_layouts slide_layouts = SlideLayouts(sldLayoutIdLst, None) - slide_layout_lst = [sl for sl in slide_layouts] + slide_layout_lst = list(slide_layouts) assert related_slide_layout_.call_args_list == [call("a"), call("b")] assert slide_layout_lst == _slide_layouts @@ -974,7 +974,7 @@ def it_knows_how_many_masters_it_contains(self, len_fixture): def it_can_iterate_the_slide_masters(self, iter_fixture): slide_masters, related_slide_master_, calls, expected_values = iter_fixture - _slide_masters = [sm for sm in slide_masters] + _slide_masters = list(slide_masters) assert related_slide_master_.call_args_list == calls assert _slide_masters == expected_values @@ -1045,15 +1045,15 @@ class Describe_Background(object): """Unit-test suite for `pptx.slide._Background` objects.""" @pytest.mark.parametrize( - "cSld_xml, expected_cxml", - ( + ("cSld_xml", "expected_cxml"), + [ ("p:cSld{a:b=c}", "p:cSld{a:b=c}/p:bg/p:bgPr/(a:noFill,a:effectLst)"), ( "p:cSld{a:b=c}/p:bg/p:bgRef", "p:cSld{a:b=c}/p:bg/p:bgPr/(a:noFill,a:effectLst)", ), ("p:cSld/p:bg/p:bgPr/a:solidFill", "p:cSld/p:bg/p:bgPr/a:solidFill"), - ), + ], ) def it_provides_access_to_its_fill(self, request, cSld_xml, expected_cxml): fill_ = instance_mock(request, FillFormat) diff --git a/tests/text/test_layout.py b/tests/text/test_layout.py index 6e2c83d6a..cb9fdc10a 100644 --- a/tests/text/test_layout.py +++ b/tests/text/test_layout.py @@ -51,12 +51,12 @@ def it_finds_best_fit_font_size_to_help_best_fit(self, _best_fit_fixture): assert font_size is font_size_ @pytest.mark.parametrize( - "extents, point_size, text_lines, expected_value", - ( + ("extents", "point_size", "text_lines", "expected_value"), + [ ((66, 99), 6, ("foo", "bar"), False), ((66, 100), 6, ("foo", "bar"), True), ((66, 101), 6, ("foo", "bar"), True), - ), + ], ) def it_provides_a_fits_inside_predicate_fn( self, diff --git a/tests/text/test_text.py b/tests/text/test_text.py index 73343b2b6..48bbc5bc1 100644 --- a/tests/text/test_text.py +++ b/tests/text/test_text.py @@ -70,12 +70,12 @@ def it_can_change_its_autosize_setting( @pytest.mark.parametrize( "txBody_cxml", - ( + [ "p:txBody/(a:p,a:p,a:p)", 'p:txBody/a:p/a:r/a:t"foo"', 'p:txBody/a:p/(a:br,a:r/a:t"foo")', 'p:txBody/a:p/(a:fld,a:br,a:r/a:t"foo")', - ), + ], ) def it_can_clear_itself_of_content(self, txBody_cxml): text_frame = TextFrame(element(txBody_cxml), None) @@ -1210,12 +1210,12 @@ def it_can_get_the_text_of_the_run(self, text_get_fixture): assert isinstance(text, str) @pytest.mark.parametrize( - "r_cxml, new_value, expected_r_cxml", - ( + ("r_cxml", "new_value", "expected_r_cxml"), + [ ("a:r/a:t", "barfoo", 'a:r/a:t"barfoo"'), ("a:r/a:t", "bar\x1bfoo", 'a:r/a:t"bar_x001B_foo"'), ("a:r/a:t", "bar\tfoo", 'a:r/a:t"bar\tfoo"'), - ), + ], ) def it_can_change_its_text(self, r_cxml, new_value, expected_r_cxml): run = _Run(element(r_cxml), None)