Coverage for src/evutils/io/decoders.py: 82%
83 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +0000
1"""Decoders module.
3Provides mapping and resolution of event decoders based on file extensions or
4magic bytes. Backends whose optional dependencies are missing (e.g. pandas for
5CSV, h5py for HDF5) are not registered; asking for them raises an
6``ImportError`` that names the extra to install.
7"""
9from pathlib import Path
10from typing import Type, cast
12from .common import EventDecoder
13from ._compression import strip_compression_suffix
15#: Extension -> decoder class, for available backends only.
16_READER_MAPPING: dict[str, Type[EventDecoder]] = {}
18#: Extension -> reason it is unavailable (missing optional dependency).
19_UNAVAILABLE: dict[str, str] = {}
21from ._aedat import EventDecoder_Aedat
22_READER_MAPPING[".aedat"] = EventDecoder_Aedat
23_READER_MAPPING[".aedat4"] = EventDecoder_Aedat
25from ._bin import EventDecoder_Bin
26_READER_MAPPING[".bin"] = EventDecoder_Bin
28try:
29 from ._csv import EventDecoder_Csv
30 _READER_MAPPING[".csv"] = EventDecoder_Csv
31 _READER_MAPPING[".txt"] = EventDecoder_Csv
32except ImportError:
33 _UNAVAILABLE[".csv"] = _UNAVAILABLE[".txt"] = (
34 "reading CSV/TXT event files requires the evutils native library "
35 "(build it with `uv pip install -e .`)"
36 )
38from ._dat import EventDecoder_Dat
39_READER_MAPPING[".dat"] = EventDecoder_Dat
41try:
42 from ._hdf5 import EventDecoder_HDF5
43 _READER_MAPPING[".hdf5"] = EventDecoder_HDF5
44 _READER_MAPPING[".h5"] = EventDecoder_HDF5
45except ImportError:
46 _UNAVAILABLE[".hdf5"] = _UNAVAILABLE[".h5"] = (
47 "reading HDF5 event files requires h5py/hdf5plugin: install `evutils[hdf5]`"
48 )
50from ._npz import EventDecoder_Npz
51_READER_MAPPING[".npz"] = EventDecoder_Npz
53from ._evt import EventDecoder_EVT
54_READER_MAPPING[".raw"] = EventDecoder_EVT
55_READER_MAPPING[".evt"] = EventDecoder_EVT
56_READER_MAPPING[".evt3"] = EventDecoder_EVT
57_READER_MAPPING[".evt2"] = EventDecoder_EVT
58_READER_MAPPING[".evt21"] = EventDecoder_EVT
59_READER_MAPPING[".evt4"] = EventDecoder_EVT
61from ._aer import EventDecoder_AER
62_READER_MAPPING[".aer"] = EventDecoder_AER
64def _lookup(ext: str) -> Type[EventDecoder]:
65 """Resolve an extension to a decoder class, or raise a helpful error."""
66 if ext in _READER_MAPPING:
67 return _READER_MAPPING[ext]
68 if ext in _UNAVAILABLE:
69 raise ImportError(f"File extension {ext} is supported, but {_UNAVAILABLE[ext]}")
70 raise ValueError(
71 f"File extension {ext} not supported, available extensions: "
72 f"{sorted(_READER_MAPPING.keys() | _UNAVAILABLE.keys())}"
73 )
75def get_reader_from_filename(file: Path) -> Type[EventDecoder]:
76 """Get the appropriate reader for the given file.
78 Parameters
79 ----------
80 file
81 File to read
83 Returns
84 -------
85 EventDecoder
86 Reader object for the file
88 """
89 return _lookup(file.suffix.lower())
91# Content sniffers: (predicate over the first bytes -> decoder class). Tried in
92# order when the filename extension is unknown or absent (streams, USB).
93def _header_lines(head: bytes) -> list[str]:
94 """The ``"% ..."`` ASCII header lines at the start of ``head``."""
95 text = head.decode("ascii", "ignore")
96 return [ln for ln in text.split("\n") if ln.startswith("% ")]
98def _sniff_dat(head: bytes) -> bool:
99 """Prophesee DAT: header carries ``% Version`` / ``% Data file containing``.
101 DAT and RAW/EVT both open with a ``%`` header, so they can only be told
102 apart by the keywords inside it.
103 """
104 for ln in _header_lines(head):
105 low = ln.lower()
106 if "data file containing" in low or low.startswith("% version"):
107 return True
108 return False
110def _sniff_evt(head: bytes) -> bool:
111 """Prophesee RAW/EVT: header carries ``% evt`` / ``% format EVT`` / ``% geometry``."""
112 for ln in _header_lines(head):
113 low = ln.lower()
114 if (low.startswith("% evt")
115 or low.startswith("% format evt")
116 or low.startswith("% geometry")):
117 return True
118 return False
120def _sniff_prophesee(head: bytes) -> bool:
121 """Fallback: any other ``"% "``-headed stream with prophesee-like metadata."""
122 if not head.startswith(b"% "):
123 return False
124 low = head.lower()
125 return b"system_id" in low or b"firmware_version" in low or b"plugin name" in low
127def _sniff_aedat(head: bytes) -> bool:
128 """Check if the first bytes match an AEDAT version line.
130 Parameters
131 ----------
132 head : bytes
133 The first bytes of the file/stream.
135 Returns
136 -------
137 bool
138 True if it matches the AEDAT format, False otherwise.
140 """
141 return head.startswith(b"#!AER-DAT")
143_SNIFFERS = [
144 (_sniff_dat, "EventDecoder_Dat"),
145 (_sniff_evt, "EventDecoder_EVT"),
146 (_sniff_aedat, "EventDecoder_Aedat"),
147 (_sniff_prophesee, "EventDecoder_EVT"), # generic "% "-headed fallback
148]
150def resolve_decoder_cls(source: "io.BufferedIOBase | str | bytes") -> Type[EventDecoder]:
151 """Determine the decoder class for a :class:`ByteSource`.
153 Tries the filename extension first (cheap, usually right), then falls back
154 to sniffing the leading bytes -- which works for streams and USB devices
155 that have no filename.
157 Parameters
158 ----------
159 source
160 A ByteSource (see :mod:`evutils.io._source`).
162 Returns
163 -------
164 Type[EventDecoder]
165 The decoder class to instantiate with the source.
167 """
168 name = getattr(source, "name", None)
169 if name:
170 # A compression suffix (foo.raw.zst) is transparent to format choice:
171 # strip it so the *inner* extension selects the decoder.
172 name = strip_compression_suffix(name)
173 ext = Path(name).suffix.lower()
174 if ext in _READER_MAPPING or ext in _UNAVAILABLE:
175 return _lookup(ext)
177 try:
178 head = source.peek(512)
179 except Exception:
180 head = b""
182 for matches, cls_name in _SNIFFERS:
183 if matches(head):
184 return cast(Type[EventDecoder], globals()[cls_name])
186 raise ValueError(
187 "Could not determine the event format: unknown/absent extension "
188 f"({name!r}) and no known magic bytes. Pass an explicit decoder."
189 )
191__all__ = ["EventDecoder", 'EventDecoder_Aedat', 'EventDecoder_Bin', 'EventDecoder_Csv', 'EventDecoder_Dat', 'EventDecoder_HDF5', 'EventDecoder_Npz', 'EventDecoder_EVT', 'EventDecoder_AER', 'get_reader_from_filename', 'resolve_decoder_cls']