Skip to content

Commit 74201b9

Browse files
authored
Add timezone argument to ASCReader and ASCWriter (#2035)
* add timezone argument to ASCReader and ASCWriter * add news fragment
1 parent e0bef6b commit 74201b9

3 files changed

Lines changed: 107 additions & 66 deletions

File tree

can/io/asc.py

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@
99
import logging
1010
import re
1111
from collections.abc import Generator
12-
from datetime import datetime
12+
from datetime import datetime, timezone, tzinfo
1313
from typing import Any, Final, TextIO
1414

1515
from ..message import Message
1616
from ..typechecking import StringPathLike
1717
from ..util import channel2int, dlc2len, len2dlc
1818
from .generic import TextIOMessageReader, TextIOMessageWriter
1919

20+
_LOCAL_TZ: Final = datetime.now(timezone.utc).astimezone().tzinfo
21+
2022
CAN_MSG_EXT = 0x80000000
2123
CAN_ID_MASK = 0x1FFFFFFF
2224
BASE_HEX = 16
@@ -44,24 +46,31 @@ def __init__(
4446
file: StringPathLike | TextIO,
4547
base: str = "hex",
4648
relative_timestamp: bool = True,
49+
tz: tzinfo | None = _LOCAL_TZ,
4750
**kwargs: Any,
4851
) -> None:
4952
"""
50-
:param file: a path-like object or as file-like object to read from
51-
If this is a file-like object, is has to opened in text
52-
read mode, not binary read mode.
53-
:param base: Select the base(hex or dec) of id and data.
54-
If the header of the asc file contains base information,
55-
this value will be overwritten. Default "hex".
56-
:param relative_timestamp: Select whether the timestamps are
57-
`relative` (starting at 0.0) or `absolute` (starting at
58-
the system time). Default `True = relative`.
53+
:param file:
54+
a path-like object or a file-like object to read from.
55+
If this is a file-like object, it must be opened in text
56+
read mode, not binary read mode.
57+
:param base:
58+
Select the base ('hex' or 'dec') for CAN IDs and data bytes.
59+
If the header of the ASC file contains base information,
60+
this value will be overwritten. Default is "hex".
61+
:param relative_timestamp:
62+
Select whether the timestamps are
63+
`relative` (starting at 0.0) or `absolute` (starting at
64+
the system time). Default is `True` (relative).
65+
:param tz:
66+
Timezone for absolute timestamps. Defaults to local timezone.
5967
"""
6068
super().__init__(file, mode="r")
6169

6270
if not self.file:
6371
raise ValueError("The given file cannot be None")
6472
self.base = base
73+
self._timezone = tz
6574
self._converted_base = self._check_base(base)
6675
self.relative_timestamp = relative_timestamp
6776
self.date: str | None = None
@@ -93,7 +102,7 @@ def _extract_header(self) -> None:
93102
self.start_time = (
94103
0.0
95104
if self.relative_timestamp
96-
else self._datetime_to_timestamp(self.date)
105+
else self._datetime_to_timestamp(self.date, self._timezone)
97106
)
98107
continue
99108

@@ -115,7 +124,7 @@ def _extract_header(self) -> None:
115124
break
116125

117126
@staticmethod
118-
def _datetime_to_timestamp(datetime_string: str) -> float:
127+
def _datetime_to_timestamp(datetime_string: str, tz: tzinfo | None) -> float:
119128
month_map = {
120129
"jan": 1,
121130
"feb": 2,
@@ -155,7 +164,11 @@ def _datetime_to_timestamp(datetime_string: str) -> float:
155164

156165
for format_str in datetime_formats:
157166
try:
158-
return datetime.strptime(datetime_string, format_str).timestamp()
167+
return (
168+
datetime.strptime(datetime_string, format_str)
169+
.replace(tzinfo=tz)
170+
.timestamp()
171+
)
159172
except ValueError:
160173
continue
161174

@@ -279,7 +292,7 @@ def __iter__(self) -> Generator[Message, None, None]:
279292
self.start_time = (
280293
0.0
281294
if self.relative_timestamp
282-
else self._datetime_to_timestamp(datetime_str)
295+
else self._datetime_to_timestamp(datetime_str, self._timezone)
283296
)
284297
continue
285298

@@ -358,14 +371,19 @@ def __init__(
358371
self,
359372
file: StringPathLike | TextIO,
360373
channel: int = 1,
374+
tz: tzinfo | None = _LOCAL_TZ,
361375
**kwargs: Any,
362376
) -> None:
363377
"""
364-
:param file: a path-like object or as file-like object to write to
365-
If this is a file-like object, is has to opened in text
366-
write mode, not binary write mode.
367-
:param channel: a default channel to use when the message does not
368-
have a channel set
378+
:param file:
379+
a path-like object or a file-like object to write to.
380+
If this is a file-like object, it must be opened in text
381+
write mode, not binary write mode.
382+
:param channel:
383+
a default channel to use when the message does not
384+
have a channel set. Default is 1.
385+
:param tz:
386+
Timezone for timestamps in the log file. Defaults to local timezone.
369387
"""
370388
if kwargs.get("append", False):
371389
raise ValueError(
@@ -374,10 +392,11 @@ def __init__(
374392
)
375393
super().__init__(file, mode="w")
376394

395+
self._timezone = tz
377396
self.channel = channel
378397

379398
# write start of file header
380-
start_time = self._format_header_datetime(datetime.now())
399+
start_time = self._format_header_datetime(datetime.now(tz=self._timezone))
381400
self.file.write(f"date {start_time}\n")
382401
self.file.write("base hex timestamps absolute\n")
383402
self.file.write("internal events logged\n")
@@ -417,7 +436,7 @@ def log_event(self, message: str, timestamp: float | None = None) -> None:
417436
if not self.header_written:
418437
self.started = self.last_timestamp = timestamp or 0.0
419438

420-
start_time = datetime.fromtimestamp(self.last_timestamp)
439+
start_time = datetime.fromtimestamp(self.last_timestamp, tz=self._timezone)
421440
formatted_date = self._format_header_datetime(start_time)
422441

423442
self.file.write(f"Begin Triggerblock {formatted_date}\n")

doc/changelog.d/2035.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add new timezone parameter `tz` to `can.io.asc.ASCReader` and `can.io.asc.ASCWriter`.

test/logformats_test.py

Lines changed: 66 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818
import unittest
1919
from abc import ABCMeta, abstractmethod
2020
from contextlib import contextmanager
21-
from datetime import datetime
21+
from datetime import datetime, timedelta, timezone
2222
from itertools import zip_longest
2323
from pathlib import Path
2424
from unittest.mock import patch
2525

2626
from parameterized import parameterized
2727

28-
import can
29-
from can.io import blf
28+
import can.io
29+
from can.io import asc, blf
30+
3031
from .data.example_data import (
3132
TEST_COMMENTS,
3233
TEST_MESSAGES_BASE,
@@ -427,9 +428,11 @@ def _read_log_file(self, filename, **kwargs):
427428

428429
def test_read_absolute_time(self):
429430
time_from_file = "Sat Sep 30 10:06:13.191 PM 2017"
430-
start_time = datetime.strptime(
431-
time_from_file, self.FORMAT_START_OF_FILE_DATE
432-
).timestamp()
431+
start_time = (
432+
datetime.strptime(time_from_file, self.FORMAT_START_OF_FILE_DATE)
433+
.replace(tzinfo=asc._LOCAL_TZ)
434+
.timestamp()
435+
)
433436

434437
expected_messages = [
435438
can.Message(
@@ -629,51 +632,55 @@ def test_read_error_frame_channel(self):
629632
os.unlink(temp_file.name)
630633

631634
def test_write_millisecond_handling(self):
635+
tz = asc._LOCAL_TZ
632636
now = datetime(
633-
year=2017, month=9, day=30, hour=15, minute=6, second=13, microsecond=191456
637+
year=2017,
638+
month=9,
639+
day=30,
640+
hour=15,
641+
minute=6,
642+
second=13,
643+
microsecond=191456,
644+
tzinfo=tz,
634645
)
635646

636-
# We temporarily set the locale to C to ensure test reproducibility
637-
with override_locale(category=locale.LC_TIME, locale_str="C"):
638-
# We mock datetime.now during ASCWriter __init__ for reproducibility
639-
# Unfortunately, now() is a readonly attribute, so we mock datetime
640-
with patch("can.io.asc.datetime") as mock_datetime:
641-
mock_datetime.now.return_value = now
642-
writer = can.ASCWriter(self.test_file_name)
643-
644-
msg = can.Message(
645-
timestamp=now.timestamp(), arbitration_id=0x123, data=b"h"
646-
)
647-
writer.on_message_received(msg)
647+
with patch("can.io.asc.datetime") as mock_datetime:
648+
mock_datetime.now.return_value = now
649+
writer = can.ASCWriter(self.test_file_name, tz=tz)
648650

649-
writer.stop()
651+
msg = can.Message(timestamp=now.timestamp(), arbitration_id=0x123, data=b"h")
652+
writer.on_message_received(msg)
653+
writer.stop()
650654

651655
actual_file = Path(self.test_file_name)
652656
expected_file = self._get_logfile_location("single_frame_us_locale.asc")
653657

654658
self.assertEqual(expected_file.read_text(), actual_file.read_text())
655659

656660
def test_write(self):
657-
now = datetime(
658-
year=2017, month=9, day=30, hour=15, minute=6, second=13, microsecond=191456
659-
)
660-
661-
# We temporarily set the locale to C to ensure test reproducibility
662-
with override_locale(category=locale.LC_TIME, locale_str="C"):
663-
# We mock datetime.now during ASCWriter __init__ for reproducibility
664-
# Unfortunately, now() is a readonly attribute, so we mock datetime
665-
with patch("can.io.asc.datetime") as mock_datetime:
666-
mock_datetime.now.return_value = now
667-
writer = can.ASCWriter(self.test_file_name)
668-
669-
msg = can.Message(
670-
timestamp=now.timestamp(),
671-
arbitration_id=0x123,
672-
data=range(64),
661+
tz = asc._LOCAL_TZ
662+
with patch("can.io.asc.datetime") as mock_datetime:
663+
now = datetime(
664+
year=2017,
665+
month=9,
666+
day=30,
667+
hour=15,
668+
minute=6,
669+
second=13,
670+
microsecond=191456,
671+
tzinfo=tz,
673672
)
673+
mock_datetime.now.return_value = now
674+
writer = can.ASCWriter(self.test_file_name, tz=tz)
674675

675-
with writer:
676-
writer.on_message_received(msg)
676+
msg = can.Message(
677+
timestamp=now.timestamp(),
678+
arbitration_id=0x123,
679+
data=range(64),
680+
)
681+
682+
with writer:
683+
writer.on_message_received(msg)
677684

678685
actual_file = Path(self.test_file_name)
679686
expected_file = self._get_logfile_location("single_frame.asc")
@@ -684,34 +691,48 @@ def test_write(self):
684691
[
685692
(
686693
"May 27 04:09:35.000 pm 2014",
687-
datetime(2014, 5, 27, 16, 9, 35, 0).timestamp(),
694+
datetime(
695+
2014, 5, 27, 16, 9, 35, 0, tzinfo=timezone(timedelta(hours=5))
696+
).timestamp(),
688697
),
689698
(
690699
"Mai 27 04:09:35.000 pm 2014",
691-
datetime(2014, 5, 27, 16, 9, 35, 0).timestamp(),
700+
datetime(
701+
2014, 5, 27, 16, 9, 35, 0, tzinfo=timezone(timedelta(hours=5))
702+
).timestamp(),
692703
),
693704
(
694705
"Apr 28 10:44:52.480 2022",
695-
datetime(2022, 4, 28, 10, 44, 52, 480000).timestamp(),
706+
datetime(
707+
2022, 4, 28, 10, 44, 52, 480000, tzinfo=timezone(timedelta(hours=5))
708+
).timestamp(),
696709
),
697710
(
698711
"Sep 30 15:06:13.191 2017",
699-
datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(),
712+
datetime(
713+
2017, 9, 30, 15, 6, 13, 191000, tzinfo=timezone(timedelta(hours=5))
714+
).timestamp(),
700715
),
701716
(
702717
"Sep 30 15:06:13.191 pm 2017",
703-
datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(),
718+
datetime(
719+
2017, 9, 30, 15, 6, 13, 191000, tzinfo=timezone(timedelta(hours=5))
720+
).timestamp(),
704721
),
705722
(
706723
"Sep 30 15:06:13.191 am 2017",
707-
datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(),
724+
datetime(
725+
2017, 9, 30, 15, 6, 13, 191000, tzinfo=timezone(timedelta(hours=5))
726+
).timestamp(),
708727
),
709728
]
710729
)
711730
def test_datetime_to_timestamp(
712731
self, datetime_string: str, expected_timestamp: float
713732
):
714-
timestamp = can.ASCReader._datetime_to_timestamp(datetime_string)
733+
timestamp = can.ASCReader._datetime_to_timestamp(
734+
datetime_string, tz=timezone(timedelta(hours=5))
735+
)
715736
self.assertAlmostEqual(timestamp, expected_timestamp)
716737

717738

0 commit comments

Comments
 (0)