From 2c0bb48693d1e96332ef8cb9df74bc9e363e41ed Mon Sep 17 00:00:00 2001 From: TIM ANDERSON Date: Sat, 7 Nov 2026 01:05:32 +1000 Subject: [PATCH] Fix MF4 optional dependency import order --- can/io/mf4.py | 78 +++++++++++-------- doc/changelog.d/2122.fixed.rst | 2 + test/mf4_import_test.py | 135 +++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 doc/changelog.d/2122.fixed.rst create mode 100644 test/mf4_import_test.py diff --git a/can/io/mf4.py b/can/io/mf4.py index fcde2e193..f18ba7178 100644 --- a/can/io/mf4.py +++ b/can/io/mf4.py @@ -5,31 +5,36 @@ the ASAM MDF standard (see https://www.asam.net/standards/detail/mdf/) """ +from __future__ import annotations + import abc import heapq import logging -from collections.abc import Generator, Iterator from datetime import 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 typing import TYPE_CHECKING, Any, BinaryIO, cast from ..message import Message -from ..typechecking import StringPathLike from ..util import channel2int, len2dlc from .generic import BinaryIOMessageReader, BinaryIOMessageWriter logger = logging.getLogger("can.io.mf4") -try: - import asammdf - import numpy as np +if TYPE_CHECKING: + from collections.abc import Generator, Iterator + from types import ModuleType + 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 + + from ..typechecking import StringPathLike + +try: + import numpy as np STD_DTYPE = np.dtype( [ @@ -72,9 +77,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 +128,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 +139,7 @@ def __init__( super().__init__(file, mode="w+b") now = datetime.now() - 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 +155,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=" 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 @@ -502,13 +514,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/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") + """)