Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate from pendulum to arrow for dates #22

Merged
merged 2 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Changelog
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
Version 0.6.10 unreleased
Version 0.7.0 unreleased

* Migrate to Poetry v2 and project-managed Poetry plugins.
* Move configuration into pyproject.toml for pytest, mypy & coverage.
* Upgrade to gha-shared-workflows@v8 for Poetry v2 support.
* Migrate from pendulum to arrow for dates (interface change).
* Update all dependencies and outdated constraints.

Version 0.6.9 02 Jan 2025
Expand Down
146 changes: 33 additions & 113 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ license = "Apache-2.0"
readme = "PyPI.md"
dynamic = [ "classifiers", "version" ]
dependencies = [
"pendulum (>=3.0.0,<4.0.0)",
"attrs (>=24.2.0,<25.0.0)",
"cattrs (>=24.1.2,<25.0.0)",
"PyYAML (>=6.0.1,<7.0.0)",
"pycryptodomex (>=3.19.0,<4.0.0)",
"requests (>=2.31.0,<3.0.0)",
"tenacity (>=9.0.0,<10.0.0)",
"arrow (>=1.3.0,<2.0.0)",
]

[project.urls]
Expand Down
37 changes: 19 additions & 18 deletions src/smartapp/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from typing import Any, Dict, Type, TypeVar

import yaml
from arrow import Arrow
from arrow import get as arrow_get
from arrow import now as arrow_now
from attrs import fields, has
from cattrs import GenConverter
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override
from pendulum import from_format, now
from pendulum.datetime import DateTime

from .interface import (
CONFIG_SETTING_BY_TYPE,
Expand All @@ -29,36 +30,36 @@

DATETIME_ZONE = "UTC"

DATETIME_SEC_EPOCH = "1970-01-01T00:00:00Z" # date of the UNIX epoch, which sometimes seem to mean "no date"
DATETIME_SEC_EPOCH = "1970-01-01T00:00:00Z" # date of the UNIX epoch, which sometimes seems to mean "no date"
DATETIME_SEC_LEN = len("YYYY-MM-DDTHH:MM:SSZ") # like "2017-09-13T04:18:12Z"
DATETIME_SEC_FORMAT = "YYYY-MM-DD[T]HH:mm:ss[Z]"

DATETIME_MS_EPOCH = "1970-01-01T00:00:00.000Z" # date of the UNIX epoch, which sometimes seem to mean "no date"
DATETIME_MS_EPOCH = "1970-01-01T00:00:00.000Z" # date of the UNIX epoch, which sometimes seems to mean "no date"
DATETIME_MS_LEN = len("YYYY-MM-DDTHH:MM:SS.SSSZ") # like "2017-09-13T04:18:12.992Z"
DATETIME_MS_FORMAT = "YYYY-MM-DD[T]HH:mm:ss.SSS[Z]"

T = TypeVar("T") # pylint: disable=invalid-name:


def serialize_datetime(datetime: DateTime) -> str:
"""Serialize a DateTime to a string."""
def serialize_datetime(datetime: Arrow) -> str:
"""Serialize an Arrow datetime to a string."""
# Note that we always use the full millisecond timestamp here and always convert to UTC
return datetime.in_timezone(DATETIME_ZONE).format(DATETIME_MS_FORMAT) # type: ignore[no-untyped-call,no-any-return,unused-ignore]
return datetime.to(DATETIME_ZONE).format(DATETIME_MS_FORMAT)


def deserialize_datetime(datetime: str) -> DateTime:
"""Deserialize a string into a DateTime."""
def deserialize_datetime(datetime: str) -> Arrow:
"""Deserialize a string into an Arrow datetime."""
# Dates from SmartThings are not as reliable as I had hoped. The samples show a
# format including milliseconds. Actual data (at least sometimes) comes without
# milliseconds. Further, some requests come with a UNIX epoch date (1970-01-01) which
# I guess is probably what happens when no date was set by the device. I'm choosing
# to interpret that as "now".
if datetime in (DATETIME_MS_EPOCH, DATETIME_SEC_EPOCH):
return now()
return arrow_now()
elif len(datetime) == DATETIME_MS_LEN:
return from_format(datetime, DATETIME_MS_FORMAT, tz=DATETIME_ZONE)
return arrow_get(datetime, DATETIME_MS_FORMAT, tzinfo=DATETIME_ZONE)
elif len(datetime) == DATETIME_SEC_LEN:
return from_format(datetime, DATETIME_SEC_FORMAT, tz=DATETIME_ZONE)
return arrow_get(datetime, DATETIME_SEC_FORMAT, tzinfo=DATETIME_ZONE)
else:
raise ValueError("Unknown datetime format: %s" % datetime)

Expand Down Expand Up @@ -118,18 +119,18 @@ class SmartAppConverter(StandardConverter):

def __init__(self) -> None:
super().__init__()
self.register_unstructure_hook(DateTime, self._unstructure_datetime)
self.register_structure_hook(DateTime, self._structure_datetime)
self.register_unstructure_hook(Arrow, self._unstructure_datetime)
self.register_structure_hook(Arrow, self._structure_datetime)
self.register_structure_hook(ConfigValue, self._structure_config_value)
self.register_structure_hook(ConfigSetting, self._structure_config_setting)
self.register_structure_hook(LifecycleRequest, self._structure_request)

def _unstructure_datetime(self, datetime: DateTime) -> str:
"""Serialize a DateTime to a string."""
def _unstructure_datetime(self, datetime: Arrow) -> str:
"""Serialize an Arrow datetime to a string."""
return serialize_datetime(datetime)

def _structure_datetime(self, datetime: str, _: Type[DateTime]) -> DateTime:
"""Deserialize a string into a DateTime."""
def _structure_datetime(self, datetime: str, _: Type[Arrow]) -> Arrow:
"""Deserialize a string into an Arrow datetime."""
return deserialize_datetime(datetime)

def _structure_config_value(self, data: Dict[str, Any], _: Type[ConfigValue]) -> ConfigValue:
Expand Down
4 changes: 2 additions & 2 deletions src/smartapp/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
from enum import Enum
from typing import Any, Callable, Dict, List, Mapping, Optional, Union

from arrow import Arrow
from attrs import field, frozen
from pendulum.datetime import DateTime

AUTHORIZATION_HEADER = "authorization"
CORRELATION_ID_HEADER = "x-st-correlation"
Expand Down Expand Up @@ -362,7 +362,7 @@ def as_float(self, key: str) -> float:
class Event:
"""Holds the triggered event, one of several different attributes depending on event type."""

event_time: Optional[DateTime] = None
event_time: Optional[Arrow] = None
event_type: EventType
device_event: Optional[Dict[str, Any]] = None
device_lifecycle_event: Optional[Dict[str, Any]] = None
Expand Down
15 changes: 8 additions & 7 deletions src/smartapp/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@
from typing import List, Mapping, Optional

import requests
from arrow import Arrow
from arrow import get as arrow_get
from arrow import now as arrow_now
from attrs import field, frozen
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import pkcs1_15
from pendulum import from_format, now
from pendulum.datetime import DateTime
from requests import ConnectionError as RequestsConnectionError
from requests import HTTPError, RequestException
from tenacity import retry
Expand All @@ -72,7 +73,7 @@ def retrieve_public_key(key_server_url: str, key_id: str) -> str:
return response.text


DATE_FORMAT = "DD MMM YYYY HH:mm:ss z" # like "05 Jan 2014 21:31:40 GMT"; we strip off the leading day of week
DATE_FORMAT = "DD MMM YYYY HH:mm:ss ZZZ" # like "05 Jan 2014 21:31:40 GMT"; we strip off the leading day of week


# noinspection PyUnresolvedReferences
Expand All @@ -89,7 +90,7 @@ class SignatureVerifier:
method: str = field(init=False)
path: str = field(init=False)
request_target: str = field(init=False)
date: DateTime = field(init=False)
date: Arrow = field(init=False)
authorization: str = field(init=False)
signing_attributes: Mapping[str, str] = field(init=False)
signing_headers: str = field(init=False)
Expand Down Expand Up @@ -129,8 +130,8 @@ def _default_authorization(self) -> str:
return self.header("authorization")

@date.default
def _default_date(self) -> DateTime:
return from_format(self.header("date")[5:], DATE_FORMAT) # remove the day ("Thu, ") from front
def _default_date(self) -> Arrow:
return arrow_get(self.header("date")[5:], DATE_FORMAT) # remove the day ("Thu, ") from front

@signing_attributes.default
def _default_signing_attributes(self) -> Mapping[str, str]:
Expand Down Expand Up @@ -206,7 +207,7 @@ def retrieve_public_key(self) -> str:
def verify_date(self) -> None:
"""Verify the date, ensuring that it is current per skew configuration."""
if self.config.clock_skew_sec is not None:
skew = abs(now() - self.date)
skew = abs(arrow_now() - self.date)
if skew.seconds > self.config.clock_skew_sec:
raise SignatureError("Request date is not current, skew of %d seconds" % skew.seconds, self.correlation_id)

Expand Down
25 changes: 13 additions & 12 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from json import JSONDecodeError
from unittest.mock import patch

import pendulum
import pytest
from arrow import arrow

from smartapp.converter import CONVERTER, deserialize_datetime, serialize_datetime
from smartapp.interface import *
Expand All @@ -18,7 +18,7 @@
RESPONSE_DIR = os.path.join(FIXTURE_DIR, "response")
SETTINGS_DIR = os.path.join(FIXTURE_DIR, "settings")

NOW = pendulum.datetime(2022, 6, 1, 2, 3, 4, microsecond=5, tz="UTC")
NOW = arrow.Arrow(2022, 6, 1, 2, 3, 4, microsecond=5, tzinfo="UTC")


@pytest.fixture
Expand Down Expand Up @@ -58,24 +58,25 @@ class TestDatetime:
@pytest.mark.parametrize(
"datetime,expected",
[
(pendulum.datetime(2017, 9, 13, 4, 18, 12, microsecond=469000, tz="UTC"), "2017-09-13T04:18:12.469Z"),
(pendulum.datetime(2017, 9, 13, 4, 18, 12, microsecond=0, tz="UTC"), "2017-09-13T04:18:12.000Z"),
(pendulum.datetime(1970, 1, 1, 0, 0, 0, microsecond=0, tz="UTC"), "1970-01-01T00:00:00.000Z"),
(arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=469000, tzinfo="UTC"), "2017-09-13T04:18:12.469Z"),
(arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=469123, tzinfo="UTC"), "2017-09-13T04:18:12.469Z"),
(arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=0, tzinfo="UTC"), "2017-09-13T04:18:12.000Z"),
(arrow.Arrow(1970, 1, 1, 0, 0, 0, microsecond=0, tzinfo="UTC"), "1970-01-01T00:00:00.000Z"),
],
)
def test_serialize_datetime(self, datetime, expected):
assert serialize_datetime(datetime) == expected

@patch("smartapp.converter.now")
@patch("smartapp.converter.arrow_now")
@pytest.mark.parametrize(
"datetime,expected",
[
("2017-09-13T04:18:12.469Z", pendulum.datetime(2017, 9, 13, 4, 18, 12, microsecond=469000, tz="UTC")),
("2017-09-13T04:18:12.000Z", pendulum.datetime(2017, 9, 13, 4, 18, 12, microsecond=0, tz="UTC")),
("2017-09-13T04:18:12Z", pendulum.datetime(2017, 9, 13, 4, 18, 12, microsecond=0, tz="UTC")),
("2022-06-16T15:17:24.883Z", pendulum.datetime(2022, 6, 16, 10, 17, 24, microsecond=883000, tz="America/Chicago")),
("2022-06-16T15:17:24.000Z", pendulum.datetime(2022, 6, 16, 10, 17, 24, microsecond=0, tz="America/Chicago")),
("2022-06-16T15:16:24Z", pendulum.datetime(2022, 6, 16, 10, 16, 24, microsecond=0, tz="America/Chicago")),
("2017-09-13T04:18:12.469Z", arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=469000, tzinfo="UTC")),
("2017-09-13T04:18:12.000Z", arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=0, tzinfo="UTC")),
("2017-09-13T04:18:12Z", arrow.Arrow(2017, 9, 13, 4, 18, 12, microsecond=0, tzinfo="UTC")),
("2022-06-16T15:17:24.883Z", arrow.Arrow(2022, 6, 16, 10, 17, 24, microsecond=883000, tzinfo="America/Chicago")),
("2022-06-16T15:17:24.000Z", arrow.Arrow(2022, 6, 16, 10, 17, 24, microsecond=0, tzinfo="America/Chicago")),
("2022-06-16T15:16:24Z", arrow.Arrow(2022, 6, 16, 10, 16, 24, microsecond=0, tzinfo="America/Chicago")),
("1970-01-01T00:00:00.000Z", NOW),
("1970-01-01T00:00:00Z", NOW),
],
Expand Down
Loading
Loading