feat: Font.color non-mutation + UTC-aware datetimes + Shapes.by_name (Modernization Phase 2)#43
Merged
MHoroszowski merged 1 commit intomasterfrom May 8, 2026
Merged
Conversation
…(Modernization Phase 2) Issue: #29 (Phase 2) Phase 1 (PR #39) shipped PathLike + PERCENT_40 typo + Slide.background fix. This PR closes three nagging upstream tickets in a single bundle, all covered by issue #29's "bug fixes that pollute every other epic if left unfixed" group plus the most-cited shape-lookup ergonomic from the API ergonomics group. Bug fixes - `Font.color` getter is now non-mutating. Prior implementation called `self.fill.solid()` on every read, inserting `<a:solidFill/>` into the run's `<a:rPr>` element — meaning *reading* a font's color silently modified the document. New implementation returns a `_LazyFontColorFormat` proxy that mirrors `ColorFormat`'s public surface; reads (`type`, `rgb`, `theme_color`, `brightness`, `transparency`) return |None| / inherit values without writing; the setter path materializes `<a:solidFill/>` lazily on first write and delegates to the real `ColorFormat`. Closes scanny#1111 and scanny#1074. - W3CDTF datetime parser returns tz-aware datetimes when the source string carries timezone information. `Z` suffix → UTC; numeric offsets like `-08:00` or `+05:30` → fixed-offset `datetime.timezone`. Strings with no offset (year-only, year-month, year-month-day, or bare timestamp) continue to return naive datetimes — we don't assume a timezone where none was given. The setter accepts both naive and tz-aware inputs; tz-aware inputs are converted to UTC via `astimezone(timezone.utc)` before serialization so the on-disk W3CDTF form always uses the canonical `YYYY-MM-DDTHH:MM:SSZ` shape. Closes scanny#957. Sign-convention fix: the prior `_offset_dt` had `sign_factor = -1 if sign == "+" else 1` (inverted vs. POSIX/W3CDTF convention) and *adjusted the clock values*; the new `_tzinfo_from_offset_str` uses `sign_factor = 1 if sign == "+" else -1` and returns a `tzinfo` instance, leaving the clock values intact. End-to-end behavior is preserved when callers convert via `astimezone(utc)` — `'2024-01-15 T10:30:00-08:00'` still represents the same instant (18:30 UTC). API ergonomics - `_BaseShapes.by_name(name)` lookup helper. Returns the first shape in document order whose `.name` matches `name` (case-sensitive, matching PowerPoint's behavior). Raises `KeyError` with a clear message on miss. Defined on `_BaseShapes` so it inherits to `SlideShapes`, `SlideLayoutShapes`, `SlideMasterShapes`, etc. Closes scanny#798, scanny#309, and scanny#532. Behavior changes (intentional, documented in the PR body) - Reading `font.color` no longer inserts `<a:solidFill/>`. Code that relied on the mutation as a side-effect needs to call `font.fill.solid()` explicitly. - Reading `core_properties.created` / `last_printed` / `modified` on a document whose XML carries `Z` or numeric offset markers now returns a tz-aware datetime. Naive-input expectations must update. Tests - 27 new pytest cases in `tests/test_modernization_phase2.py` covering byte-stable XML on Font.color reads, lazy materialization on first set, datetime parser tz-awareness across all five W3CDTF input forms, round-trip through save/reload, and the three by_name paths (hit / miss / case-sensitivity / multi-match / inheritance to layouts and masters). Two existing test fixtures updated to expect tz-aware values where the input strings carry `Z`. Full pytest: `3453 passed`. - 5 new behave scenarios in `features/modernization-phase2.feature` (Font.color non-mutation byte-diff, lazy materialization, tz-aware round-trip, by_name match, by_name miss). Existing `features/steps/coreprops.py` updated to use tz-aware datetimes for the now-tz-aware reload values. Full behave: `1041 scenarios passed, 0 failed`. - Ruff: `ruff check src tests` → All checks passed; `ruff format --check` → no diff. Skipped from issue #29 Phase 2 (deferred or no-op) - `collections.abc` import path migration (would close scanny#771): already complete in this fork; verified via grep across `src/`. - `iter_leaf_shapes`, `Mapping` ABC, `find_by_xpath`, selection-pane order listing: deferred to a future Phase 4 to keep Phase 2 focused on bug fixes. Refs #29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of issue #29 (Modernization & Ergonomics): bug fixes + by_name
Phase 1 (PR #39) shipped PathLike + PERCENT_40 typo +
Slide.backgroundfix. This PR closes three nagging upstream tickets in a single bundle, all from issue #29's "bug fixes that pollute every other epic if left unfixed" group plus the most-cited shape-lookup ergonomic from the API ergonomics group.Public surface — bug fixes
What it adds
Font.colornon-mutation (closes scanny#1111, scanny#1074)Font.colorreturns a new_LazyFontColorFormatproxy that mirrorsColorFormat's public surface.type,rgb,theme_color,brightness,transparency) returnNoneon a run with no<a:solidFill/>— the inherit signal — without writing to XML.rgb=,theme_color=,brightness=,transparency=) materialize<a:solidFill/>lazily on first set, then delegate to the realColorFormat.MSO_THEME_COLOR.NOT_THEME_COLOR/0.0on the no-fill path — those are real settable values, not inherit signals. Conflating them would silently break inheritance on read/write round-trips. Fixed to returnNonefor all four reads on the no-fill path.UTC-aware datetimes (closes scanny#957)
_parse_W3CDTF_to_datetimereturns tz-aware datetimes when the source string carries timezone info:Z→ UTC;±HH:MM→ fixed-offsetdatetime.timezone._set_element_datetimeaccepts both naive and tz-aware datetimes; tz-aware inputs are converted to UTC viaastimezone(timezone.utc)before serialization, so the on-disk W3CDTF form always uses the canonicalYYYY-MM-DDTHH:MM:SSZshape._offset_dthadsign_factor = -1 if "+" else 1(inverted) and adjusted clock values; new_tzinfo_from_offset_struses POSIX-correctsign_factor = 1 if "+" else -1and returns atzinfoinstance. Forge audit walked through'-08:00'and'+05:30'— same effective UTC instant produced in both old and new code (old returned naive-but-UTC-shifted, new returns tz-aware-with-original-offset)._BaseShapes.by_name(name)(closes scanny#798, scanny#309, scanny#532).namematches.KeyErrorwith a clear message on miss._BaseShapesso it inherits toSlideShapes,SlideLayoutShapes,SlideMasterShapes, etc.Behavior changes (intentional, called out for downstream consumers)
font.colorno longer inserts<a:solidFill/>. Code relying on the mutation as a side-effect must callfont.fill.solid()explicitly.core_properties.created/last_printed/modifiedon a document whose XML carriesZor±HH:MMmarkers now returns a tz-aware datetime. Tests and callers that expected naive datetimes need updating; this PR updates the two existing test fixtures that exercised this path.Tests
tests/test_modernization_phase2.pycovering byte-stable XML on Font.color reads, lazy materialization on first set, the inherit-signal contract (Nonefor all four reads on no-fill), datetime parser tz-awareness across all five W3CDTF input forms, round-trip through save/reload, and the fiveby_namepaths (hit, miss, case-sensitivity, multi-match, inheritance to layouts and masters). Two existing test fixtures updated to expect tz-aware values where the input strings carryZ. Full pytest:3456 passed.features/modernization-phase2.feature. Existingfeatures/steps/coreprops.pyupdated to use tz-aware datetimes for the now-tz-aware reload values. Full behave:1041 scenarios passed, 0 failed.ruff check src tests→ All checks passed;ruff format --check→ no diff.Reporting contract (CLAUDE.md §7)
UAT
uat_modernization_phase2.py(untracked per CLAUDE.md §6) at repo root.by_namehit/miss.Skipped from issue #29 Phase 2 (deferred or no-op)
collections.abcimport path migration (would close Compaitibility script no longer breaks to collections changes in 3.10 scanny/python-pptx#771): already complete in this fork — every relevant import already readsfrom collections.abc import X. No-op.iter_leaf_shapes,MappingABC,find_by_xpath, selection-pane order listing: deferred to a future Phase 4 to keep Phase 2 focused on bug fixes.Refs #29