Skip to content

Commit 69c81b8

Browse files
committed
add timezone argument to ASCReader and ASCWriter
1 parent e0bef6b commit 69c81b8

2 files changed

Lines changed: 97 additions & 64 deletions

File tree

can/io/asc.py

Lines changed: 31 additions & 19 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,27 @@ 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".
53+
:param file: a path-like object or a file-like object to read from.
54+
If this is a file-like object, it must be opened in text
55+
read mode, not binary read mode.
56+
:param base: Select the base ('hex' or 'dec') for CAN IDs and data bytes.
57+
If the header of the ASC file contains base information,
58+
this value will be overwritten. Default is "hex".
5659
: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`.
60+
`relative` (starting at 0.0) or `absolute` (starting at
61+
the system time). Default is `True` (relative).
62+
:param tz: Timezone for absolute timestamps. Defaults to local timezone.
5963
"""
6064
super().__init__(file, mode="r")
6165

6266
if not self.file:
6367
raise ValueError("The given file cannot be None")
6468
self.base = base
69+
self._timezone = tz
6570
self._converted_base = self._check_base(base)
6671
self.relative_timestamp = relative_timestamp
6772
self.date: str | None = None
@@ -93,7 +98,7 @@ def _extract_header(self) -> None:
9398
self.start_time = (
9499
0.0
95100
if self.relative_timestamp
96-
else self._datetime_to_timestamp(self.date)
101+
else self._datetime_to_timestamp(self.date, self._timezone)
97102
)
98103
continue
99104

@@ -115,7 +120,7 @@ def _extract_header(self) -> None:
115120
break
116121

117122
@staticmethod
118-
def _datetime_to_timestamp(datetime_string: str) -> float:
123+
def _datetime_to_timestamp(datetime_string: str, tz: tzinfo | None) -> float:
119124
month_map = {
120125
"jan": 1,
121126
"feb": 2,
@@ -155,7 +160,11 @@ def _datetime_to_timestamp(datetime_string: str) -> float:
155160

156161
for format_str in datetime_formats:
157162
try:
158-
return datetime.strptime(datetime_string, format_str).timestamp()
163+
return (
164+
datetime.strptime(datetime_string, format_str)
165+
.replace(tzinfo=tz)
166+
.timestamp()
167+
)
159168
except ValueError:
160169
continue
161170

@@ -279,7 +288,7 @@ def __iter__(self) -> Generator[Message, None, None]:
279288
self.start_time = (
280289
0.0
281290
if self.relative_timestamp
282-
else self._datetime_to_timestamp(datetime_str)
291+
else self._datetime_to_timestamp(datetime_str, self._timezone)
283292
)
284293
continue
285294

@@ -358,14 +367,16 @@ def __init__(
358367
self,
359368
file: StringPathLike | TextIO,
360369
channel: int = 1,
370+
tz: tzinfo | None = _LOCAL_TZ,
361371
**kwargs: Any,
362372
) -> None:
363373
"""
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.
374+
:param file: a path-like object or a file-like object to write to.
375+
If this is a file-like object, it must be opened in text
376+
write mode, not binary write mode.
367377
:param channel: a default channel to use when the message does not
368-
have a channel set
378+
have a channel set. Default is 1.
379+
:param tz: Timezone for timestamps in the log file. Defaults to local timezone.
369380
"""
370381
if kwargs.get("append", False):
371382
raise ValueError(
@@ -374,10 +385,11 @@ def __init__(
374385
)
375386
super().__init__(file, mode="w")
376387

388+
self._timezone = tz
377389
self.channel = channel
378390

379391
# write start of file header
380-
start_time = self._format_header_datetime(datetime.now())
392+
start_time = self._format_header_datetime(datetime.now(tz=self._timezone))
381393
self.file.write(f"date {start_time}\n")
382394
self.file.write("base hex timestamps absolute\n")
383395
self.file.write("internal events logged\n")
@@ -417,7 +429,7 @@ def log_event(self, message: str, timestamp: float | None = None) -> None:
417429
if not self.header_written:
418430
self.started = self.last_timestamp = timestamp or 0.0
419431

420-
start_time = datetime.fromtimestamp(self.last_timestamp)
432+
start_time = datetime.fromtimestamp(self.last_timestamp, tz=self._timezone)
421433
formatted_date = self._format_header_datetime(start_time)
422434

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

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)