Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions features/modernization-phase2.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Feature: Modernization & Ergonomics Phase 2 — bug fixes + by_name
In order to inspect fonts without polluting the document, round-trip datetimes faithfully, and look up shapes by name
As a developer using python-pptx
I need a non-mutating Font.color getter, tz-aware core-property datetimes, and Shapes.by_name(name)


Scenario: Reading font.color does not insert a:solidFill
Given a fresh slide with a title placeholder
When I read run.font.color.rgb on an unstyled run
Then the underlying rPr XML is unchanged from before the access


Scenario: Setting font.color.rgb materializes a:solidFill lazily
Given a fresh slide with a title placeholder
When I set run.font.color.rgb to RGBColor(0xFF, 0x00, 0x00)
Then the underlying rPr now contains an a:solidFill child
And run.font.color.rgb reads back as FF0000


Scenario: Tz-aware core_properties.created round-trips faithfully
Given a fresh presentation for core-property datetimes
When I set core_properties.created to a tz-aware UTC datetime
Then the reloaded core_properties.created is tz-aware


Scenario: shapes.by_name returns the matching shape
Given a fresh slide with a title placeholder
Then shapes.by_name("Title 1") returns the title shape


Scenario: shapes.by_name raises KeyError on miss
Given a fresh slide with a title placeholder
Then shapes.by_name("Bogus") raises KeyError
17 changes: 11 additions & 6 deletions features/steps/coreprops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from behave import given, then, when
from helpers import no_core_props_pptx_path, saved_pptx_path
Expand All @@ -28,18 +28,21 @@ def step_when_open_presentation_with_no_core_props_part(context):

@when("I set the core properties to valid values")
def step_when_set_core_doc_props_to_valid_values(context):
# ---issue #29 Phase 2: datetimes round-trip as tz-aware UTC values, so
# ---the input fixture is already tz-aware UTC for an apples-to-apples
# ---comparison with the reloaded value.
context.propvals = (
("author", "Creator"),
("category", "Category"),
("comments", "Description"),
("content_status", "Content Status"),
("created", datetime(2013, 6, 15, 12, 34, 56)),
("created", datetime(2013, 6, 15, 12, 34, 56, tzinfo=timezone.utc)),
("identifier", "Identifier"),
("keywords", "key; word; keyword"),
("language", "Language"),
("last_modified_by", "Last Modified By"),
("last_printed", datetime(2013, 6, 15, 12, 34, 56)),
("modified", datetime(2013, 6, 15, 12, 34, 56)),
("last_printed", datetime(2013, 6, 15, 12, 34, 56, tzinfo=timezone.utc)),
("modified", datetime(2013, 6, 15, 12, 34, 56, tzinfo=timezone.utc)),
("revision", 9),
("subject", "Subject"),
# --- exercise unicode-text case for Python 2.7 ---
Expand All @@ -60,8 +63,10 @@ def step_then_a_core_props_part_with_def_vals_is_added(context):
assert core_props.last_modified_by == "python-pptx"
assert core_props.revision == 1
# core_props.modified only stores time with seconds resolution, so
# comparison needs to be a little loose (within two seconds)
modified_timedelta = datetime.utcnow() - core_props.modified
# comparison needs to be a little loose (within two seconds). Issue #29
# Phase 2 makes the parser return a tz-aware datetime; use a tz-aware
# `now()` here so the subtraction is well-typed.
modified_timedelta = datetime.now(timezone.utc) - core_props.modified
max_expected_timedelta = timedelta(seconds=2)
assert modified_timedelta < max_expected_timedelta

Expand Down
106 changes: 106 additions & 0 deletions features/steps/modernization_phase2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Gherkin steps for Modernization Phase 2 (issue #29)."""

from __future__ import annotations

import datetime as dt
import io

import pytest
from behave import given, then, when
from lxml import etree

from pptx import Presentation
from pptx.dml.color import RGBColor


# given ===================================================


@given("a fresh slide with a title placeholder")
def given_a_fresh_slide_with_title(context):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
context.prs = prs
context.slide = slide


@given("a fresh presentation for core-property datetimes")
def given_a_fresh_presentation_for_core_props(context):
context.prs = Presentation()


# when ====================================================


@when("I read run.font.color.rgb on an unstyled run")
def when_read_font_color_unstyled(context):
tf = context.slide.shapes.title.text_frame
run = tf.paragraphs[0].add_run()
run.text = "x"
rPr = run._r.get_or_add_rPr()
context.run = run
context.rPr = rPr
context.before_xml = etree.tostring(rPr)
_ = run.font.color.rgb


@when("I set run.font.color.rgb to RGBColor(0xFF, 0x00, 0x00)")
def when_set_font_color_red(context):
tf = context.slide.shapes.title.text_frame
run = tf.paragraphs[0].add_run()
run.text = "x"
context.run = run
context.rPr = run._r.get_or_add_rPr()
run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00)


@when("I set core_properties.created to a tz-aware UTC datetime")
def when_set_created_utc(context):
context.target_dt = dt.datetime(2024, 7, 4, 17, 0, 0, tzinfo=dt.timezone.utc)
context.prs.core_properties.created = context.target_dt


# the "save and reload via stream" step is shared with tbl_styles.py


# then ====================================================


@then("the underlying rPr XML is unchanged from before the access")
def then_rPr_unchanged(context):
after = etree.tostring(context.rPr)
assert context.before_xml == after, (context.before_xml, after)


@then("the underlying rPr now contains an a:solidFill child")
def then_rPr_has_solidFill(context):
ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
assert context.rPr.find("%ssolidFill" % ns) is not None


@then("run.font.color.rgb reads back as FF0000")
def then_font_color_rgb_reads_FF0000(context):
assert context.run.font.color.rgb == RGBColor(0xFF, 0x00, 0x00)


@then("the reloaded core_properties.created is tz-aware")
def then_reloaded_created_is_tzaware(context):
buf = io.BytesIO()
context.prs.save(buf)
buf.seek(0)
prs2 = Presentation(buf)
reloaded = prs2.core_properties.created
assert reloaded.tzinfo is not None
assert reloaded == context.target_dt


@then('shapes.by_name("Title 1") returns the title shape')
def then_by_name_returns_title(context):
sh = context.slide.shapes.by_name("Title 1")
assert sh.name == "Title 1"


@then('shapes.by_name("Bogus") raises KeyError')
def then_by_name_raises_keyerror(context):
with pytest.raises(KeyError):
context.slide.shapes.by_name("Bogus")
62 changes: 44 additions & 18 deletions src/pptx/oxml/coreprops.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,34 +210,44 @@ def _get_or_add(self, prop_name: str):
return element

@classmethod
def _offset_dt(cls, datetime: dt.datetime, offset_str: str):
"""Return |datetime| instance offset from `datetime` by offset specified in `offset_str`.

`offset_str` is a string like `'-07:00'`.
"""
def _tzinfo_from_offset_str(cls, offset_str: str) -> dt.timezone:
"""Return a :class:`datetime.timezone` parsed from a W3CDTF offset like '-08:00'."""
match = cls._offset_pattern.match(offset_str)
if match is None:
raise ValueError(f"{repr(offset_str)} is not a valid offset string")
sign, hours_str, minutes_str = match.groups()
sign_factor = -1 if sign == "+" else 1
sign_factor = 1 if sign == "+" else -1
hours = int(hours_str) * sign_factor
minutes = int(minutes_str) * sign_factor
td = dt.timedelta(hours=hours, minutes=minutes)
return datetime + td
return dt.timezone(dt.timedelta(hours=hours, minutes=minutes))

_offset_pattern = re.compile(r"([+-])(\d\d):(\d\d)")

@classmethod
def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime:
# valid W3CDTF date cases:
# yyyy e.g. '2003'
# yyyy-mm e.g. '2003-12'
# yyyy-mm-dd e.g. '2003-12-31'
# UTC timezone e.g. '2003-12-31T10:14:55Z'
# numeric timezone e.g. '2003-12-31T10:14:55-08:00'
"""Parse a W3CDTF string into a :class:`datetime.datetime`.

Returns a tz-aware datetime when the input string carries timezone
information — ``Z`` suffix maps to UTC; numeric offsets like
``-08:00`` map to a fixed-offset :class:`datetime.timezone`. When the
input has no timezone marker (year-only, year-month, year-month-day,
or a bare timestamp), the returned datetime is naive — callers
should not assume any specific timezone for naive inputs.

Closes scanny/python-pptx#957 — the prior implementation always
returned a naive datetime, even for strings that explicitly carried
timezone information.

valid W3CDTF date cases:
- yyyy e.g. '2003'
- yyyy-mm e.g. '2003-12'
- yyyy-mm-dd e.g. '2003-12-31'
- UTC timezone e.g. '2003-12-31T10:14:55Z'
- numeric timezone e.g. '2003-12-31T10:14:55-08:00'
"""
templates = ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y-%m", "%Y")
# strptime isn't smart enough to parse literal timezone offsets like
# '-07:30', so we have to do it ourselves
# ---strptime can't parse literal timezone offsets like '-07:30', so
# ---we strip the offset and add tzinfo ourselves below.
parseable_part = w3cdtf_str[:19]
offset_str = w3cdtf_str[19:]
timestamp = None
Expand All @@ -249,15 +259,31 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime:
if timestamp is None:
tmpl = "could not parse W3CDTF datetime string '%s'"
raise ValueError(tmpl % w3cdtf_str)
# ---'Z' means UTC---
if offset_str == "Z":
return timestamp.replace(tzinfo=dt.timezone.utc)
# ---numeric offset like '-08:00'---
if len(offset_str) == 6:
return cls._offset_dt(timestamp, offset_str)
return timestamp.replace(tzinfo=cls._tzinfo_from_offset_str(offset_str))
# ---no timezone marker; return naive (don't assume UTC)---
return timestamp

def _set_element_datetime(self, prop_name: str, value: dt.datetime) -> None:
"""Set date/time value of child element having `prop_name` to `value`."""
"""Set date/time value of child element having `prop_name` to `value`.

Accepts both naive and tz-aware datetimes. Tz-aware inputs are
converted to UTC before serialization so the on-disk W3CDTF form
always uses the canonical ``YYYY-MM-DDTHH:MM:SSZ`` shape. Naive
inputs are written as-is (with the trailing ``Z`` suffix indicating
UTC) — callers passing naive datetimes are responsible for the
timezone interpretation.
"""
if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance]
tmpl = "property requires <type 'datetime.datetime'> object, got %s"
raise ValueError(tmpl % type(value))
# ---tz-aware -> normalize to UTC before serializing---
if value.tzinfo is not None:
value = value.astimezone(dt.timezone.utc).replace(tzinfo=None)
element = self._get_or_add(prop_name)
dt_str = value.strftime("%Y-%m-%dT%H:%M:%SZ")
element.text = dt_str
Expand Down
17 changes: 17 additions & 0 deletions src/pptx/shapes/shapetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ def __len__(self) -> int:
shape_elms = list(self._iter_member_elms())
return len(shape_elms)

def by_name(self, name: str) -> BaseShape:
"""Return the first shape in this collection whose `.name` equals `name`.

Lookup is case-sensitive, matching PowerPoint's own behavior. When
multiple shapes share the same name (uncommon but possible —
PowerPoint does not enforce uniqueness), the first match in
document order is returned. Raises |KeyError| with a clear message
if no match is found.

Closes scanny/python-pptx#798, scanny/python-pptx#309, and
scanny/python-pptx#532.
"""
for shape in self:
if shape.name == name:
return shape
raise KeyError("no shape named %r in this collection" % name)

def clone_placeholder(self, placeholder: LayoutPlaceholder) -> None:
"""Add a new placeholder shape based on `placeholder`."""
sp = placeholder.element
Expand Down
Loading
Loading