Skip to content

Commit

Permalink
chore: aware datetimes
Browse files Browse the repository at this point in the history
- Add "DTZ" ruff rule
- Add utility functions to `streamlink.utils.times` which use
  "aware" datetimes with explicit timezone information,
  and use `isodate`'s local timezone implementation
- Replace all "naive" datetimes without timezone information
- Replace all custom ISO8601 parsers with `isodate`'s implementation
- Add tests for new utility functions
  • Loading branch information
bastimeyer authored and gravyboat committed Mar 1, 2023
1 parent 86002ad commit 49988a0
Show file tree
Hide file tree
Showing 16 changed files with 126 additions and 60 deletions.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ select = [
"COM",
# flake8-comprehensions
"C4",
# flake8-datetimez
"DTZ",
# flake8-implicit-str-concat
"ISC",
# flake8-pie
Expand Down
4 changes: 2 additions & 2 deletions src/streamlink/logger.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging
import sys
import warnings
from datetime import datetime
from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING
from pathlib import Path
from sys import version_info
Expand All @@ -12,6 +11,7 @@
from warnings import WarningMessage

from streamlink.exceptions import StreamlinkWarning
from streamlink.utils.times import fromlocaltimestamp


if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -107,7 +107,7 @@ def usesTime(self):
return self._usesTime

def formatTime(self, record, datefmt=None):
tdt = datetime.fromtimestamp(record.created)
tdt = fromlocaltimestamp(record.created)

return tdt.strftime(datefmt or self.default_time_format)

Expand Down
13 changes: 2 additions & 11 deletions src/streamlink/plugins/crunchyroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
$type vod
"""

import datetime
import logging
import re
from uuid import uuid4

from streamlink.plugin import Plugin, PluginError, pluginargument, pluginmatcher
from streamlink.plugin.api import validate
from streamlink.stream.hls import HLSStream
from streamlink.utils.times import parse_datetime


log = logging.getLogger(__name__)
Expand All @@ -29,15 +29,6 @@
}


def parse_timestamp(ts):
"""Takes ISO 8601 format(string) and converts into a utc datetime(naive)"""
return (
datetime.datetime.strptime(ts[:-7], "%Y-%m-%dT%H:%M:%S")
+ datetime.timedelta(hours=int(ts[-5:-3]), minutes=int(ts[-2:]))
* int(f"{ts[-6:-5]}1")
)


_api_schema = validate.Schema({
"error": bool,
validate.optional("code"): str,
Expand Down Expand Up @@ -70,7 +61,7 @@ def parse_timestamp(ts):
"auth": validate.any(str, None),
"expires": validate.all(
str,
validate.transform(parse_timestamp),
validate.transform(parse_datetime),
),
"user": {
"username": validate.any(str, None),
Expand Down
4 changes: 2 additions & 2 deletions src/streamlink/plugins/htv.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

import logging
import re
from datetime import date

from streamlink.plugin import Plugin, pluginmatcher
from streamlink.plugin.api import validate
from streamlink.stream.hls import HLSStream
from streamlink.utils.times import localnow


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -59,7 +59,7 @@ def _get_streams(self):
"channelid": channel_id,
"template": "AjaxSchedules.xslt",
"channelcode": channel_code,
"date": date.today().strftime("%d-%m-%Y"),
"date": localnow().strftime("%d-%m-%Y"),
},
schema=validate.Schema(
validate.parse_json(),
Expand Down
4 changes: 2 additions & 2 deletions src/streamlink/plugins/oneplusone.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
import logging
import re
from base64 import b64decode
from datetime import datetime
from time import time
from urllib.parse import urljoin, urlparse

from streamlink.exceptions import NoStreamsError, PluginError
from streamlink.plugin import Plugin, pluginmatcher
from streamlink.plugin.api import validate
from streamlink.stream.hls import HLSStream
from streamlink.utils.times import fromlocaltimestamp


log = logging.getLogger(__name__)
Expand All @@ -34,7 +34,7 @@ def __init__(self, session_, url, self_url=None, **args):
self.api = OnePlusOneAPI(session_, self_url)

def _next_watch_timeout(self):
_next = datetime.fromtimestamp(self.watch_timeout).isoformat(" ")
_next = fromlocaltimestamp(self.watch_timeout).isoformat(" ")
log.debug(f"next watch_timeout at {_next}")

def open(self):
Expand Down
6 changes: 2 additions & 4 deletions src/streamlink/plugins/pluzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@

import logging
import re
from datetime import datetime
from urllib.parse import urlparse

from isodate import LOCAL as LOCALTIMEZONE # type: ignore[import]

from streamlink.plugin import Plugin, PluginError, pluginmatcher
from streamlink.plugin.api import useragents, validate
from streamlink.stream.dash import DASHStream
from streamlink.stream.hls import HLSStream
from streamlink.utils.times import localnow
from streamlink.utils.url import update_qsd


Expand Down Expand Up @@ -103,7 +101,7 @@ def _get_streams(self):
"browser": "chrome",
"browser_version": CHROME_VERSION,
"os": "ios",
"gmt": datetime.now(tz=LOCALTIMEZONE).strftime("%z"),
"gmt": localnow().strftime("%z"),
})
video_format, token_url, url, self.title = self.session.http.get(api_url, schema=validate.Schema(
validate.parse_json(),
Expand Down
13 changes: 4 additions & 9 deletions src/streamlink/plugins/rtbf.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from streamlink.stream.dash import DASHStream
from streamlink.stream.hls import HLSStream
from streamlink.stream.http import HTTPStream
from streamlink.utils.times import parse_datetime


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -109,12 +110,6 @@ def tokenize_stream(self, url):
data = self.session.http.json(res)
return data["streams"]["url"]

@staticmethod
def iso8601_to_epoch(date):
# Convert an ISO 8601-formatted string date to datetime
return datetime.datetime.strptime(date[:-6], "%Y-%m-%dT%H:%M:%S") + \
datetime.timedelta(hours=int(date[-6:-3]), minutes=int(date[-2:]))

def _get_radio_streams(self):
res = self.session.http.get(self.url)
match = self._radio_id_re.search(res.text)
Expand Down Expand Up @@ -152,7 +147,7 @@ def _get_video_streams(self):
log.error("Stream is DRM-protected")
return

now = datetime.datetime.now()
now = datetime.datetime.now(datetime.timezone.utc)
try:
if isinstance(stream_data["sources"], dict):
urls = []
Expand Down Expand Up @@ -185,10 +180,10 @@ def _get_video_streams(self):
if "403 Client Error" in str(err):
# Check whether video is expired
if "startDate" in stream_data:
if now < self.iso8601_to_epoch(stream_data["startDate"]):
if now < parse_datetime(stream_data["startDate"]):
log.error("Stream is not yet available")
elif "endDate" in stream_data:
if now > self.iso8601_to_epoch(stream_data["endDate"]):
if now > parse_datetime(stream_data["endDate"]):
log.error("Stream has expired")

def _get_streams(self):
Expand Down
6 changes: 3 additions & 3 deletions src/streamlink/plugins/ustreamtv.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
import re
from collections import deque
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from random import randint
from threading import Event, RLock
from typing import Any, Callable, Deque, Dict, List, NamedTuple, Optional, Union
Expand Down Expand Up @@ -295,7 +295,7 @@ def _handle_module_info_stream(self, data: Dict):
if self.stream_initial_id is None:
self.stream_initial_id = current_id

current_time = datetime.now()
current_time = datetime.now(timezone.utc)

# lock the stream segments deques for the worker threads
with self.stream_segments_lock:
Expand Down Expand Up @@ -362,7 +362,7 @@ def fetch(self, segment: Segment, is_init: bool): # type: ignore[override]
if self.closed: # pragma: no cover
return

now = datetime.now()
now = datetime.now(timezone.utc)
if segment.available_at > now:
time_to_wait = (segment.available_at - now).total_seconds()
log.debug(f"Waiting for {self.stream.kind} segment: {segment.num} ({time_to_wait:.01f}s)")
Expand Down
29 changes: 29 additions & 0 deletions src/streamlink/utils/times.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
import re
from datetime import datetime, timezone, tzinfo

from isodate import LOCAL, parse_datetime # type: ignore[import]


UTC = timezone.utc


def now(tz: tzinfo = UTC) -> datetime:
return datetime.now(tz=tz)


def localnow() -> datetime:
return datetime.now(tz=LOCAL)


def fromtimestamp(timestamp: float, tz: tzinfo = UTC) -> datetime:
return datetime.fromtimestamp(timestamp, tz=tz)


def fromlocaltimestamp(timestamp: float) -> datetime:
return datetime.fromtimestamp(timestamp, tz=LOCAL)


_hours_minutes_seconds_re = re.compile(r"""
Expand Down Expand Up @@ -60,6 +82,13 @@ def seconds_to_hhmmss(seconds):


__all__ = [
"UTC",
"LOCAL",
"parse_datetime",
"now",
"localnow",
"fromtimestamp",
"fromlocaltimestamp",
"hours_minutes_seconds",
"seconds_to_hhmmss",
]
5 changes: 3 additions & 2 deletions src/streamlink_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from streamlink.plugin import Plugin, PluginOptions
from streamlink.stream.stream import Stream, StreamIO
from streamlink.utils.named_pipe import NamedPipe
from streamlink.utils.times import LOCAL as LOCALTIMEZONE
from streamlink_cli.argparser import ArgumentParser, build_parser, setup_session_options
from streamlink_cli.compat import DeprecatedPath, importlib_metadata, stdout
from streamlink_cli.console import ConsoleOutput, ConsoleUserInputRequester
Expand Down Expand Up @@ -51,7 +52,7 @@ def get_formatter(plugin: Plugin):
"category": lambda: plugin.get_category(),
"game": lambda: plugin.get_category(),
"title": lambda: plugin.get_title(),
"time": lambda: datetime.now(),
"time": lambda: datetime.now(tz=LOCALTIMEZONE),
},
{
"time": lambda dt, fmt: dt.strftime(fmt),
Expand Down Expand Up @@ -834,7 +835,7 @@ def setup_logger_and_console(stream=sys.stdout, filename=None, level="info", jso
global console

if filename == "-":
filename = LOG_DIR / f"{datetime.now()}.log"
filename = LOG_DIR / f"{datetime.now(tz=LOCALTIMEZONE)}.log"
elif filename:
filename = Path(filename).expanduser().resolve()

Expand Down
5 changes: 2 additions & 3 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import datetime
import logging
import os
import re
Expand Down Expand Up @@ -803,7 +802,7 @@ def test_logfile_path_expanduser(self, mock_open, mock_stdout):
@patch("sys.stdout")
@patch("builtins.open")
@patch("pathlib.Path.mkdir", Mock())
@freezegun.freeze_time(datetime.datetime(2000, 1, 2, 3, 4, 5))
@freezegun.freeze_time("2000-01-02T03:04:05Z")
def test_logfile_path_auto(self, mock_open, mock_stdout):
with patch("streamlink_cli.constants.LOG_DIR", PosixPath("/foo")):
self.subject(["streamlink", "--logfile", "-"])
Expand Down Expand Up @@ -842,7 +841,7 @@ def test_logfile_path_expanduser(self, mock_open, mock_stdout):
@patch("sys.stdout")
@patch("builtins.open")
@patch("pathlib.Path.mkdir", Mock())
@freezegun.freeze_time(datetime.datetime(2000, 1, 2, 3, 4, 5))
@freezegun.freeze_time("2000-01-02T03:04:05Z")
def test_logfile_path_auto(self, mock_open, mock_stdout):
with patch("streamlink_cli.constants.LOG_DIR", WindowsPath("C:\\foo")):
self.subject(["streamlink", "--logfile", "-"])
Expand Down
18 changes: 9 additions & 9 deletions tests/plugins/test_filmon.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import datetime
from datetime import datetime, timezone
from unittest.mock import patch

import freezegun
Expand Down Expand Up @@ -42,38 +42,38 @@ class TestPluginCanHandleUrlFilmon(PluginCanHandleUrl):

@pytest.fixture()
def filmonhls():
with freezegun.freeze_time(datetime.datetime(2000, 1, 1, 0, 0, 0, 0)), \
with freezegun.freeze_time("2000-01-01T00:00:00Z"), \
patch("streamlink.plugins.filmon.FilmOnHLS._get_stream_data", return_value=[]):
session = Streamlink()
api = FilmOnAPI(session)
yield FilmOnHLS(session, "http://fake/one.m3u8", api=api, channel="test")


def test_filmonhls_to_url(filmonhls):
filmonhls.watch_timeout = datetime.datetime(2000, 1, 1, 0, 0, 0, 0).timestamp()
filmonhls.watch_timeout = datetime(2000, 1, 1, 0, 0, 0, 0, timezone.utc).timestamp()
assert filmonhls.to_url() == "http://fake/one.m3u8"


def test_filmonhls_to_url_updated(filmonhls):
filmonhls.watch_timeout = datetime.datetime(1999, 12, 31, 23, 59, 59, 9999).timestamp()
filmonhls.watch_timeout = datetime(1999, 12, 31, 23, 59, 59, 9999, timezone.utc).timestamp()

filmonhls._get_stream_data.return_value = [
("high", "http://fake/two.m3u8", datetime.datetime(2000, 1, 1, 0, 0, 0, 0).timestamp()),
("high", "http://fake/two.m3u8", datetime(2000, 1, 1, 0, 0, 0, 0, timezone.utc).timestamp()),
]
assert filmonhls.to_url() == "http://fake/two.m3u8"

filmonhls.watch_timeout = datetime.datetime(1999, 12, 31, 23, 59, 59, 9999).timestamp()
filmonhls.watch_timeout = datetime(1999, 12, 31, 23, 59, 59, 9999, timezone.utc).timestamp()
filmonhls._get_stream_data.return_value = [
("high", "http://another-fake/three.m3u8", datetime.datetime(2000, 1, 1, 0, 0, 0, 0).timestamp()),
("high", "http://another-fake/three.m3u8", datetime(2000, 1, 1, 0, 0, 0, 0, timezone.utc).timestamp()),
]
assert filmonhls.to_url() == "http://fake/three.m3u8"


def test_filmonhls_to_url_missing_quality(filmonhls):
filmonhls.watch_timeout = datetime.datetime(1999, 12, 31, 23, 59, 59, 9999).timestamp()
filmonhls.watch_timeout = datetime(1999, 12, 31, 23, 59, 59, 9999, timezone.utc).timestamp()

filmonhls._get_stream_data.return_value = [
("low", "http://fake/two.m3u8", datetime.datetime(2000, 1, 1, 0, 0, 0, 0).timestamp()),
("low", "http://fake/two.m3u8", datetime(2000, 1, 1, 0, 0, 0, 0, timezone.utc).timestamp()),
]
with pytest.raises(TypeError) as cm:
filmonhls.to_url()
Expand Down
4 changes: 2 additions & 2 deletions tests/plugins/test_twitch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import unittest
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, call, patch

import pytest
Expand Down Expand Up @@ -52,7 +52,7 @@ class TestPluginCanHandleUrlTwitch(PluginCanHandleUrl):
]


DATETIME_BASE = datetime(2000, 1, 1, 0, 0, 0, 0)
DATETIME_BASE = datetime(2000, 1, 1, 0, 0, 0, 0, timezone.utc)
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"


Expand Down

0 comments on commit 49988a0

Please sign in to comment.