From 5c1dffbd3713c85c566723aca0c27fb4c657973b Mon Sep 17 00:00:00 2001 From: Andrew MacPherson Date: Mon, 22 Apr 2024 17:16:01 -0400 Subject: [PATCH 1/2] Small change to modify the timestamp --- ring_doorbell/listen/eventlistener.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ring_doorbell/listen/eventlistener.py b/ring_doorbell/listen/eventlistener.py index 03bb3b12..2425c373 100644 --- a/ring_doorbell/listen/eventlistener.py +++ b/ring_doorbell/listen/eventlistener.py @@ -5,7 +5,7 @@ import json import logging import time -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict from firebase_messaging import FcmPushClient @@ -203,9 +203,16 @@ def _get_ding_event(self, gcm_data: dict[str, Any]) -> RingEvent: state = subtype created_at = ding["created_at"] - create_seconds = ( - datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%S.%f%z") - ).timestamp() + # Check if the datetime string contains a period which precedes 'Z', indicating microseconds + if '.' in created_at and created_at.endswith('Z'): + # String contains microseconds and ends with 'Z' + format_str = "%Y-%m-%dT%H:%M:%S.%fZ" + else: + # String does not contain microseconds and ends with 'Z' + format_str = "%Y-%m-%dT%H:%M:%SZ" + + # Parse the datetime string using the appropriate format + create_seconds = datetime.strptime(created_at, format_str).replace(tzinfo=timezone.utc).timestamp() return RingEvent( id=ding["id"], kind=kind, From 9bcf1bce15c90933d7724299b316834b71bc2109 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 8 Aug 2024 11:28:15 +0100 Subject: [PATCH 2/2] Move datetime parsing to utility function and add tests --- .vscode/settings.json | 7 ++++ poetry.lock | 59 ++++++++++++++++++++++++++- pyproject.toml | 1 + ring_doorbell/generic.py | 16 +------- ring_doorbell/listen/eventlistener.py | 13 +----- ring_doorbell/util.py | 32 +++++++++++++++ tests/test_ring.py | 47 +++++++++++++++++++++ 7 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 ring_doorbell/util.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9b388533 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index f9760ce4..a21cb347 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -454,6 +454,20 @@ requests = ">=2.19.0" [package.extras] docs = ["sphinx (==7.1.2)", "sphinx-autodoc-typehints (>=1.24.0,<2.0.0)", "sphinx-rtd-theme (>=1.3.0,<2.0.0)"] +[[package]] +name = "freezegun" +version = "1.5.1" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +files = [ + {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, + {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "http-ece" version = "1.2.0" @@ -828,6 +842,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-freezegun" +version = "0.4.2" +description = "Wrap tests with fixtures in freeze_time" +optional = false +python-versions = "*" +files = [ + {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, + {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, +] + +[package.dependencies] +freezegun = ">0.3" +pytest = ">=3.0.0" + [[package]] name = "pytest-mock" version = "3.14.0" @@ -859,6 +888,20 @@ files = [ [package.dependencies] pytest = ">=3.6.3" +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pytz" version = "2024.1" @@ -895,6 +938,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1027,6 +1071,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1365,4 +1420,4 @@ listen = ["firebase-messaging"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "928822147fee70007783115c908e4bad006a823b90613435927309bde911f09a" +content-hash = "824eb03f58e672451c6bcfed7e8d2c6843a54a4578059f5dc6797732a3bdcd0d" diff --git a/pyproject.toml b/pyproject.toml index a9a5fc0a..8d638b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ ruff = "*" types-requests-oauthlib = "1.3" types-pytz = "^2024.1.0.20240203" types-click = "^7.1.8" +pytest-freezegun = "^0.4.2" [tool.poetry.extras] docs = ["sphinx", "sphinx-rtd-theme", "sphinx-github-changelog"] diff --git a/ring_doorbell/generic.py b/ring_doorbell/generic.py index 58fbac28..233a54cd 100644 --- a/ring_doorbell/generic.py +++ b/ring_doorbell/generic.py @@ -5,12 +5,12 @@ from __future__ import annotations import logging -from datetime import datetime from typing import TYPE_CHECKING, Any import pytz from ring_doorbell.const import URL_DOORBELL_HISTORY, RingCapability +from ring_doorbell.util import parse_datetime _LOGGER = logging.getLogger(__name__) @@ -204,23 +204,11 @@ def history( # noqa: C901, PLR0912, PLR0913 if convert_timezone: # convert for specific timezone - utc = pytz.utc if timezone: mytz = pytz.timezone(timezone) for entry in response: - dt_at = datetime.strptime( - entry["created_at"], "%Y-%m-%dT%H:%M:%S.%f%z" - ) - utc_dt = datetime( - dt_at.year, - dt_at.month, - dt_at.day, - dt_at.hour, - dt_at.minute, - dt_at.second, - tzinfo=utc, - ) + utc_dt = parse_datetime(entry["created_at"]) if timezone: tz_dt = utc_dt.astimezone(mytz) entry["created_at"] = tz_dt diff --git a/ring_doorbell/listen/eventlistener.py b/ring_doorbell/listen/eventlistener.py index 2425c373..ab7b687a 100644 --- a/ring_doorbell/listen/eventlistener.py +++ b/ring_doorbell/listen/eventlistener.py @@ -5,7 +5,6 @@ import json import logging import time -from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict from firebase_messaging import FcmPushClient @@ -25,6 +24,7 @@ ) from ring_doorbell.event import RingEvent from ring_doorbell.exceptions import RingError +from ring_doorbell.util import parse_datetime from .listenerconfig import RingEventListenerConfig @@ -203,16 +203,7 @@ def _get_ding_event(self, gcm_data: dict[str, Any]) -> RingEvent: state = subtype created_at = ding["created_at"] - # Check if the datetime string contains a period which precedes 'Z', indicating microseconds - if '.' in created_at and created_at.endswith('Z'): - # String contains microseconds and ends with 'Z' - format_str = "%Y-%m-%dT%H:%M:%S.%fZ" - else: - # String does not contain microseconds and ends with 'Z' - format_str = "%Y-%m-%dT%H:%M:%SZ" - - # Parse the datetime string using the appropriate format - create_seconds = datetime.strptime(created_at, format_str).replace(tzinfo=timezone.utc).timestamp() + create_seconds = parse_datetime(created_at).timestamp() return RingEvent( id=ding["id"], kind=kind, diff --git a/ring_doorbell/util.py b/ring_doorbell/util.py new file mode 100644 index 00000000..c05c8a1a --- /dev/null +++ b/ring_doorbell/util.py @@ -0,0 +1,32 @@ +"""Module for common utility functions.""" + +import datetime +import logging + +_logger = logging.getLogger(__name__) + + +def parse_datetime(datetime_str: str) -> datetime.datetime: + """Parse a datetime string into a datetime object. + + Ring api has inconsistent datetime string patterns. + """ + # Check if the datetime string contains a period which precedes 'Z', + # indicating microseconds + if "." in datetime_str and datetime_str.endswith("Z"): + # String contains microseconds and ends with 'Z' + format_str = "%Y-%m-%dT%H:%M:%S.%fZ" + else: + # String does not contain microseconds, should end with 'Z' + # Could be updated to handle other formats + format_str = "%Y-%m-%dT%H:%M:%SZ" + try: + res = datetime.datetime.strptime(datetime_str, format_str).replace( + tzinfo=datetime.timezone.utc + ) + except ValueError: + _logger.exception( + "Unable to parse datetime string %s, defaulting to now time", datetime_str + ) + res = datetime.datetime.now(datetime.timezone.utc) + return res diff --git a/tests/test_ring.py b/tests/test_ring.py index e26da226..267fe183 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -1,7 +1,11 @@ """The tests for the Ring platform.""" +from datetime import datetime, timezone + import pytest +from freezegun import freeze_time from ring_doorbell import RingError +from ring_doorbell.util import parse_datetime def test_basic_attributes(ring): @@ -165,3 +169,46 @@ def test_motion_detection_enable(ring, requests_mock): assert len(active_dings) == 3 assert len(ring.active_alerts()) == 3 + + +@pytest.mark.parametrize( + ("datetime_string", "expected", "error_in_log"), + [ + pytest.param( + "2012-01-15T06:01:01", + datetime(2012, 1, 14, 5 - 4, 5, 5, 123 * 1_000, tzinfo=timezone.utc), + True, + id="No timezone", + ), + pytest.param( + "2012-01-15T06:01:01.12Z", + datetime(2012, 1, 15, 6, 1, 1, 120 * 1_000, tzinfo=timezone.utc), + False, + id="Millis", + ), + pytest.param( + "2012-01-15T06:01:01.123456Z", + datetime(2012, 1, 15, 6, 1, 1, 123456, tzinfo=timezone.utc), + False, + id="Micros", + ), + pytest.param( + "2012-01-15T06:01:01Z", + datetime(2012, 1, 15, 6, 1, 1, 0, tzinfo=timezone.utc), + False, + id="No millis", + ), + ], +) +@freeze_time("2012-01-14T05:05:05.123", tz_offset=-4) +def test_datetime_parse( + caplog: pytest.LogCaptureFixture, datetime_string, expected, error_in_log +): + """Test the datetime parsing.""" + dt = parse_datetime(datetime_string) + is_error_in_log = ( + f"Unable to parse datetime string {datetime_string}, defaulting to now time" + in caplog.text + ) + assert dt == expected + assert is_error_in_log is error_in_log