diff --git a/CHANGELOG.md b/CHANGELOG.md index 8386741..3e86904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Breaking + +- `parse_api_datetime()` and `datetime_type` now return timezone-aware UTC values instead of naive ones. Plugins comparing them against naive datetimes raise `TypeError`, and `.isoformat()` output gains a `+00:00` suffix (#266) + +### Changed + +- API timestamps, library date filters and voucher deadlines are now timezone-aware UTC; naive `--start-date`/`--end-date` input is interpreted as UTC, as the option help already stated (#266) +- Replaced `datetime.utcnow()` and `datetime.utcfromtimestamp()`, deprecated since Python 3.12 (#266) + ### Fixed - Accept API timestamps with and without fractional seconds, fixing the `--start-date`/`--end-date` crash on podcast episodes and the mirror-image crash in `is_published()` (#264) diff --git a/plugin_cmds/cmd_goodreads-transform.py b/plugin_cmds/cmd_goodreads-transform.py index a497100..462f7e7 100644 --- a/plugin_cmds/cmd_goodreads-transform.py +++ b/plugin_cmds/cmd_goodreads-transform.py @@ -1,6 +1,5 @@ import logging import pathlib -from datetime import timezone import click from audible_cli.decorators import ( @@ -85,9 +84,7 @@ def _prepare_library_for_export(library): date_added = i.library_status if date_added is not None: date_added = date_added["date_added"] - date_added = parse_api_datetime(date_added).replace( - tzinfo=timezone.utc - ) + date_added = parse_api_datetime(date_added) date_added = date_added.astimezone().date().isoformat() date_read = None diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 4bcc189..e21fd4e 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -4,7 +4,7 @@ import json import pathlib import logging -from datetime import datetime +from datetime import UTC, datetime import aiofiles import click @@ -392,7 +392,7 @@ async def _reuse_voucher(lr_file, item): if "refresh_date" in content_license: refresh_date = content_license["refresh_date"] refresh_date = datetime_type.convert(refresh_date, None, None) - if refresh_date < datetime.utcnow(): + if refresh_date < datetime.now(UTC): raise VoucherNeedRefresh(lr_file) content_metadata = content_license["content_metadata"] @@ -401,8 +401,8 @@ async def _reuse_voucher(lr_file, item): expires = url.params.get("Expires") if expires: - expires = datetime.utcfromtimestamp(int(expires)) - now = datetime.utcnow() + expires = datetime.fromtimestamp(int(expires), tz=UTC) + now = datetime.now(UTC) if expires < now: raise DownloadUrlExpired(lr_file) diff --git a/src/audible_cli/exceptions.py b/src/audible_cli/exceptions.py index 3c40e7f..c77b619 100644 --- a/src/audible_cli/exceptions.py +++ b/src/audible_cli/exceptions.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from .utils import parse_api_datetime @@ -79,7 +79,7 @@ class ItemNotPublished(AudibleCliException): def __init__(self, asin: str, pub_date): pub_date = parse_api_datetime(pub_date) - now = datetime.utcnow() + now = datetime.now(UTC) published_in = pub_date - now pub_str = "" diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index c1a9497..459a1a5 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -3,7 +3,7 @@ import secrets import string import unicodedata -from datetime import datetime +from datetime import UTC, datetime from math import ceil from typing import List, Optional, Union from warnings import warn @@ -21,7 +21,12 @@ NotDownloadableAsAAX, ItemNotPublished ) -from .utils import full_response_callback, LongestSubString, parse_api_datetime +from .utils import ( + full_response_callback, + LongestSubString, + parse_api_datetime, + to_utc_datetime +) logger = logging.getLogger("audible_cli.models") @@ -147,7 +152,7 @@ def is_published(self): if publication_datetime is not None: pub_date = parse_api_datetime(publication_datetime) - now = datetime.utcnow() + now = datetime.now(UTC) return now > pub_date @@ -498,6 +503,13 @@ async def from_api( end_date: Optional[datetime] = None, **request_params ): + # Callers may pass naive datetimes, so normalize the bounds before + # they are compared against the aware timestamps from the API. + if start_date is not None: + start_date = to_utc_datetime(start_date) + if end_date is not None: + end_date = to_utc_datetime(end_date) + def filter_by_date(item): if item.purchase_date is not None: date_added = parse_api_datetime(item.purchase_date) diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index 9758d51..03e0eb4 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -2,7 +2,7 @@ import io import logging import pathlib -from datetime import datetime +from datetime import UTC, datetime from difflib import SequenceMatcher from typing import List, Optional, Union @@ -22,41 +22,69 @@ logger = logging.getLogger("audible_cli.utils") -datetime_type = click.DateTime([ - "%Y-%m-%d", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%dT%H:%M:%S.%fZ", - "%Y-%m-%dT%H:%M:%SZ" -]) +def to_utc_datetime(value: datetime) -> datetime: + """Normalize a datetime to timezone-aware UTC. -API_DATETIME_FORMATS = ( - "%Y-%m-%dT%H:%M:%S.%fZ", - "%Y-%m-%dT%H:%M:%SZ" -) + Naive values are interpreted as UTC. That matches the documented + semantics of the ``--start-date`` and ``--end-date`` options, which ask + for a UTC date, and it keeps values coming from :data:`datetime_type` + comparable to the timestamps parsed from API responses. + """ + if value.utcoffset() is None: + return value.replace(tzinfo=UTC) + + return value.astimezone(UTC) def parse_api_datetime(value: str) -> datetime: - """Parse a timestamp as returned by the Audible API. + """Parse a timestamp as returned by the Audible API as UTC. The API uses both variants, with and without fractional seconds (``2019-11-29T11:40:49.000Z`` vs. ``2019-11-29T11:40:49Z``), sometimes for the same field. Top-level library items usually carry the fraction, child episodes of a podcast usually do not, so both have to be accepted. + Accepts anything :meth:`datetime.datetime.fromisoformat` understands as + long as it carries timezone information, and normalizes it to UTC. That + is deliberately wider than the two shapes above: an offset other than + ``Z`` denotes the same instant and is worth accepting, while a timestamp + without any offset is ambiguous and is not. + Raises: - ValueError: If the value matches none of the known formats. + ValueError: If the value is not a timestamp + :meth:`~datetime.datetime.fromisoformat` accepts, or if it does + not carry timezone information. """ - for fmt in API_DATETIME_FORMATS: - try: - return datetime.strptime(value, fmt) - except ValueError: - continue - - raise ValueError( - f"time data {value!r} does not match any known API datetime " - f"format {API_DATETIME_FORMATS}" - ) + try: + parsed = datetime.fromisoformat(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"invalid API datetime {value!r}") from exc + + if parsed.utcoffset() is None: + raise ValueError(f"API datetime {value!r} has no timezone information") + + return parsed.astimezone(UTC) + + +class UTCDateTime(click.DateTime): + """A :class:`click.DateTime` that always yields UTC values. + + The accepted formats either end in a literal ``Z`` or carry no timezone + at all, so :class:`click.DateTime` always returns a naive value. Both + cases mean UTC here, so the result is marked as such. + """ + + def convert(self, value, param, ctx): + return to_utc_datetime(super().convert(value, param, ctx)) + + +datetime_type = UTCDateTime([ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%fZ", + "%Y-%m-%dT%H:%M:%SZ" +]) def prompt_captcha_callback(captcha_url: str) -> str: