Skip to content

Commit 7e29504

Browse files
authored
Fix ASCWriter millisecond handling (#1734)
* Add _read_ prefix to ASC reader tests * Update the ASCWriter to use a 24h date format * Add test to check handling of ASCWriter date and time * Reimplement datetime formatting in ASCWriter * Use Path read methods for ASCWriter test * Appease ruff linter
1 parent a2ddb51 commit 7e29504

File tree

3 files changed

+77
-28
lines changed

3 files changed

+77
-28
lines changed

can/io/asc.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
"""
88
import logging
99
import re
10-
import time
1110
from datetime import datetime
1211
from typing import Any, Dict, Final, Generator, List, Optional, TextIO, Union
1312

@@ -340,8 +339,7 @@ class ASCWriter(TextIOMessageWriter):
340339
"{bit_timing_conf_ext_data:>8}",
341340
]
342341
)
343-
FORMAT_START_OF_FILE_DATE = "%a %b %d %I:%M:%S.%f %p %Y"
344-
FORMAT_DATE = "%a %b %d %I:%M:%S.{} %p %Y"
342+
FORMAT_DATE = "%a %b %d %H:%M:%S.{} %Y"
345343
FORMAT_EVENT = "{timestamp: 9.6f} {message}\n"
346344

347345
def __init__(
@@ -367,12 +365,8 @@ def __init__(
367365
self.channel = channel
368366

369367
# write start of file header
370-
now = datetime.now().strftime(self.FORMAT_START_OF_FILE_DATE)
371-
# Note: CANoe requires that the microsecond field only have 3 digits
372-
idx = now.index(".") # Find the index in the string of the decimal
373-
# Keep decimal and first three ms digits (4), remove remaining digits
374-
now = now.replace(now[idx + 4 : now[idx:].index(" ") + idx], "")
375-
self.file.write(f"date {now}\n")
368+
start_time = self._format_header_datetime(datetime.now())
369+
self.file.write(f"date {start_time}\n")
376370
self.file.write("base hex timestamps absolute\n")
377371
self.file.write("internal events logged\n")
378372

@@ -381,6 +375,15 @@ def __init__(
381375
self.last_timestamp = 0.0
382376
self.started = 0.0
383377

378+
def _format_header_datetime(self, dt: datetime) -> str:
379+
# Note: CANoe requires that the microsecond field only have 3 digits
380+
# Since Python strftime only supports microsecond formatters, we must
381+
# manually include the millisecond portion before passing the format
382+
# to strftime
383+
msec = dt.microsecond // 1000 % 1000
384+
format_w_msec = self.FORMAT_DATE.format(msec)
385+
return dt.strftime(format_w_msec)
386+
384387
def stop(self) -> None:
385388
# This is guaranteed to not be None since we raise ValueError in __init__
386389
if not self.file.closed:
@@ -400,12 +403,11 @@ def log_event(self, message: str, timestamp: Optional[float] = None) -> None:
400403

401404
# this is the case for the very first message:
402405
if not self.header_written:
403-
self.last_timestamp = timestamp or 0.0
404-
self.started = self.last_timestamp
405-
mlsec = repr(self.last_timestamp).split(".")[1][:3]
406-
formatted_date = time.strftime(
407-
self.FORMAT_DATE.format(mlsec), time.localtime(self.last_timestamp)
408-
)
406+
self.started = self.last_timestamp = timestamp or 0.0
407+
408+
start_time = datetime.fromtimestamp(self.last_timestamp)
409+
formatted_date = self._format_header_datetime(start_time)
410+
409411
self.file.write(f"Begin Triggerblock {formatted_date}\n")
410412
self.header_written = True
411413
self.log_event("Start of measurement") # caution: this is a recursive call!

test/data/single_frame_us_locale.asc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
date Sat Sep 30 15:06:13.191 2017
2+
base hex timestamps absolute
3+
internal events logged
4+
Begin Triggerblock Sat Sep 30 15:06:13.191 2017
5+
0.000000 Start of measurement
6+
0.000000 1 123x Rx d 1 68
7+
End TriggerBlock

test/logformats_test.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,22 @@
1111
1212
TODO: correctly set preserves_channel and adds_default_channel
1313
"""
14+
import locale
1415
import logging
1516
import os
1617
import tempfile
1718
import unittest
1819
from abc import ABCMeta, abstractmethod
20+
from contextlib import contextmanager
1921
from datetime import datetime
2022
from itertools import zip_longest
23+
from pathlib import Path
24+
from unittest.mock import patch
2125

2226
from parameterized import parameterized
2327

2428
import can
2529
from can.io import blf
26-
2730
from .data.example_data import (
2831
TEST_COMMENTS,
2932
TEST_MESSAGES_BASE,
@@ -42,6 +45,14 @@
4245
asammdf = None
4346

4447

48+
@contextmanager
49+
def override_locale(category: int, locale_str: str) -> None:
50+
prev_locale = locale.getlocale(category)
51+
locale.setlocale(category, locale_str)
52+
yield
53+
locale.setlocale(category, prev_locale)
54+
55+
4556
class ReaderWriterExtensionTest(unittest.TestCase):
4657
def _get_suffix_case_variants(self, suffix):
4758
return [
@@ -403,12 +414,16 @@ def _setup_instance(self):
403414
adds_default_channel=0,
404415
)
405416

417+
def _get_logfile_location(self, filename: str) -> Path:
418+
my_dir = Path(__file__).parent
419+
return my_dir / "data" / filename
420+
406421
def _read_log_file(self, filename, **kwargs):
407-
logfile = os.path.join(os.path.dirname(__file__), "data", filename)
422+
logfile = self._get_logfile_location(filename)
408423
with can.ASCReader(logfile, **kwargs) as reader:
409424
return list(reader)
410425

411-
def test_absolute_time(self):
426+
def test_read_absolute_time(self):
412427
time_from_file = "Sat Sep 30 10:06:13.191 PM 2017"
413428
start_time = datetime.strptime(
414429
time_from_file, self.FORMAT_START_OF_FILE_DATE
@@ -436,7 +451,7 @@ def test_absolute_time(self):
436451
actual = self._read_log_file("test_CanMessage.asc", relative_timestamp=False)
437452
self.assertMessagesEqual(actual, expected_messages)
438453

439-
def test_can_message(self):
454+
def test_read_can_message(self):
440455
expected_messages = [
441456
can.Message(
442457
timestamp=2.5010,
@@ -459,7 +474,7 @@ def test_can_message(self):
459474
actual = self._read_log_file("test_CanMessage.asc")
460475
self.assertMessagesEqual(actual, expected_messages)
461476

462-
def test_can_remote_message(self):
477+
def test_read_can_remote_message(self):
463478
expected_messages = [
464479
can.Message(
465480
timestamp=2.510001,
@@ -488,7 +503,7 @@ def test_can_remote_message(self):
488503
actual = self._read_log_file("test_CanRemoteMessage.asc")
489504
self.assertMessagesEqual(actual, expected_messages)
490505

491-
def test_can_fd_remote_message(self):
506+
def test_read_can_fd_remote_message(self):
492507
expected_messages = [
493508
can.Message(
494509
timestamp=30.300981,
@@ -504,7 +519,7 @@ def test_can_fd_remote_message(self):
504519
actual = self._read_log_file("test_CanFdRemoteMessage.asc")
505520
self.assertMessagesEqual(actual, expected_messages)
506521

507-
def test_can_fd_message(self):
522+
def test_read_can_fd_message(self):
508523
expected_messages = [
509524
can.Message(
510525
timestamp=30.005021,
@@ -541,7 +556,7 @@ def test_can_fd_message(self):
541556
actual = self._read_log_file("test_CanFdMessage.asc")
542557
self.assertMessagesEqual(actual, expected_messages)
543558

544-
def test_can_fd_message_64(self):
559+
def test_read_can_fd_message_64(self):
545560
expected_messages = [
546561
can.Message(
547562
timestamp=30.506898,
@@ -566,7 +581,7 @@ def test_can_fd_message_64(self):
566581
actual = self._read_log_file("test_CanFdMessage64.asc")
567582
self.assertMessagesEqual(actual, expected_messages)
568583

569-
def test_can_and_canfd_error_frames(self):
584+
def test_read_can_and_canfd_error_frames(self):
570585
expected_messages = [
571586
can.Message(timestamp=2.501000, channel=0, is_error_frame=True),
572587
can.Message(timestamp=3.501000, channel=0, is_error_frame=True),
@@ -582,16 +597,16 @@ def test_can_and_canfd_error_frames(self):
582597
actual = self._read_log_file("test_CanErrorFrames.asc")
583598
self.assertMessagesEqual(actual, expected_messages)
584599

585-
def test_ignore_comments(self):
600+
def test_read_ignore_comments(self):
586601
_msg_list = self._read_log_file("logfile.asc")
587602

588-
def test_no_triggerblock(self):
603+
def test_read_no_triggerblock(self):
589604
_msg_list = self._read_log_file("issue_1256.asc")
590605

591-
def test_can_dlc_greater_than_8(self):
606+
def test_read_can_dlc_greater_than_8(self):
592607
_msg_list = self._read_log_file("issue_1299.asc")
593608

594-
def test_error_frame_channel(self):
609+
def test_read_error_frame_channel(self):
595610
# gh-issue 1578
596611
err_frame = can.Message(is_error_frame=True, channel=4)
597612

@@ -611,6 +626,31 @@ def test_error_frame_channel(self):
611626
finally:
612627
os.unlink(temp_file.name)
613628

629+
def test_write_millisecond_handling(self):
630+
now = datetime(
631+
year=2017, month=9, day=30, hour=15, minute=6, second=13, microsecond=191456
632+
)
633+
634+
# We temporarily set the locale to C to ensure test reproducibility
635+
with override_locale(category=locale.LC_TIME, locale_str="C"):
636+
# We mock datetime.now during ASCWriter __init__ for reproducibility
637+
# Unfortunately, now() is a readonly attribute, so we mock datetime
638+
with patch("can.io.asc.datetime") as mock_datetime:
639+
mock_datetime.now.return_value = now
640+
writer = can.ASCWriter(self.test_file_name)
641+
642+
msg = can.Message(
643+
timestamp=now.timestamp(), arbitration_id=0x123, data=b"h"
644+
)
645+
writer.on_message_received(msg)
646+
647+
writer.stop()
648+
649+
actual_file = Path(self.test_file_name)
650+
expected_file = self._get_logfile_location("single_frame_us_locale.asc")
651+
652+
self.assertEqual(expected_file.read_text(), actual_file.read_text())
653+
614654

615655
class TestBlfFileFormat(ReaderWriterTest):
616656
"""Tests can.BLFWriter and can.BLFReader.

0 commit comments

Comments
 (0)