diff --git a/README.md b/README.md index 7df10db4..9eeadb0e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Generate [RaBe CRIDs](https://github.com/radiorabe/crid-spec) based on several d poetry add rabe-cridlib # or on old setup style projects -pip -m install rabe-cridlib +pip install rabe-cridlib ``` ## Usage @@ -23,7 +23,7 @@ pip -m install rabe-cridlib >>> # parse an existing crid >>> crid = cridlib.parse("crid://rabe.ch/v1/klangbecken#t=clock=19930301T131200.00Z") >>> print(f"version: {crid.version}, show: {crid.show}, start: {crid.start}") -version: v1, show: klangbecken, start: 1993-03-01 13:12:00 +version: v1, show: klangbecken, start: 1993-03-01 13:12:00+00:00 >>> # get crid for current show >>> crid = cridlib.get() diff --git a/cridlib/__init__.py b/cridlib/__init__.py index d237e295..e8a50a4c 100644 --- a/cridlib/__init__.py +++ b/cridlib/__init__.py @@ -1,7 +1,7 @@ """Generate RaBe Content Reference Idenitifier Spcification (CRID) Identifiers. * [`cridlib.get(timestamp=None, fragment='')`](./get/#cridlib.get.get) -* [`cridlib.parse(value)`](./parse/#gridlib.parse.parse) +* [`cridlib.parse(value)`](./parse/#cridlib.parse.parse) """ from .get import get diff --git a/cridlib/get.py b/cridlib/get.py index 4af8be4d..98dea002 100644 --- a/cridlib/get.py +++ b/cridlib/get.py @@ -26,8 +26,8 @@ def get(timestamp: datetime | None = None, fragment: str = "") -> CRID: >>> with patch("cridlib.strategy.past.get_session") as mock_gs: ... mock_gs.return_value.get.return_value = mock_resp ... crid = get(datetime(2020, 3, 1, 0, 0, tzinfo=timezone('Europe/Zurich'))) - >>> print(f"version: {crid.version}, start: {crid.start}") - version: v1, start: ... + >>> print(f"version: {crid.version}, start: {crid.start}") # doctest:+ELLIPSIS + version: v1, start: ...-...-... ...+00:00 ``` @@ -49,7 +49,7 @@ def get(timestamp: datetime | None = None, fragment: str = "") -> CRID: _show = now.get_show() elif _ts < _now: _show = past.get_show(past=_ts) - elif _ts > _now: # pragma: no cover + elif _ts > _now: _show = future.get_show(future=_ts) if _show: diff --git a/cridlib/lib.py b/cridlib/lib.py index 36ec55b5..661a5a91 100644 --- a/cridlib/lib.py +++ b/cridlib/lib.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from pathlib import PurePath from typing import Self from urllib.parse import parse_qs @@ -102,12 +102,10 @@ def __init__(self: Self, uri: str | None = None) -> None: # fragments are optional, but if provided we want them to contain t=code if self.fragment: try: - # TODO(hairmare): investigate noqa for bug - # https://github.com/radiorabe/python-rabe-cridlib/issues/244 - self._start = datetime.strptime( # noqa: DTZ007 + self._start = datetime.strptime( parse_qs(parse_qs(self.fragment)["t"][0])["clock"][0], "%Y%m%dT%H%M%S.%fZ", - ) + ).replace(tzinfo=timezone.utc) except KeyError as ex: raise CRIDMissingMediaFragmentError(self.fragment, uri) from ex except ValueError as ex: # pragma: no cover @@ -200,7 +198,25 @@ def start(self: Self) -> datetime | None: Returns ------- - Start time form CRIDs media fragment. + Start time from CRIDs media fragment as a UTC-aware datetime, or + ``None`` when no fragment was present. The timezone is always + ``datetime.timezone.utc`` so the value can be compared directly + to any other timezone-aware datetime without a ``TypeError``. + + Examples + -------- + Start time is UTC-aware and safe to compare with aware datetimes: + ```python + >>> from datetime import datetime, timezone + >>> crid = CRID("crid://rabe.ch/v1/test#t=clock=19930301T131200.00Z") + >>> crid.start + datetime.datetime(1993, 3, 1, 13, 12, tzinfo=datetime.timezone.utc) + >>> crid.start.tzinfo == timezone.utc + True + >>> crid.start < datetime.now(timezone.utc) + True + + ``` """ return self._start diff --git a/cridlib/strategy/__init__.py b/cridlib/strategy/__init__.py index 69c0bf71..644d8bc2 100644 --- a/cridlib/strategy/__init__.py +++ b/cridlib/strategy/__init__.py @@ -1,6 +1,6 @@ """Different strategies for getting CRIDs from different sources. * [`cridlib.strategy.past`](./past/) -* [`cridlib.strategy.present`](./present/) +* [`cridlib.strategy.now`](./now/) * [`cridlib.strategy.future`](./future/) """ diff --git a/cridlib/strategy/future.py b/cridlib/strategy/future.py index aefe4376..d3e8352f 100644 --- a/cridlib/strategy/future.py +++ b/cridlib/strategy/future.py @@ -12,7 +12,7 @@ ) -def get_show(future: datetime) -> str: # pragma: no cover +def get_show(future: datetime) -> str: """Return the slug for a show from LibreTime if it is in the next 7 days. Only returns a show for the next seven days because everything futher than diff --git a/tests/conftest.py b/tests/conftest.py index 6e7fc0f6..500e1376 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Configureation for pytest.""" +"""Configuration for pytest.""" # pylint: disable=line-too-long diff --git a/tests/strategy/test_future.py b/tests/strategy/test_future.py new file mode 100644 index 00000000..a725ed99 --- /dev/null +++ b/tests/strategy/test_future.py @@ -0,0 +1,25 @@ +"""Tests for the future show strategy.""" + +from datetime import datetime, timezone + +from freezegun import freeze_time + +import cridlib.strategy.future + + +def test_get_show(libretime_mock): # noqa: ARG001 + """Test that the correct future show is returned for a given timestamp.""" + with freeze_time("1993-03-01 00:00:00 UTC"): + show = cridlib.strategy.future.get_show( + future=datetime(1993, 3, 1, 11, 15, 0, tzinfo=timezone.utc), + ) + assert show == "info" + + +def test_get_show_no_match(libretime_mock): # noqa: ARG001 + """Test that an empty string is returned when no show matches the timestamp.""" + with freeze_time("1993-03-01 00:00:00 UTC"): + show = cridlib.strategy.future.get_show( + future=datetime(1993, 3, 8, 13, 12, 0, tzinfo=timezone.utc), + ) + assert show == "" diff --git a/tests/test_get.py b/tests/test_get.py index 9649c2c4..eeb07b5e 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -16,13 +16,16 @@ def test_get_now(klangbecken_mock): # noqa: ARG001 def test_get_past(archiv_mock): # noqa: ARG001 """Test meth:`get` for past shows.""" + ts = datetime(1993, 3, 1, 13, 12, 0, tzinfo=timezone.utc) with freeze_time("1993-03-02 00:00:00 UTC"): - crid = cridlib.get( - timestamp=datetime(1993, 3, 1, 13, 12, 00, tzinfo=timezone.utc), - ) + crid = cridlib.get(timestamp=ts) assert crid.version == "v1" assert crid.show == "test" assert str(crid) == "crid://rabe.ch/v1/test#t=clock=19930301T131200.00Z" + # start is UTC-aware; it can be compared directly to the original timestamp + assert crid.start is not None + assert crid.start.tzinfo is timezone.utc + assert crid.start == ts # show with additional local args in fragments with freeze_time("1993-03-02 00:00:00 UTC"): diff --git a/tests/test_lib.py b/tests/test_lib.py index 37b61886..f544339a 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -1,6 +1,6 @@ """Test high level cridlib API.""" -from datetime import datetime +from datetime import datetime, timezone import pytest from freezegun import freeze_time @@ -16,7 +16,7 @@ { "version": "v1", "show": "test", - "start": datetime(1993, 3, 1, 13, 12), + "start": datetime(1993, 3, 1, 13, 12, tzinfo=timezone.utc), }, ), ( @@ -32,7 +32,7 @@ { "version": "v1", "show": None, - "start": datetime(1993, 3, 1, 13, 12), + "start": datetime(1993, 3, 1, 13, 12, tzinfo=timezone.utc), }, ), ], @@ -46,6 +46,21 @@ def test_crid_roundtrip(crid_str, expected): assert crid.start == expected["start"] +def test_start_is_utc_aware(): + """Test that start is a UTC-aware datetime. + + The ``Z`` in the clock fragment encodes UTC. Before the tzinfo fix + ``crid.start`` was naive, so comparing it to any timezone-aware + datetime would raise a ``TypeError``. Now it carries + ``timezone.utc`` and comparisons work as expected. + """ + crid = cridlib.lib.CRID("crid://rabe.ch/v1/test#t=clock=19930301T131200.00Z") + assert crid.start is not None + assert crid.start.tzinfo is timezone.utc + # Direct comparison with another UTC-aware datetime must not raise TypeError + assert crid.start < datetime.now(timezone.utc) + + def test_crid_scheme_mismatch(): with pytest.raises(cridlib.lib.CRIDSchemeMismatchError): cridlib.lib.CRID("https://rabe.ch/v1/test")