diff --git a/can/__init__.py b/can/__init__.py index 773e94022..9c155b4c1 100644 --- a/can/__init__.py +++ b/can/__init__.py @@ -35,7 +35,14 @@ from .interface import Bus, detect_available_configs from .bit_timing import BitTiming -from .io import Logger, SizedRotatingLogger, Printer, LogReader, MessageSync +from .io import ( + Logger, + SizedRotatingLogger, + Printer, + LogReader, + MessageSync, + RotatingLogger, +) from .io import ASCWriter, ASCReader from .io import BLFReader, BLFWriter from .io import CanutilsLogReader, CanutilsLogWriter diff --git a/can/io/__init__.py b/can/io/__init__.py index 6dc9ac1af..37d94afe3 100644 --- a/can/io/__init__.py +++ b/can/io/__init__.py @@ -4,7 +4,7 @@ """ # Generic -from .logger import Logger, BaseRotatingLogger, SizedRotatingLogger +from .logger import Logger, BaseRotatingLogger, SizedRotatingLogger, RotatingLogger from .player import LogReader, MessageSync # Format specific diff --git a/can/io/logger.py b/can/io/logger.py index b6ea23380..b1416b92b 100644 --- a/can/io/logger.py +++ b/can/io/logger.py @@ -6,6 +6,8 @@ import pathlib from abc import ABC, abstractmethod from datetime import datetime +import time +import warnings import gzip from typing import Any, Optional, Callable, Type, Tuple, cast, Dict, Set @@ -272,11 +274,14 @@ def do_rollover(self) -> None: """Perform rollover.""" -class SizedRotatingLogger(BaseRotatingLogger): - """Log CAN messages to a sequence of files with a given maximum size. +class RotatingLogger(BaseRotatingLogger): + """Log CAN messages to a sequence of files with a given maximum size, + a specified amount of time or a combination of both. When both max size + (bytes) and rollover time (seconds) are provided, the rollover trigger + is caused by the first to occur. - The logger creates a log file with the given `base_filename`. When the - size threshold is reached the current log file is closed and renamed + The logger creates a log file with the given `base_filename`. When a size + or time threshold is reached the current log file is closed and renamed by adding a timestamp and the rollover count. A new log file is then created and written to. @@ -287,20 +292,38 @@ class SizedRotatingLogger(BaseRotatingLogger): Example:: - from can import Notifier, SizedRotatingLogger + from can import Notifier, RotatingLogger from can.interfaces.vector import VectorBus bus = VectorBus(channel=[0], app_name="CANape", fd=True) - logger = SizedRotatingLogger( + # Size constrained rollover + logger = RotatingLogger( base_filename="my_logfile.asc", - max_bytes=5 * 1024 ** 2, # =5MB + max_bytes=5 * 1024 ** 2, # = 5 MB ) logger.rollover_count = 23 # start counter at 23 notifier = Notifier(bus=bus, listeners=[logger]) - The SizedRotatingLogger currently supports the formats + Example:: + + # Size or time constrained rollover (whichever limit occurs first) + logger = RotatingLogger( + base_filename="my_logfile.asc", + max_bytes=5 * 1024 ** 2, # = 5 MB + max_seconds=5 * 60, # = 5 minutes + ) + + Example:: + + # Time constrained rollover + logger = RotatingLogger( + base_filename="my_logfile.asc", + max_seconds=5 * 60, # = 5 minutes + ) + + The RotatingLogger currently supports the formats * .asc: :class:`can.ASCWriter` * .blf :class:`can.BLFWriter` * .csv: :class:`can.CSVWriter` @@ -315,41 +338,60 @@ class SizedRotatingLogger(BaseRotatingLogger): """ _supported_formats = {".asc", ".blf", ".csv", ".log", ".txt"} + last_rollover_time = time.time() def __init__( self, base_filename: StringPathLike, max_bytes: int = 0, + max_seconds: int = 0, **kwargs: Any, ) -> None: """ :param base_filename: - A path-like object for the base filename. The log file format is defined by - the suffix of `base_filename`. + A path-like object for the base filename. The log file format is + defined by the suffix of `base_filename`. :param max_bytes: - The size threshold at which a new log file shall be created. If set to 0, no - rollover will be performed. + The size threshold at which a new log file shall be created. If set + less than or equal to 0, no rollover will be performed. + :param max_seconds: + The elapsed time threshold at which a new log file shall be + created. If set less than or equal to 0, no rollover will be + performed. """ super().__init__(**kwargs) self.base_filename = os.path.abspath(base_filename) - self.max_bytes = max_bytes + + # Rotation parameters + self.max_bytes = max_bytes # Maximum bytes for rotation (bytes) + self.max_seconds = max_seconds # Time difference between rotation (seconds) self._writer = self._get_new_writer(self.base_filename) def should_rollover(self, msg: Message) -> bool: - if self.max_bytes <= 0: + # Check to see if a file rollover should occur based on file size + # (bytes) and elapsed time (seconds) since last rollover. + if self.max_bytes <= 0 and self.max_seconds <= 0: return False - if self.writer.file_size() >= self.max_bytes: + # Check to see if the file size is greater than max bytes + if self.writer.file_size() >= self.max_bytes > 0: + return True + # Check to see if elapsed time is greater than max seconds + if time.time() - self.last_rollover_time > self.max_seconds > 0: return True return False def do_rollover(self) -> None: + # Perform the file rollover. if self.writer: self.writer.stop() + # Reset the time since last rollover + self.last_rollover_time = time.time() + sfn = self.base_filename dfn = self.rotation_filename(self._default_name()) self.rotate(sfn, dfn) @@ -368,3 +410,73 @@ def _default_name(self) -> StringPathLike: + "".join(path.suffixes[-2:]) ) return str(path.parent / new_name) + + +class SizedRotatingLogger(RotatingLogger): + """Log CAN messages to a sequence of files with a given maximum size. + + The logger creates a log file with the given `base_filename`. When the + size threshold is reached the current log file is closed and renamed + by adding a timestamp and the rollover count. A new log file is then + created and written to. + + This behavior can be customized by setting the + :attr:`~can.io.BaseRotatingLogger.namer` and + :attr:`~can.io.BaseRotatingLogger.rotator` + attribute. + + Example:: + + from can import Notifier, SizedRotatingLogger + from can.interfaces.vector import VectorBus + + bus = VectorBus(channel=[0], app_name="CANape", fd=True) + + logger = SizedRotatingLogger( + base_filename="my_logfile.asc", + max_bytes=5 * 1024 ** 2, # =5MB + ) + logger.rollover_count = 23 # start counter at 23 + + notifier = Notifier(bus=bus, listeners=[logger]) + + The SizedRotatingLogger currently supports the formats + * .asc: :class:`can.ASCWriter` + * .blf :class:`can.BLFWriter` + * .csv: :class:`can.CSVWriter` + * .log :class:`can.CanutilsLogWriter` + * .txt :class:`can.Printer` (if pointing to a file) + + .. note:: + The :class:`can.SqliteWriter` is not supported yet. + + The log files on disk may be incomplete due to buffering until + :meth:`~can.Listener.stop` is called. + """ + + _supported_formats = {".asc", ".blf", ".csv", ".log", ".txt"} + + def __init__( + self, + base_filename: StringPathLike, + max_bytes: int = 0, + **kwargs: Any, + ) -> None: + """ + :param base_filename: + A path-like object for the base filename. The log file format is defined by + the suffix of `base_filename`. + :param max_bytes: + The size threshold at which a new log file shall be created. If set to 0, no + rollover will be performed. + """ + # This object is deprecated as of 4.2 and will be removed in 5.0. + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + "`can.io.SizedRotatingLogger` is deprecated as of v4.2. It will be " + "removed in v5.0. New features are not supported by this object. " + "Use the `can.io.RotatingLogger`. class instead", + DeprecationWarning, + ) + # Initialize self as a RotatingLogger + super().__init__(base_filename, max_bytes, **kwargs) diff --git a/can/logconvert.py b/can/logconvert.py index d89155758..0b722cd87 100644 --- a/can/logconvert.py +++ b/can/logconvert.py @@ -6,7 +6,7 @@ import argparse import errno -from can import LogReader, Logger, SizedRotatingLogger +from can import LogReader, Logger, RotatingLogger class ArgumentParser(argparse.ArgumentParser): @@ -48,9 +48,7 @@ def main(): with LogReader(args.input) as reader: if args.file_size: - logger = SizedRotatingLogger( - base_filename=args.output, max_bytes=args.file_size - ) + logger = RotatingLogger(base_filename=args.output, max_bytes=args.file_size) else: logger = Logger(filename=args.output) diff --git a/can/logger.py b/can/logger.py index 55e67b27e..4fd9ec55b 100644 --- a/can/logger.py +++ b/can/logger.py @@ -8,7 +8,7 @@ import can from can.io import BaseRotatingLogger from can.io.generic import MessageWriter -from . import Bus, BusState, Logger, SizedRotatingLogger +from . import Bus, BusState, Logger, RotatingLogger from .typechecking import CanFilter, CanFilters @@ -185,6 +185,17 @@ def main() -> None: default=None, ) + parser.add_argument( + "-t", + "--file_time", + dest="file_time", + type=int, + help="Maximum period in seconds before rotating log file. (If file_size " + "is also given, then the first of the two constraints to occur is " + "what causes the rollover.)", + default=0, + ) + parser.add_argument( "-v", action="count", @@ -224,10 +235,11 @@ def main() -> None: print(f"Can Logger (Started on {datetime.now()})") logger: Union[MessageWriter, BaseRotatingLogger] - if results.file_size: - logger = SizedRotatingLogger( + if results.file_size or results.file_time: + logger = RotatingLogger( base_filename=results.log_file, max_bytes=results.file_size, + max_seconds=results.file_time, append=results.append, **additional_config, ) diff --git a/doc/listeners.rst b/doc/listeners.rst index 260854d2a..d3c7e6726 100644 --- a/doc/listeners.rst +++ b/doc/listeners.rst @@ -97,7 +97,7 @@ create log files with different file types of the messages received. .. autoclass:: can.io.BaseRotatingLogger :members: -.. autoclass:: can.SizedRotatingLogger +.. autoclass:: can.RotatingLogger :members: diff --git a/test/test_logger.py b/test/test_logger.py index bb0015a89..d4b2a6cf5 100644 --- a/test/test_logger.py +++ b/test/test_logger.py @@ -38,8 +38,8 @@ def setUp(self) -> None: self.MockLoggerUse = self.MockLogger self.loggerToUse = self.mock_logger - # Patch SizedRotatingLogger object - patcher_logger_sized = mock.patch("can.logger.SizedRotatingLogger", spec=True) + # Patch RotatingLogger object + patcher_logger_sized = mock.patch("can.logger.RotatingLogger", spec=True) self.MockLoggerSized = patcher_logger_sized.start() self.addCleanup(patcher_logger_sized.stop) self.mock_logger_sized = self.MockLoggerSized.return_value @@ -103,7 +103,17 @@ def test_log_virtual_sizedlogger(self): self.MockLoggerUse = self.MockLoggerSized self.loggerToUse = self.mock_logger_sized - sys.argv = self.baseargs + ["--file_size", "1000000"] + sys.argv = self.baseargs + ["-f file.log"] + ["--file_size", "1000000"] + can.logger.main() + self.assertSuccessfullCleanup() + self.mock_logger_sized.assert_called_once() + + def test_log_virtual_timedlogger(self): + self.mock_virtual_bus.recv = Mock(side_effect=[self.testmsg, KeyboardInterrupt]) + self.MockLoggerUse = self.MockLoggerSized + self.loggerToUse = self.mock_logger_sized + + sys.argv = self.baseargs + ["-f file.log"] + ["--file_time", "5"] can.logger.main() self.assertSuccessfullCleanup() self.mock_logger_sized.assert_called_once() diff --git a/test/test_rotating_loggers.py b/test/test_rotating_loggers.py index ad4388bf7..cab9161f0 100644 --- a/test/test_rotating_loggers.py +++ b/test/test_rotating_loggers.py @@ -212,6 +212,8 @@ def test_create_instance(self, tmp_path): logger_instance.stop() def test_should_rollover(self, tmp_path): + # NOTE: Move to TestRotatingLogger at v5.0 release when + # SizedRotatingLogger is removed. base_filename = "mylogfile.ASC" max_bytes = 512 @@ -235,6 +237,8 @@ def test_should_rollover(self, tmp_path): logger_instance.stop() def test_logfile_size(self, tmp_path): + # NOTE: Move to TestRotatingLogger at v5.0 release when + # SizedRotatingLogger is removed. base_filename = "mylogfile.ASC" max_bytes = 1024 msg = generate_message(0x123) @@ -251,6 +255,8 @@ def test_logfile_size(self, tmp_path): logger_instance.stop() def test_logfile_size_context_manager(self, tmp_path): + # NOTE: Move to TestRotatingLogger at v5.0 release when + # SizedRotatingLogger is removed. base_filename = "mylogfile.ASC" max_bytes = 1024 msg = generate_message(0x123) @@ -263,3 +269,54 @@ def test_logfile_size_context_manager(self, tmp_path): for file_path in os.listdir(tmp_path): assert os.path.getsize(os.path.join(tmp_path, file_path)) <= 1100 + + +class TestRotatingLogger: + def test_import(self): + assert hasattr(can.io, "RotatingLogger") + assert hasattr(can, "RotatingLogger") + + def test_attributes(self): + assert issubclass(can.RotatingLogger, can.io.BaseRotatingLogger) + assert hasattr(can.RotatingLogger, "namer") + assert hasattr(can.RotatingLogger, "rotator") + assert hasattr(can.RotatingLogger, "should_rollover") + assert hasattr(can.RotatingLogger, "do_rollover") + + def test_create_instance(self, tmp_path): + base_filename = "mylogfile.ASC" + max_bytes = 512 + + logger_instance = can.SizedRotatingLogger( + base_filename=tmp_path / base_filename, max_bytes=max_bytes + ) + assert Path(logger_instance.base_filename).name == base_filename + assert logger_instance.max_bytes == max_bytes + assert logger_instance.rollover_count == 0 + assert isinstance(logger_instance.writer, can.ASCWriter) + + logger_instance.stop() + + def test_should_rollover_time(self, tmp_path): + base_filename = "mylogfile.ASC" + max_seconds = 300 + + logger_instance = can.RotatingLogger( + base_filename=tmp_path / base_filename, max_seconds=max_seconds + ) + msg = generate_message(0x123) + do_rollover = Mock() + logger_instance.do_rollover = do_rollover + + assert logger_instance.should_rollover(msg) is False + logger_instance.on_message_received(msg) + do_rollover.assert_not_called() + + logger_instance.last_rollover_time = ( + logger_instance.last_rollover_time - max_seconds + ) + assert logger_instance.should_rollover(msg) is True + logger_instance.on_message_received(msg) + do_rollover.assert_called() + + logger_instance.stop()