refactor!: make all API and download datetimes timezone-aware UTC - #266
Open
mkb79 wants to merge 1 commit into
Open
refactor!: make all API and download datetimes timezone-aware UTC#266mkb79 wants to merge 1 commit into
mkb79 wants to merge 1 commit into
Conversation
Follow-up to #265, which fixed the `--start-date` crash by tolerating both API timestamp shapes but kept the parsed values naive. Naive datetimes were the reason the two hardcoded formats could coexist for so long: the `Z` was parsed as a literal and its meaning silently dropped. Parse API timestamps with `datetime.fromisoformat()` instead of a list of formats. Timezone information is now mandatory and the result is normalized to UTC, so an offset other than `Z` denotes the same instant and is accepted, while an ambiguous timestamp without any offset is rejected. Because comparing naive against aware datetimes raises `TypeError`, this has to happen atomically rather than site by site: * `utils.to_utc_datetime()` normalizes a datetime to UTC, interpreting naive values as UTC. That matches the `--start-date` / `--end-date` help texts, which already asked for a UTC date. * `utils.UTCDateTime` wraps `click.DateTime` so `datetime_type` yields aware values. All of its formats either end in a literal `Z` or carry no timezone at all, and both mean UTC here. * `Library.from_api()` normalizes its `start_date` / `end_date` bounds, so programmatic callers passing naive values keep working. * `datetime.utcnow()` and `datetime.utcfromtimestamp()`, deprecated since Python 3.12, are replaced by `datetime.now(UTC)` and `datetime.fromtimestamp(..., tz=UTC)`. BREAKING CHANGE: `parse_api_datetime()` and `datetime_type` now return timezone-aware UTC datetimes. Plugins comparing those results against naive datetimes will raise `TypeError` and have to be adjusted.
mkb79
force-pushed
the
refactor/tz-aware-datetimes
branch
from
July 29, 2026 21:01
37e55ce to
7bafb4c
Compare
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.
Follow-up to #265. That PR fixed the
--start-datecrash from #264 by tolerating both API timestamp shapes, but deliberately kept the parsed values naive so the change stayed contained. This PR does the refactor that was left out.Why
Naive datetimes are the reason the two hardcoded formats could coexist unnoticed for so long: the trailing
Zwas parsed as a literal character and its meaning silently dropped. Once the timezone is actually carried, the "with or without fraction" distinction stops being a parsing concern at all.What changed
Parse API timestamps with
datetime.fromisoformat()instead of a list of formats. Timezone information is mandatory and the result is normalized to UTC — an offset other thanZdenotes the same instant and is accepted, an ambiguous timestamp without any offset is rejected.Comparing naive against aware datetimes raises
TypeError, so this has to land atomically rather than site by site:utils.to_utc_datetime()--start-date/--end-datehelp texts, which already ask for a UTC date.utils.UTCDateTimeclick.DateTimesubclass sodatetime_typeyields aware values. All its formats either end in a literalZor carry no timezone, and both mean UTC here.Library.from_api()start_date/end_datebounds, so programmatic callers passing naive values keep working.models.py,exceptions.py,cmd_download.pydatetime.utcnow()→datetime.now(UTC),datetime.utcfromtimestamp()→datetime.fromtimestamp(..., tz=UTC). Both deprecated since Python 3.12..replace(tzinfo=timezone.utc)dropped, since the parser now returns aware values.parse_api_datetime()anddatetime_typenow return timezone-aware UTC datetimes. Plugins that compare those results against naive datetimes will raiseTypeErrorand have to be adjusted..isoformat()output gains a+00:00suffix.Verification
A harness targeting the naive/aware fault lines specifically — all passing:
parse_api_datetime: both API shapes, 7-digit fractions,+02:00offsets; naive, invalid andNoneinputs rejected withValueErrorto_utc_datetime: naive → UTC, aware+02:00→ correctly converteddatetime_type: all five accepted formats yield aware UTC; already-parseddatetimeobjects are normalized tooLibrary.from_api: naivestart_date(noTypeError), awarestart_datein another timezone,end_datefiltering;purchased_afterstill serialized as2008-01-01T00:00:00.000000Zis_published()andItemNotPublishedwith both timestamp shapes_reuse_voucher: expiredrefresh_date→VoucherNeedRefresh, expiredExpires→DownloadUrlExpired, valid voucher acceptedAlso:
ruff check src plugin_cmds432 → 431 findings, none new; importing the touched modules under-W error::DeprecationWarningis clean.Out of scope
A review pass turned up four pre-existing bugs in code adjacent to this change. All four reproduce identically on
masterwith the refactor stashed, so they are not regressions and are deliberately left for separate PRs:episode_count != len(children),get_child_items()passes the originalrequest_params— includingstart_date/end_date— toCatalog.from_api(), which forwards them tocatalog/productsas query parameters and returns the result without any local date filtering.AttributeErroron partial library responses. With any date bound set, an item withpurchase_date: nulland a missing or nulllibrary_statushits.get("date_added")onNone.TypeErroron a nullrefresh_date._reuse_voucher()checks"refresh_date" in content_licenseand then converts the value, so an explicitnullreachesstrptimeasNone.is_published()returnsNonewhenpublication_datetimeis missing. Download paths read that as "not published" and then passNoneintoItemNotPublished, which now raisesValueErrorfrom the parser.Unrelated, carried over from #265:
--end-date 2019-11-29is parsed as00:00:00, so "on or before this UTC date" still means the start of that day.