Skip to content

Commit e512ccd

Browse files
eliasbenbCopilotJonnyWong16
authored
Datetime timezone awareness (#1590)
* feat: Global datetime timezone setter/resolver * feat: Normalize to configured timezone in utils.toDatetime * chore: setDatetimeTimezone tests * style: Solve flake8 warnings * docs: Note possible breaking behavioral change when plexapi.timezone is toggled * refactor: Solve C901 flake8 error * chore: Integration test for timezone awareness * chore: Force a realod to get re-run datetime parsing with configure tz * chore: Migrate tz tests from the server module to a less important video module as to not conflict with other tests * Document tzdata requirement on systems that don't have it by default (e.g. alpine, windows) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Handle case where toDatetime is called with format when the dt already has timezone info * refactor: Keep `plexapi.DATETIME_TIMEZONE` and `utils.DATETIME_TIMEZONE` in sync (aliased) * chore: Fix incorrect type assignment on timezone test * fix: bool-like parsing in setDatetimeTimezone resolution Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Stale DATETIME_TIMZONE ref Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * style: Unused import * style: Resolve flake8 E501 line too long * refactor: Use `utils.cast()` for partial config parsing Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> * refactor: Keep one reference to the configured timezone Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> * refactor: Re-export DATETIME_TIMEZONE * fix: `DATETIME_TIMEZONE` desync when imported from `plexapi` Re-exporting `plexapi.utils.DATETIME_TIMEZONE` stores a copy of the string at import time, which can cause descync if setDatetimeTimezone is later called. Instead, we should dynamically load the `DATETIME_TIMEZONE` reference in a module-level `__getattr__` * chore: Test to confirm plexapi and plexapi.utils `DATETIME_TIMEZONE` references stay in-sync --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
1 parent e455160 commit e512ccd

File tree

5 files changed

+179
-18
lines changed

5 files changed

+179
-18
lines changed

docs/configuration.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ are optional. An example config.ini file may look like the following with all po
1212
[plexapi]
1313
container_size = 50
1414
timeout = 30
15+
timezone = false
1516
1617
[auth]
1718
myplex_username = johndoe
@@ -69,6 +70,19 @@ Section [plexapi] Options
6970
When the options is set to `true` the connection procedure will be aborted with first successfully
7071
established connection (default: false).
7172

73+
**timezone**
74+
Controls whether :func:`~plexapi.utils.toDatetime` returns timezone-aware datetime objects.
75+
76+
* `false` (default): keep naive datetime objects (backward compatible).
77+
* `true` or `local`: use the local machine timezone.
78+
* IANA timezone string (for example `UTC` or `America/New_York`): use that timezone.
79+
80+
This feature relies on Python's :class:`zoneinfo.ZoneInfo` and the availability of IANA tzdata
81+
on the system. On platforms without system tzdata (notably Windows), you may need to install
82+
the :mod:`tzdata` Python package for IANA timezone strings (such as ``America/New_York``) to
83+
work as expected.
84+
Toggling this option may break comparisons between aware and naive datetimes.
85+
7286

7387
Section [auth] Options
7488
----------------------

plexapi/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from platform import uname
55
from uuid import getnode
66

7-
from plexapi.config import PlexConfig, reset_base_headers
87
import plexapi.const as const
9-
from plexapi.utils import SecretsFilter
8+
import plexapi.utils as utils
9+
from plexapi.config import PlexConfig, reset_base_headers
10+
from plexapi.utils import SecretsFilter, setDatetimeTimezone
1011

1112
# Load User Defined Config
1213
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
@@ -17,6 +18,8 @@
1718
PROJECT = 'PlexAPI'
1819
VERSION = __version__ = const.__version__
1920
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
21+
setDatetimeTimezone(CONFIG.get('plexapi.timezone', False))
22+
2023
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
2124
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
2225

@@ -50,3 +53,10 @@
5053
logfilter = SecretsFilter()
5154
if CONFIG.get('log.show_secrets', '').lower() != 'true':
5255
log.addFilter(logfilter)
56+
57+
58+
def __getattr__(name):
59+
""" Dynamic module attribute access for aliased values. """
60+
if name == 'DATETIME_TIMEZONE':
61+
return utils.DATETIME_TIMEZONE
62+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

plexapi/utils.py

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from threading import Event, Thread
1919
from urllib.parse import quote
2020
from xml.etree import ElementTree
21+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2122

2223
import requests
2324
from requests.status_codes import _codes as codes
@@ -103,6 +104,9 @@
103104
# Plex Objects - Populated at runtime
104105
PLEXOBJECTS = {}
105106

107+
# Global timezone for toDatetime() conversions, set by setDatetimeTimezone()
108+
DATETIME_TIMEZONE = None
109+
106110

107111
class SecretsFilter(logging.Filter):
108112
""" Logging filter to hide secrets. """
@@ -326,6 +330,66 @@ def threaded(callback, listargs):
326330
return [r for r in results if r is not None]
327331

328332

333+
def setDatetimeTimezone(value):
334+
""" Sets the timezone to use when converting values with :func:`toDatetime`.
335+
336+
Parameters:
337+
value (bool, str):
338+
- ``False`` or ``None`` to disable timezone (default).
339+
- ``True`` or ``"local"`` to use the local timezone.
340+
- A valid IANA timezone (e.g. ``UTC`` or ``America/New_York``).
341+
342+
Returns:
343+
datetime.tzinfo: Resolved timezone object or ``None`` if disabled or invalid.
344+
"""
345+
global DATETIME_TIMEZONE
346+
347+
# Disable timezone if value is False or None
348+
if value is None or value is False:
349+
tzinfo = None
350+
# Use local timezone if value is True or "local"
351+
elif value is True or str(value).strip().lower() == 'local':
352+
tzinfo = datetime.now().astimezone().tzinfo
353+
# Attempt to resolve value as a boolean-like string or IANA timezone string
354+
else:
355+
setting = str(value).strip()
356+
# Try to cast as boolean first (normalize to lowercase for case-insensitive matching)
357+
try:
358+
is_enabled = cast(bool, setting.lower())
359+
tzinfo = datetime.now().astimezone().tzinfo if is_enabled else None
360+
except ValueError:
361+
# Not a boolean string, try parsing as IANA timezone
362+
try:
363+
tzinfo = ZoneInfo(setting)
364+
except ZoneInfoNotFoundError:
365+
tzinfo = None
366+
log.warning('Failed to set timezone to "%s", defaulting to None', value)
367+
368+
DATETIME_TIMEZONE = tzinfo
369+
return DATETIME_TIMEZONE
370+
371+
372+
def _parseTimestamp(value, tzinfo):
373+
""" Helper function to parse a timestamp value into a datetime object. """
374+
try:
375+
value = int(value)
376+
except ValueError:
377+
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
378+
return None
379+
try:
380+
if tzinfo:
381+
return datetime.fromtimestamp(value, tz=tzinfo)
382+
return datetime.fromtimestamp(value)
383+
except (OSError, OverflowError, ValueError):
384+
try:
385+
if tzinfo:
386+
return datetime.fromtimestamp(0, tz=tzinfo) + timedelta(seconds=value)
387+
return datetime.fromtimestamp(0) + timedelta(seconds=value)
388+
except OverflowError:
389+
log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
390+
return None
391+
392+
329393
def toDatetime(value, format=None):
330394
""" Returns a datetime object from the specified value.
331395
@@ -334,26 +398,20 @@ def toDatetime(value, format=None):
334398
format (str): Format to pass strftime (optional; if value is a str).
335399
"""
336400
if value is not None:
401+
tzinfo = DATETIME_TIMEZONE
337402
if format:
338403
try:
339-
return datetime.strptime(value, format)
404+
dt = datetime.strptime(value, format)
405+
# If parsed datetime already contains timezone
406+
if dt.tzinfo is not None:
407+
return dt.astimezone(tzinfo) if tzinfo else dt
408+
else:
409+
return dt.replace(tzinfo=tzinfo) if tzinfo else dt
340410
except ValueError:
341411
log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format)
342412
return None
343413
else:
344-
try:
345-
value = int(value)
346-
except ValueError:
347-
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
348-
return None
349-
try:
350-
return datetime.fromtimestamp(value)
351-
except (OSError, OverflowError, ValueError):
352-
try:
353-
return datetime.fromtimestamp(0) + timedelta(seconds=value)
354-
except OverflowError:
355-
log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
356-
return None
414+
return _parseTimestamp(value, tzinfo)
357415
return value
358416

359417

tests/test_utils.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import time
22

3-
import plexapi.utils as utils
43
import pytest
4+
5+
import plexapi
6+
import plexapi.utils as utils
57
from plexapi.exceptions import NotFound
68

79

@@ -12,6 +14,50 @@ def test_utils_toDatetime():
1214
# assert str(utils.toDatetime('0'))[:-9] in ['1970-01-01', '1969-12-31']
1315

1416

17+
def test_utils_setDatetimeTimezone_disabled_and_utc():
18+
original_tz = utils.DATETIME_TIMEZONE
19+
try:
20+
assert utils.setDatetimeTimezone(False) is None
21+
assert utils.toDatetime("0").tzinfo is None
22+
23+
tzinfo = utils.setDatetimeTimezone("UTC")
24+
assert tzinfo is not None
25+
assert utils.toDatetime("0").tzinfo == tzinfo
26+
assert utils.toDatetime("2026-01-01", format="%Y-%m-%d").tzinfo == tzinfo
27+
finally: # Restore for other tests
28+
utils.DATETIME_TIMEZONE = original_tz
29+
30+
31+
def test_utils_setDatetimeTimezone_local_and_invalid():
32+
original_tz = utils.DATETIME_TIMEZONE
33+
try:
34+
assert utils.setDatetimeTimezone(True) is not None
35+
assert utils.toDatetime("0").tzinfo is not None
36+
37+
assert utils.setDatetimeTimezone("local") is not None
38+
assert utils.toDatetime("0").tzinfo is not None
39+
40+
assert utils.setDatetimeTimezone("Not/A_Real_Timezone") is None
41+
assert utils.toDatetime("0").tzinfo is None
42+
finally: # Restore for other tests
43+
utils.DATETIME_TIMEZONE = original_tz
44+
45+
46+
def test_utils_package_datetime_timezone_stays_synced():
47+
original_tz = utils.DATETIME_TIMEZONE
48+
try:
49+
tzinfo = utils.setDatetimeTimezone("UTC")
50+
assert tzinfo is not None
51+
assert plexapi.DATETIME_TIMEZONE is tzinfo
52+
53+
assert plexapi.DATETIME_TIMEZONE is utils.DATETIME_TIMEZONE
54+
utils.setDatetimeTimezone(False)
55+
assert plexapi.DATETIME_TIMEZONE is None
56+
assert plexapi.DATETIME_TIMEZONE is utils.DATETIME_TIMEZONE
57+
finally: # Restore for other tests
58+
utils.DATETIME_TIMEZONE = original_tz
59+
60+
1561
def test_utils_threaded():
1662
def _squared(num, results, i, job_is_done_event=None):
1763
time.sleep(0.5)

tests/test_video.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
2-
from datetime import datetime
2+
from datetime import datetime, timedelta
33
from time import sleep
44
from urllib.parse import quote_plus
55

66
import pytest
7+
import plexapi.utils as plexutils
78
from plexapi.exceptions import BadRequest, NotFound
9+
from plexapi.utils import setDatetimeTimezone
810
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p
911

1012
from . import conftest as utils
@@ -21,6 +23,37 @@ def test_video_Movie_attributeerror(movie):
2123
movie.asshat
2224

2325

26+
def test_video_Movie_datetime_timezone(movie):
27+
original_tz = plexutils.DATETIME_TIMEZONE
28+
try:
29+
# no timezone configured, should be naive
30+
setDatetimeTimezone(False)
31+
movie.reload()
32+
dt_naive = movie.updatedAt
33+
assert dt_naive.tzinfo is None
34+
35+
# local timezone configured, should be aware
36+
setDatetimeTimezone(True)
37+
movie.reload()
38+
dt_local = movie.updatedAt
39+
assert dt_local.tzinfo is not None
40+
41+
# explicit IANA zones. Check that the offset is correct too
42+
setDatetimeTimezone("UTC")
43+
movie.reload()
44+
dt = movie.updatedAt
45+
assert dt.tzinfo is not None
46+
assert dt.tzinfo.utcoffset(dt) == timedelta(0)
47+
48+
setDatetimeTimezone("Asia/Dubai")
49+
movie.reload()
50+
dt = movie.updatedAt
51+
assert dt.tzinfo is not None
52+
assert dt.tzinfo.utcoffset(dt) == timedelta(hours=4)
53+
finally: # Restore for other tests
54+
plexutils.DATETIME_TIMEZONE = original_tz
55+
56+
2457
def test_video_ne(movies):
2558
assert (
2659
len(

0 commit comments

Comments
 (0)