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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions plugin_cmds/cmd_goodreads-transform.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import pathlib
from datetime import timezone

import click
from audible_cli.decorators import (
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/audible_cli/cmds/cmd_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
import pathlib
import logging
from datetime import datetime
from datetime import UTC, datetime

import aiofiles
import click
Expand Down Expand Up @@ -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"]
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/audible_cli/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path

from .utils import parse_api_datetime
Expand Down Expand Up @@ -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 = ""
Expand Down
18 changes: 15 additions & 3 deletions src/audible_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
76 changes: 52 additions & 24 deletions src/audible_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down