From f76bd88d2774c572a1b000588fc74272c6b50c1e Mon Sep 17 00:00:00 2001 From: townsmcp <14061617+townsmcp@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:59:53 +0100 Subject: [PATCH] feat: message time helpers that return None instead of now() MessageEntity.message_time_or_none and create_time_utc return None when a message has no (readable) time, instead of substituting now(). message_time behaves as before. Originally written by James Townsend (townsmcp) in https://github.com/townsmcp/saic-python-client-ng, taken from commit 5bc66b8 (the message helper part only). --- .../api/message/schema.py | 56 +++++++++++++++---- tests/test_message_times.py | 51 +++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/test_message_times.py diff --git a/src/saic_ismart_client_ng/api/message/schema.py b/src/saic_ismart_client_ng/api/message/schema.py index 8cb8f5f..6b84d1d 100644 --- a/src/saic_ismart_client_ng/api/message/schema.py +++ b/src/saic_ismart_client_ng/api/message/schema.py @@ -32,18 +32,54 @@ class MessageEntity: @property def message_time(self) -> datetime.datetime: - if self.messageTime: - for date_format in MESSAGE_DATE_TIME_FORMATS: - try: - return datetime.datetime.strptime(self.messageTime, date_format) - except ValueError: - pass - LOGGER.error( - "Could not parse messageTime '%s'. This is a bug. Please file a ticket", - self.messageTime, - ) + """The message time, or the current local time if there isn't one. + + Falling back to now() means a message with no time looks brand new. + Prefer ``message_time_or_none`` or ``create_time_utc`` when that + matters (e.g. deciding whether a message is old). + """ + parsed = self.message_time_or_none + if parsed is not None: + return parsed return datetime.datetime.now() + @property + def message_time_or_none(self) -> datetime.datetime | None: + """``messageTime`` parsed, or None if it is missing or unreadable. + + The result is naive: SAIC sends it in the server's local time with no + timezone (the EU servers use CET/CEST), so it can't safely be compared + with UTC. Use ``create_time_utc`` for that. + """ + if not self.messageTime: + return None + for date_format in MESSAGE_DATE_TIME_FORMATS: + try: + return datetime.datetime.strptime(self.messageTime, date_format) + except ValueError: + pass + LOGGER.error( + "Could not parse messageTime '%s'. This is a bug. Please file a ticket", + self.messageTime, + ) + return None + + @property + def create_time_utc(self) -> datetime.datetime | None: + """``createTime`` (Unix milliseconds) as a UTC datetime, or None. + + None when the message has no createTime (common on EU accounts) or + it can't be read. Unlike ``messageTime`` this has no timezone doubt. + """ + if self.createTime is None: + return None + try: + return datetime.datetime.fromtimestamp( + self.createTime / 1000.0, tz=datetime.UTC + ) + except (TypeError, OSError, OverflowError, ValueError): + return None + @property def read_status(self) -> str: if self.readStatus is None: diff --git a/tests/test_message_times.py b/tests/test_message_times.py new file mode 100644 index 0000000..6ba26f6 --- /dev/null +++ b/tests/test_message_times.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import datetime + +from saic_ismart_client_ng.api.message.schema import MessageEntity + +UTC = datetime.UTC + + +def test_create_time_utc_from_milliseconds() -> None: + created = datetime.datetime(2026, 9, 25, 17, 57, 38, tzinfo=UTC) + msg = MessageEntity(createTime=int(created.timestamp() * 1000)) + assert msg.create_time_utc == created + assert msg.create_time_utc is not None + assert msg.create_time_utc.tzinfo is not None + + +def test_create_time_utc_missing_is_none() -> None: + assert MessageEntity().create_time_utc is None + + +def test_create_time_utc_unreadable_is_none() -> None: + assert MessageEntity(createTime="soon").create_time_utc is None # type: ignore[arg-type] + assert MessageEntity(createTime=10**20).create_time_utc is None + + +def test_message_time_or_none_parses_every_known_format() -> None: + expected = datetime.datetime(2026, 9, 25, 18, 57, 38) + for text in ("2026-09-25 18:57:38", "25-09-2026 18:57:38", "25/09/2026 18:57:38"): + assert MessageEntity(messageTime=text).message_time_or_none == expected + + +def test_message_time_or_none_is_naive() -> None: + parsed = MessageEntity(messageTime="2026-09-25 18:57:38").message_time_or_none + assert parsed is not None + assert parsed.tzinfo is None + + +def test_message_time_or_none_missing_or_unreadable_is_none() -> None: + assert MessageEntity().message_time_or_none is None + assert MessageEntity(messageTime="").message_time_or_none is None + assert MessageEntity(messageTime="yesterday").message_time_or_none is None + + +def test_message_time_still_falls_back_to_now() -> None: + # Unchanged behaviour for existing callers. + before = datetime.datetime.now() + fallback = MessageEntity().message_time + assert before <= fallback <= datetime.datetime.now() + parsed = MessageEntity(messageTime="2026-09-25 18:57:38").message_time + assert parsed == datetime.datetime(2026, 9, 25, 18, 57, 38)