diff --git a/can/io/mf4.py b/can/io/mf4.py index 92feb4710..7eceb58a1 100644 --- a/can/io/mf4.py +++ b/can/io/mf4.py @@ -11,9 +11,11 @@ from collections.abc import Generator, Iterator from datetime import UTC, datetime from hashlib import md5 +from importlib import import_module from io import BufferedIOBase, BytesIO from pathlib import Path -from typing import Any, BinaryIO, cast +from types import ModuleType +from typing import TYPE_CHECKING, Any, BinaryIO, cast from ..message import Message from ..typechecking import StringPathLike @@ -22,14 +24,13 @@ logger = logging.getLogger("can.io.mf4") -try: - import asammdf - import numpy as np +if TYPE_CHECKING: from asammdf import Signal, Source from asammdf.blocks.mdf_v4 import MDF4 - from asammdf.blocks.v4_blocks import ChannelGroup, SourceInformation - from asammdf.blocks.v4_constants import BUS_TYPE_CAN, FLAG_CG_BUS_EVENT, SOURCE_BUS - from asammdf.mdf import MDF + from asammdf.blocks.v4_blocks import ChannelGroup + +try: + import numpy as np STD_DTYPE = np.dtype( [ @@ -72,9 +73,21 @@ ] ) except ImportError: - asammdf = None - MDF4 = None - Signal = None + pass + + +def _import_asammdf(class_name: str) -> ModuleType: + # Import when a reader/writer is created: importing can while asammdf is + # initializing must not permanently disable MF4 support. + try: + return import_module("asammdf") + except ModuleNotFoundError as exc: + if exc.name != "asammdf": + raise + raise NotImplementedError( + "The asammdf package was not found. Install python-can with " + f"the optional dependency [mf4] to use the {class_name}." + ) from exc CAN_MSG_EXT = 0x80000000 @@ -111,11 +124,8 @@ def __init__( * 1 - deflate (slower, but produces smaller files) * 2 - transposition + deflate (slowest, but produces the smallest files) """ - if asammdf is None: - raise NotImplementedError( - "The asammdf package was not found. Install python-can with " - "the optional dependency [mf4] to use the MF4Writer." - ) + asammdf = _import_asammdf("MF4Writer") + source_information = import_module("asammdf.blocks.v4_blocks").SourceInformation if kwargs.get("append", False): raise ValueError( @@ -125,7 +135,7 @@ def __init__( super().__init__(file, mode="w+b") now = datetime.now(tz=UTC) - self._mdf = cast("MDF4", MDF(version="4.10")) + self._mdf = cast("MDF4", asammdf.MDF(version="4.10")) self._mdf.header.start_time = now self.last_timestamp = self._start_time = now.timestamp() @@ -141,13 +151,13 @@ def __init__( else: attachment = None - acquisition_source = SourceInformation( - source_type=SOURCE_BUS, bus_type=BUS_TYPE_CAN + acquisition_source = source_information( + source_type=asammdf.Source.SOURCE_BUS, bus_type=asammdf.Source.BUS_TYPE_CAN ) # standard frames group self._mdf.append( - Signal( + asammdf.Signal( name="CAN_DataFrame", samples=np.array([], dtype=STD_DTYPE), timestamps=np.array([], dtype=" Signal: + def _get_data(self, current_offset: int) -> "Signal": # NOTE: asammdf suggests using select instead of get. Select seem to miss converting some # channels which get does convert as expected. data_raw = self._mdf.get( @@ -326,7 +338,7 @@ class MF4Reader(BinaryIOMessageReader): class _CANDataFrameIterator(FrameIterator): - def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + def __init__(self, mdf: "MDF4", group_index: int, start_timestamp: float): super().__init__(mdf, group_index, start_timestamp, "CAN_DataFrame") def __iter__(self) -> Generator[Message, None, None]: @@ -369,7 +381,7 @@ def __iter__(self) -> Generator[Message, None, None]: class _CANErrorFrameIterator(FrameIterator): - def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + def __init__(self, mdf: "MDF4", group_index: int, start_timestamp: float): super().__init__(mdf, group_index, start_timestamp, "CAN_ErrorFrame") def __iter__(self) -> Generator[Message, None, None]: @@ -424,7 +436,7 @@ def __iter__(self) -> Generator[Message, None, None]: class _CANRemoteFrameIterator(FrameIterator): - def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + def __init__(self, mdf: "MDF4", group_index: int, start_timestamp: float): super().__init__(mdf, group_index, start_timestamp, "CAN_RemoteFrame") def __iter__(self) -> Generator[Message, None, None]: @@ -467,30 +479,28 @@ def __init__( If this is a file-like object, is has to be opened in binary read mode, not text read mode. """ - if asammdf is None: - raise NotImplementedError( - "The asammdf package was not found. Install python-can with " - "the optional dependency [mf4] to use the MF4Reader." - ) + asammdf = _import_asammdf("MF4Reader") super().__init__(file, mode="rb") self._mdf: MDF4 if isinstance(file, BufferedIOBase): - self._mdf = cast("MDF4", MDF(BytesIO(file.read()))) + self._mdf = cast("MDF4", asammdf.MDF(BytesIO(file.read()))) else: - self._mdf = cast("MDF4", MDF(file)) + self._mdf = cast("MDF4", asammdf.MDF(file)) self._start_timestamp = self._mdf.header.start_time.timestamp() def __iter__(self) -> Iterator[Message]: # To handle messages split over multiple channel groups, create a single iterator per # channel group and merge these iterators into a single iterator using heapq. + asammdf = _import_asammdf("MF4Reader") + bus_event = import_module("asammdf.blocks.v4_constants").FLAG_CG_BUS_EVENT iterators: list[FrameIterator] = [] for group_index, group in enumerate(self._mdf.groups): channel_group: ChannelGroup = group.channel_group - if not channel_group.flags & FLAG_CG_BUS_EVENT: + if not channel_group.flags & bus_event: # Not a bus event, skip continue @@ -503,13 +513,13 @@ def __iter__(self) -> Iterator[Message]: if acquisition_source is None: # No source information, skip continue - if not acquisition_source.source_type & Source.SOURCE_BUS: + if not acquisition_source.source_type & asammdf.Source.SOURCE_BUS: # Not a bus type (likely already covered by the channel group flag), skip continue channel_names = [channel.name for channel in group.channels] - if acquisition_source.bus_type == Source.BUS_TYPE_CAN: + if acquisition_source.bus_type == asammdf.Source.BUS_TYPE_CAN: if "CAN_DataFrame" in channel_names: iterators.append( self._CANDataFrameIterator( diff --git a/doc/changelog.d/2122.fixed.rst b/doc/changelog.d/2122.fixed.rst new file mode 100644 index 000000000..ed70afd6f --- /dev/null +++ b/doc/changelog.d/2122.fixed.rst @@ -0,0 +1,2 @@ +Load MF4 dependencies when a reader or writer is created so importing +``asammdf`` before ``can`` does not permanently disable MF4 support. diff --git a/test/logformats_test.py b/test/logformats_test.py index beb5b7b9f..c3b621410 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -1042,6 +1042,9 @@ class TestMF4FileFormat(ReaderWriterTest): ] ) def test_error_frame_payload(self, name, storage, payload, length, fd): + from asammdf.blocks.v4_blocks import SourceInformation + from asammdf.blocks.v4_constants import FLAG_CG_BUS_EVENT + # Build the file with asammdf, since MF4Writer always uses a byte array. dtype = mf4.np.dtype( [ @@ -1081,12 +1084,13 @@ def test_error_frame_payload(self, name, storage, payload, length, fd): name="CAN_ErrorFrame", samples=samples, timestamps=mf4.np.array([1.5, 2.5]), - source=mf4.SourceInformation( - source_type=mf4.SOURCE_BUS, bus_type=mf4.BUS_TYPE_CAN + source=SourceInformation( + source_type=asammdf.Source.SOURCE_BUS, + bus_type=asammdf.Source.BUS_TYPE_CAN, ), ) ) - mdf.groups[0].channel_group.flags |= mf4.FLAG_CG_BUS_EVENT + mdf.groups[0].channel_group.flags |= FLAG_CG_BUS_EVENT mdf.save(path, overwrite=True) with can.MF4Reader(path) as reader: diff --git a/test/mf4_import_test.py b/test/mf4_import_test.py new file mode 100644 index 000000000..d72a5bbaf --- /dev/null +++ b/test/mf4_import_test.py @@ -0,0 +1,135 @@ +"""Check optional MF4 imports in fresh interpreters.""" + +import importlib.util +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + + +class MF4ImportTest(unittest.TestCase): + def run_script(self, script, *, circular_import=False): + script = textwrap.dedent(script) + if circular_import: + script = textwrap.dedent(""" + import importlib.abc + import importlib.machinery + import sys + + class CircularLoader(importlib.abc.Loader): + def __init__(self, loader): + self.loader = loader + + def create_module(self, spec): + return None + + def exec_module(self, module): + import can + self.loader.exec_module(module) + + class CircularAsammdf(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "asammdf": + spec = importlib.machinery.PathFinder.find_spec(fullname, path) + spec.loader = CircularLoader(spec.loader) + return spec + + sys.meta_path.insert(0, CircularAsammdf()) + """) + script + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + @unittest.skipUnless(importlib.util.find_spec("asammdf"), "MF4 is unavailable") + def test_both_import_orders(self): + with tempfile.TemporaryDirectory() as directory: + path = str(Path(directory) / "messages.mf4") + self.run_script(f""" + import can + with can.MF4Writer({path!r}) as writer: + writer(can.Message(timestamp=1, arbitration_id=0x123, + is_extended_id=False, data=b'\\x01\\x02')) + """) + for imports, circular_import in ( + ("import asammdf; import can", False), + ("import can; import asammdf", False), + ("import asammdf; import can", True), + ): + for operation in ("read", "write"): + with self.subTest( + imports=imports, + operation=operation, + circular_import=circular_import, + ): + self.run_script( + f""" + {imports} + if {operation!r} == "read": + with can.MF4Reader({path!r}) as reader: + messages = list(reader) + assert len(messages) == 1 + assert messages[0].arbitration_id == 0x123 + assert messages[0].data == b'\\x01\\x02' + else: + from io import BytesIO + with can.MF4Writer(BytesIO()) as writer: + writer(can.Message(arbitration_id=0x456, + is_extended_id=False, data=b'\\x03')) + """, + circular_import=circular_import, + ) + + def test_missing_dependency_does_not_open_files(self): + self.run_script(""" + import importlib.abc + import sys + from unittest.mock import patch + + class MissingAsammdf(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "asammdf": + raise ModuleNotFoundError("asammdf unavailable", name="asammdf") + + sys.meta_path.insert(0, MissingAsammdf()) + import can + for cls in (can.MF4Reader, can.MF4Writer): + with patch("builtins.open") as opened: + try: + cls("must-not-open.mf4") + except NotImplementedError as exc: + assert "optional dependency [mf4]" in str(exc) + assert cls.__name__ in str(exc) + else: + raise AssertionError("missing dependency was accepted") + opened.assert_not_called() + """) + + def test_broken_dependency_keeps_original_error(self): + self.run_script(""" + import importlib.abc + import sys + + class BrokenAsammdf(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "asammdf": + raise ModuleNotFoundError( + "missing transitive dependency", name="missing_child" + ) + + sys.meta_path.insert(0, BrokenAsammdf()) + import can + for cls in (can.MF4Reader, can.MF4Writer): + try: + cls("must-not-open.mf4") + except ModuleNotFoundError as exc: + assert exc.name == "missing_child" + else: + raise AssertionError("original dependency error was hidden") + """)