Skip to content

Replace deprecated utcnow() and fix UTC-based timedelta date headers - #496

Merged
digitalresistor merged 5 commits into
mainfrom
fix-utcnow-deprecation
Aug 3, 2026
Merged

Replace deprecated utcnow() and fix UTC-based timedelta date headers#496
digitalresistor merged 5 commits into
mainfrom
fix-utcnow-deprecation

Conversation

@digitalresistor

Copy link
Copy Markdown
Member

Combines the three outstanding utcnow() / UTC-correctness pull requests into a single branch, with the conflicts resolved and the gaps closed. All three original authors are credited via their own commits.

Issues

Supersedes

These are all mutually exclusive — they rewrite the same lines — so none can be merged alongside the others. Their content is included here:

Closing keywords don't apply to PRs, so those three need closing by hand once this lands.

What's here

The deprecation (#473). Takes #480's approach, which @mmerickel favoured in review: a webob.datetime_utils.utcnow() helper built on datetime.now(timezone.utc) that returns a naive UTC datetime. Nothing on the public API surface changes type, which keeps this separate from the tz-aware migration discussed in #473.

Aware datetimes in cookies. Takes #475's fix so that a timezone-aware datetime passed to serialize_cookie_date() is converted to UTC rather than having its local wall-clock time written out as GMT.

One change was needed to make that safe. #475 called v.astimezone(timezone.utc) unconditionally, which treats a naive datetime as local time and shifts it by the machine's UTC offset — WebOb has always treated naive datetimes as UTC. Under TZ=America/New_York:

main                 Tue, 04-Jan-2011 13:43:50 GMT
#475 as submitted    Tue, 04-Jan-2011 18:43:50 GMT

CI runs in UTC, so this is invisible there. The conversion is now gated on v.tzinfo is not None, and test_serialize_cookie_date_naive_datetime_is_utc pins it.

UTC-based timedelta headers (#430). datetime_utils._now was datetime.now, i.e. naive local. The result is fed to calendar.timegm (which reads its argument as UTC) and serialized with usegmt=True, so the value came out off by the local UTC offset.

#491 fixed this by adding a _utcnow = datetime.utcnow hook — but that would reintroduce the deprecated call this branch removes, in the same module. Instead _now is repointed at the utcnow() helper. That also fixes a second occurrence of the same bug that #491 doesn't reach: parse_date_delta() resolved delta seconds against local time, so reading back Response.retry_after returned an instant off by the UTC offset. Keeping the single _now hook means the existing _NowRestorer tests continue to apply to both call sites.

Verified end-to-end against a real Response under TZ=Europe/Berlin:

                  before              after
real UTC now      06:55:14            06:57:32
r.expires         08:55:14  (+2h)     06:57:32  (0s skew)
r.retry_after     08:55:14  (local)   06:57:32  (UTC)
r.cache_expires   correct             correct

Testing

tox across lint, py310–py314, pypy39, pypy310, coverage and docs: all green, 2398 passed / 1 xfailed per interpreter, coverage --fail-under=100 at exactly 100%.

No datetime.utcnow() calls remain in src/ or tests/. The only DeprecationWarning the suite still raises is WebOb's own pre-existing acceptparse.__contains__ one.

New tests: naive/aware/date handling in serialize_cookie_date, the utcnow() helper itself, and tz-sensitive regression tests for both serialize_date and parse_date_delta (skipped where time.tzset() is unavailable).

Not addressed

Response.retry_after still returns a naive datetime on the delta-seconds path while parse_date returns an aware UTC one, so comparing the two raises TypeError. Reconciling that is the tz-aware migration @mmerickel called "much more controversial" in #473, and is deliberately left alone here.

@jenstroeger

Copy link
Copy Markdown
Contributor

Thank you @digitalresistor for moving this forward!

@digitalresistor

Copy link
Copy Markdown
Member Author

@jenstroeger do you have any comments on this MR? Just looking for a 👍 to make sure I didn't miss something obvious!

@jenstroeger jenstroeger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jenstroeger do you have any comments on this MR? Just looking for a 👍 to make sure I didn't miss something obvious!

The changes look ok to me, though I’m not versed with webob details. One note on the tests: maybe consider using the pytest.setenv() monkeypatch context manager?

digitalresistor pushed a commit that referenced this pull request Aug 3, 2026
Per review feedback on #496, replace the hand-rolled os.environ save/restore
in the TZ-sensitive tests with pytest's monkeypatch context manager, wrapped
in a local_timezone fixture so the tzset() that repopulates the C library's
cached zone still runs after monkeypatch restores TZ.

pytest.MonkeyPatch became public in pytest 6.2, so bump the testing extra's
floor accordingly.
digitalresistor added a commit that referenced this pull request Aug 3, 2026
Per review feedback on #496, replace the hand-rolled os.environ save/restore
in the TZ-sensitive tests with pytest's monkeypatch context manager, wrapped
in a local_timezone fixture so the tzset() that repopulates the C library's
cached zone still runs after monkeypatch restores TZ.

pytest.MonkeyPatch became public in pytest 6.2, so bump the testing extra's
floor accordingly.
@digitalresistor
digitalresistor force-pushed the fix-utcnow-deprecation branch from 4002c11 to 503102c Compare August 3, 2026 05:33
kajinamit and others added 5 commits August 2, 2026 23:37
The utcnow function was deprecated in Python 3.12[1].

Note that all datetime instances are kept non-timezone-aware to keep
backword-compatibility.

[1] https://docs.python.org/3.13/library/datetime.html#datetime.datetime.utcnow
Combining #475 and #480 leaves one behavior change worth guarding: 475's
unconditional v.astimezone(timezone.utc) treats a *naive* datetime as local
time, shifting it by the machine's UTC offset. WebOb has always treated naive
datetimes as UTC, and CI runs in UTC so the change is invisible there.

Gate the conversion on v.tzinfo, keeping 475's fix for aware datetimes while
leaving naive ones alone, and cover the naive/aware/date paths plus the new
utcnow() helper.
serialize_date() added the delta to _now(), which was datetime.now and so
naive *local* time. The result is then fed to calendar.timegm (which reads its
argument as UTC) and serialized with usegmt=True, so the header came out off by
the machine's UTC offset.

Adapted from #491 for this branch:

- Rather than adding a _utcnow = datetime.utcnow hook, _now is now the
  utcnow() helper introduced alongside the #473 deprecation work. #491's
  original form would have reintroduced the deprecated call this branch
  removes, in the same module.

- Repointing _now fixes the second occurrence of the same bug, in
  parse_date_delta(), which resolved delta seconds against local time and made
  e.g. Response.retry_after return an instant off by the UTC offset. Covered by
  test_parse_date_delta_is_utc_based.

Keeping the single _now hook means the existing _NowRestorer tests continue to
apply to both call sites.
Per review feedback on #496, replace the hand-rolled os.environ save/restore
in the TZ-sensitive tests with pytest's monkeypatch context manager, wrapped
in a local_timezone fixture so the tzset() that repopulates the C library's
cached zone still runs after monkeypatch restores TZ.

pytest.MonkeyPatch became public in pytest 6.2, so bump the testing extra's
floor accordingly.
@digitalresistor
digitalresistor force-pushed the fix-utcnow-deprecation branch from 503102c to bcc693a Compare August 3, 2026 05:38
@digitalresistor
digitalresistor merged commit e9842ff into main Aug 3, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python 3.13 deprecation of utcnow() Setting response.expires via timedelta produces an incorrect header value

5 participants