Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 45 additions & 33 deletions can/io/mf4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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()

Expand All @@ -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="<f8"),
Expand All @@ -158,7 +172,7 @@ def __init__(

# error frames group
self._mdf.append(
Signal(
asammdf.Signal(
name="CAN_ErrorFrame",
samples=np.array([], dtype=ERR_DTYPE),
timestamps=np.array([], dtype="<f8"),
Expand All @@ -169,7 +183,7 @@ def __init__(

# remote frames group
self._mdf.append(
Signal(
asammdf.Signal(
name="CAN_RemoteFrame",
samples=np.array([], dtype=RTR_DTYPE),
timestamps=np.array([], dtype="<f8"),
Expand Down Expand Up @@ -466,30 +480,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

Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions doc/changelog.d/2122.fixed.rst
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions test/mf4_import_test.py
Original file line number Diff line number Diff line change
@@ -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")
""")