utils.times: rewrite hours_minutes_seconds()

- Add support for floats via `hours_minutes_seconds_float()`
- Add support for negative timestamps
- Rewrite regexes and be as strict as possible
- Rewrite and parametrize tests with an extensive list of formats
- Update cli.argparser/plugin arguments using `hours_minutes_seconds()`
This commit is contained in:
bastimeyer 2023-08-12 16:50:38 +02:00 committed by Sebastian Meyer
parent 5beda9470a
commit 1997443624
6 changed files with 248 additions and 78 deletions

View File

@ -152,12 +152,11 @@ class NicoLiveHLSStream(HLSStream):
"timeshift-offset",
type=hours_minutes_seconds,
argument_name="niconico-timeshift-offset",
metavar="[HH:]MM:SS",
default=None,
metavar="[[XX:]XX:]XX | [XXh][XXm][XXs]",
help="""
Amount of time to skip from the beginning of a stream.
Default is 00:00:00.
Default is 0.
""",
)
class NicoLive(Plugin):

View File

@ -29,7 +29,7 @@ from streamlink.stream.http import HTTPStream
from streamlink.utils.args import keyvalue
from streamlink.utils.parse import parse_json, parse_qsd
from streamlink.utils.random import CHOICES_ALPHA_NUM, random_token
from streamlink.utils.times import fromtimestamp, hours_minutes_seconds
from streamlink.utils.times import fromtimestamp, hours_minutes_seconds_float
from streamlink.utils.url import update_qsd
@ -859,7 +859,7 @@ class Twitch(Plugin):
time_offset = self.params.get("t", 0)
if time_offset:
try:
time_offset = hours_minutes_seconds(time_offset)
time_offset = hours_minutes_seconds_float(time_offset)
except ValueError:
time_offset = 0

View File

@ -1,5 +1,6 @@
import re
from datetime import datetime, timezone, tzinfo
from typing import Callable, Literal, Union, overload
from isodate import LOCAL, parse_datetime # type: ignore[import]
@ -23,52 +24,107 @@ def fromlocaltimestamp(timestamp: float) -> datetime:
return datetime.fromtimestamp(timestamp, tz=LOCAL)
_hours_minutes_seconds_re = re.compile(r"""
^-?(?:(?P<hours>\d+):)?(?P<minutes>\d+):(?P<seconds>\d+)$
""", re.VERBOSE)
_hours_minutes_seconds_2_re = re.compile(r"""^-?
(?:
(?P<hours>\d+)h
)?
(?:
(?P<minutes>\d+)m
)?
(?:
(?P<seconds>\d+)s
)?$
""", re.VERBOSE | re.IGNORECASE)
_re_hms_float = re.compile(
r"^-?\d+(?:\.\d+)?$",
)
_re_hms_s = re.compile(
r"""
^
-?
(?P<seconds>\d+(?:\.\d+)?)
s
$
""",
re.VERBOSE | re.IGNORECASE,
)
# noinspection RegExpSuspiciousBackref
_re_hms_ms = re.compile(
r"""
^
-?
(?P<minutes>\d+)
(?:(?P<sep>m)|:(?=.))
(?:
(?P<seconds>[0-5]?[0-9](?:\.\d+)?)
(?(sep)s|)
)?
$
""",
re.VERBOSE | re.IGNORECASE,
)
# noinspection RegExpSuspiciousBackref
_re_hms_hms = re.compile(
r"""
^
-?
(?P<hours>\d+)
(?:(?P<sep>h)|:(?=.))
(?:
(?P<minutes>[0-5]?[0-9])
(?(sep)m|:(?=.))
)?
(?:
(?P<seconds>[0-5]?[0-9](?:\.\d+)?)
(?(sep)s|)
)?
$
""",
re.VERBOSE | re.IGNORECASE,
)
def hours_minutes_seconds(value):
"""converts a timestamp to seconds
@overload
def _hours_minutes_seconds(as_float: Literal[False]) -> Callable[[str], int]: ... # pragma: no cover
- hours:minutes:seconds to seconds
- minutes:seconds to seconds
- 11h22m33s to seconds
- 11h to seconds
- 20h15m to seconds
- seconds to seconds
:param value: hh:mm:ss ; 00h00m00s ; seconds
:return: seconds
@overload
def _hours_minutes_seconds(as_float: Literal[True]) -> Callable[[str], float]: ... # pragma: no cover
def _hours_minutes_seconds(as_float: bool = True) -> Callable[[str], Union[float, int]]:
"""
try:
return int(value)
except ValueError:
pass
Convert an optionally negative HMS-timestamp string to seconds, as float or int
match = (_hours_minutes_seconds_re.match(value)
or _hours_minutes_seconds_2_re.match(value))
if not match:
raise ValueError
Accepted formats:
s = 0
s += int(match.group("hours") or "0") * 60 * 60
s += int(match.group("minutes") or "0") * 60
s += int(match.group("seconds") or "0")
- seconds
- minutes":"seconds
- hours":"minutes":"seconds
- seconds"s"
- minutes"m"
- hours"h"
- minutes"m"seconds"s"
- hours"h"seconds"s"
- hours"h"minutes"m"
- hours"h"minutes"m"seconds"s"
"""
return s
def inner(value: str) -> Union[int, float]:
if _re_hms_float.match(value):
return float(value) if as_float else int(float(value))
match = _re_hms_s.match(value) or _re_hms_ms.match(value) or _re_hms_hms.match(value)
if not match:
raise ValueError
data = match.groupdict()
seconds = 0.0
seconds += float(data.get("hours") or 0.0) * 3600.0
seconds += float(data.get("minutes") or 0.0) * 60.0
seconds += float(data.get("seconds") or 0.0)
res = -seconds if value[0] == "-" else seconds
return res if as_float else int(res)
inner.__name__ = "hours_minutes_seconds"
return inner
hours_minutes_seconds = _hours_minutes_seconds(as_float=False)
hours_minutes_seconds_float = _hours_minutes_seconds(as_float=True)
def seconds_to_hhmmss(seconds):
@ -90,5 +146,6 @@ __all__ = [
"fromtimestamp",
"fromlocaltimestamp",
"hours_minutes_seconds",
"hours_minutes_seconds_float",
"seconds_to_hhmmss",
]

View File

@ -9,7 +9,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from streamlink import __version__ as streamlink_version, logger
from streamlink.session import Streamlink
from streamlink.utils.args import boolean, comma_list, comma_list_filter, filesize, keyvalue, num
from streamlink.utils.times import hours_minutes_seconds
from streamlink.utils.times import hours_minutes_seconds_float
from streamlink_cli.constants import STREAM_PASSTHROUGH
from streamlink_cli.output.player import PlayerOutput
from streamlink_cli.utils import find_default_player
@ -1021,21 +1021,19 @@ def build_parser():
)
transport_hls.add_argument(
"--hls-start-offset",
type=hours_minutes_seconds,
metavar="[HH:]MM:SS",
default=None,
type=hours_minutes_seconds_float,
metavar="[[XX:]XX:]XX[.XX] | [XXh][XXm][XX[.XX]s]",
help="""
Amount of time to skip from the beginning of the stream. For live
streams, this is a negative offset from the end of the stream (rewind).
Default is 00:00:00.
Default is 0.
""",
)
transport_hls.add_argument(
"--hls-duration",
type=hours_minutes_seconds,
metavar="[HH:]MM:SS",
default=None,
type=hours_minutes_seconds_float,
metavar="[[XX:]XX:]XX[.XX] | [XXh][XXm][XX[.XX]s]",
help="""
Limit the playback duration, useful for watching segments of a stream.
The actual duration may be slightly longer, as it is rounded to the

View File

@ -1,3 +1,7 @@
import argparse
import pytest
from streamlink.plugins.nicolive import NicoLive
from tests.plugins import PluginCanHandleUrl
@ -13,3 +17,18 @@ class TestPluginCanHandleUrlNicoLive(PluginCanHandleUrl):
"https://live.nicovideo.jp/watch/co2467009?ref=community",
"https://live.nicovideo.jp/watch/co2619719",
]
class TestNicoLiveArguments:
@pytest.fixture()
def parser(self):
parser = argparse.ArgumentParser()
for parg in NicoLive.arguments or []:
parser.add_argument(parg.argument_name("nicolive"), **parg.options)
return parser
@pytest.mark.parametrize("timeshift_offset", ["123", "123.45"])
def test_timeshift_offset(self, parser: argparse.ArgumentParser, timeshift_offset: str):
parsed = parser.parse_args(["--niconico-timeshift-offset", timeshift_offset])
assert parsed.niconico_timeshift_offset == 123

View File

@ -1,3 +1,4 @@
import argparse
from datetime import datetime, timedelta, timezone
import freezegun
@ -10,6 +11,7 @@ from streamlink.utils.times import (
fromlocaltimestamp,
fromtimestamp,
hours_minutes_seconds,
hours_minutes_seconds_float,
localnow,
now,
parse_datetime,
@ -54,35 +56,130 @@ class TestDatetime:
class TestHoursMinutesSeconds:
def test_hours_minutes_seconds(self):
assert hours_minutes_seconds("00:01:30") == 90
assert hours_minutes_seconds("01:20:15") == 4815
assert hours_minutes_seconds("26:00:00") == 93600
assert hours_minutes_seconds("07") == 7
assert hours_minutes_seconds("444") == 444
assert hours_minutes_seconds("8888") == 8888
assert hours_minutes_seconds("01h") == 3600
assert hours_minutes_seconds("01h22m33s") == 4953
assert hours_minutes_seconds("01H22M37S") == 4957
assert hours_minutes_seconds("01h30s") == 3630
assert hours_minutes_seconds("1m33s") == 93
assert hours_minutes_seconds("55s") == 55
assert hours_minutes_seconds("-00:01:40") == 100
assert hours_minutes_seconds("-00h02m30s") == 150
assert hours_minutes_seconds("02:04") == 124
assert hours_minutes_seconds("1:10") == 70
assert hours_minutes_seconds("10:00") == 600
@pytest.mark.parametrize(("sign", "factor"), [
pytest.param("", 1, id="positive"),
pytest.param("-", -1, id="negative"),
])
@pytest.mark.parametrize(("timestamp", "as_float", "expected"), [
# decimals
pytest.param("0", True, 0.0, id="zero"),
pytest.param("123", True, 123.0, id="decimal without fraction"),
pytest.param("123.456789", True, 123.456789, id="decimal with fraction"),
# XX:XX:XX
pytest.param("0:0", True, 0.0, id="0:0"),
pytest.param("0:0:0", True, 0.0, id="0:0:0"),
pytest.param("1:2", True, 62.0, id="X:X"),
pytest.param("1:2.3", True, 62.3, id="X:X.X"),
pytest.param("12:34", True, 754.0, id="XX:XX"),
pytest.param("12:34.56", True, 754.56, id="XX:XX.XX"),
pytest.param("123:45", True, 7425.0, id="XXX:XX"),
pytest.param("123:45.67", True, 7425.67, id="XXX:XX.XX"),
pytest.param("1:2:3", True, 3723.0, id="X:X:X"),
pytest.param("1:2:3.4", True, 3723.4, id="X:X:X.X"),
pytest.param("12:34:56", True, 45296.0, id="XX:XX:XX"),
pytest.param("12:34:56.78", True, 45296.78, id="XX:XX:XX.XX"),
pytest.param("123:4:5", True, 443045.0, id="XXX:X:X"),
pytest.param("123:4:5.6", True, 443045.6, id="XXX:X:X.X"),
# XXhXXmXXs
pytest.param("0s", True, 0.0, id="0s"),
pytest.param("0m0s", True, 0.0, id="0m0s"),
pytest.param("0h0m0s", True, 0.0, id="0h0m0s"),
pytest.param("1s", True, 1.0, id="Xs"),
pytest.param("1.2s", True, 1.2, id="X.Xs"),
pytest.param("12s", True, 12.0, id="XXs"),
pytest.param("12.3s", True, 12.3, id="XX.Xs"),
pytest.param("123s", True, 123.0, id="XXXs"),
pytest.param("123.4s", True, 123.4, id="XXX.Xs"),
pytest.param("1m", True, 60.0, id="Xm"),
pytest.param("12m", True, 720.0, id="XXm"),
pytest.param("123m", True, 7380.0, id="XXXm"),
pytest.param("1h", True, 3600.0, id="Xh"),
pytest.param("12h", True, 43200.0, id="XXh"),
pytest.param("123h", True, 442800.0, id="XXXh"),
pytest.param("1m2s", True, 62.0, id="XmXs"),
pytest.param("1m2.3s", True, 62.3, id="XmX.Xs"),
pytest.param("12m3s", True, 723.0, id="XXmXs"),
pytest.param("12m3.4s", True, 723.4, id="XXmX.Xs"),
pytest.param("12m34s", True, 754.0, id="XXmXXs"),
pytest.param("12m34.5s", True, 754.5, id="XXmXX.Xs"),
pytest.param("123m45s", True, 7425.0, id="XXXmXXs"),
pytest.param("123m45.6s", True, 7425.6, id="XXXmXX.Xs"),
pytest.param("1h2m3s", True, 3723.0, id="XhXmXs"),
pytest.param("12h34m56s", True, 45296.0, id="XXhXXmXXs"),
pytest.param("12h34m56.78s", True, 45296.78, id="XXhXXmXX.XXs"),
pytest.param("123h4m5s", True, 443045.0, id="XXXhXmXs"),
pytest.param("123h4m5.6s", True, 443045.6, id="XXXhXmX.Xs"),
pytest.param("1h2s", True, 3602.0, id="XhXs"),
pytest.param("1h2m", True, 3720.0, id="XhXs"),
pytest.param("12.34S", True, 12.34, id="XX.XXS"),
pytest.param("12M34.56S", True, 754.56, id="XXMXX.XXS"),
pytest.param("12H34M56.78S", True, 45296.78, id="XXHXXMXX.XXS"),
# integers
pytest.param("0", False, 0, id="zero (int)"),
pytest.param("123", False, 123, id="decimal without fraction (int)"),
pytest.param("123.456789", False, 123, id="decimal with fraction (int)"),
pytest.param("12:34:56", False, 45296, id="XX:XX:XX (int)"),
pytest.param("12:34:56.78", False, 45296, id="XX:XX:XX.XX (int)"),
pytest.param("12h34m56s", False, 45296, id="XXhXXmXXs (int)"),
pytest.param("12h34m56.78s", False, 45296, id="XXhXXmXX.XXs (int)"),
# base 10
pytest.param("0123", True, 123.0, id="base10"),
pytest.param("08:08:08", True, 29288.0, id="XX:XX:XX base10"),
pytest.param("08h08m08s", True, 29288.0, id="XXhXXmXXs base10"),
])
def test_hours_minutes_seconds(self, timestamp: str, as_float: bool, sign: str, factor: int, expected: float):
method = hours_minutes_seconds_float if as_float else hours_minutes_seconds
res = method(f"{sign}{timestamp}")
assert type(res) is type(expected)
assert res == factor * expected
@pytest.mark.parametrize("timestamp", [
# missing timestamp
pytest.param("", id="empty"),
pytest.param(" ", id="whitespace"),
# invalid numbers
pytest.param("+123", id="plus sign"),
pytest.param("1e10", id="exponent notation"),
pytest.param("1_000", id="digit notation"),
pytest.param("NaN", id="NaN"),
pytest.param("infinity", id="infinity"),
pytest.param("0xff", id="base16"),
# invalid format
pytest.param("foo", id="invalid input"),
pytest.param(" 1:2:3 ", id="untrimmed input"),
pytest.param(":1:2", id="missing hours value"),
pytest.param("1::2", id="missing minutes value"),
pytest.param("1:2:", id="missing seconds value"),
pytest.param("foo:1:2", id="invalid hours"),
pytest.param("1:foo:2", id="invalid minutes"),
pytest.param("1:2:foo", id="invalid seconds"),
pytest.param("1:60", id="seconds with two digits gte 60"),
pytest.param("1:60:59", id="minutes with two digits gte 60"),
pytest.param("1:234", id="minutes and seconds with three digits"),
pytest.param("1:234:56", id="hours and minutes with three digits"),
pytest.param("1:23:456", id="hours and seconds with three digits"),
pytest.param("1h2", id="missing minutes or seconds suffix"),
pytest.param("1m2", id="missing seconds suffix"),
pytest.param("1.2h", id="hours fraction"),
pytest.param("1.2m", id="minutes fraction"),
pytest.param("1:2s", id="mixed format"),
pytest.param("1h2:3", id="mixed format"),
pytest.param("1:2:3s", id="mixed format"),
pytest.param("1:2m3s", id="mixed format"),
])
def test_hours_minutes_seconds_exception(self, timestamp: str):
with pytest.raises(ValueError): # noqa: PT011
hours_minutes_seconds("FOO")
with pytest.raises(ValueError): # noqa: PT011
hours_minutes_seconds("BAR")
with pytest.raises(ValueError): # noqa: PT011
hours_minutes_seconds("11:ERR:00")
hours_minutes_seconds_float(timestamp)
@pytest.mark.parametrize("method", [hours_minutes_seconds, hours_minutes_seconds_float])
def test_hours_minutes_seconds_argparse_failure(self, capfd: pytest.CaptureFixture, method):
parser = argparse.ArgumentParser()
parser.add_argument("hms", type=method)
with pytest.raises(SystemExit):
parser.parse_args(["invalid"])
stderr = capfd.readouterr().err
assert "error: argument hms: invalid hours_minutes_seconds value: 'invalid'\n" in stderr, \
"has the correct method name, so argparse errors are useful"
def test_seconds_to_hhmmss(self):
assert seconds_to_hhmmss(0) == "00:00:00"