Coverage for src/evutils/io/decoders.py: 85%

78 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-15 09:33 +0000

1"""Decoders module. 

2 

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""" 

8 

9from pathlib import Path 

10from typing import Type, Any, cast 

11 

12from .common import EventDecoder 

13 

14#: Extension -> decoder class, for available backends only. 

15_READER_MAPPING: dict[str, Type[EventDecoder]] = {} 

16 

17#: Extension -> reason it is unavailable (missing optional dependency). 

18_UNAVAILABLE: dict[str, str] = {} 

19 

20 

21from ._aedat import EventDecoder_Aedat 

22_READER_MAPPING[".aedat"] = EventDecoder_Aedat 

23_READER_MAPPING[".aedat4"] = EventDecoder_Aedat 

24 

25from ._bin import EventDecoder_Bin 

26_READER_MAPPING[".bin"] = EventDecoder_Bin 

27 

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 ) 

37 

38from ._dat import EventDecoder_Dat 

39_READER_MAPPING[".dat"] = EventDecoder_Dat 

40 

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 ) 

49 

50from ._npz import EventDecoder_Npz 

51_READER_MAPPING[".npz"] = EventDecoder_Npz 

52 

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 

60 

61from ._aer import EventDecoder_AER 

62_READER_MAPPING[".aer"] = EventDecoder_AER 

63 

64 

65def _lookup(ext: str) -> Type[EventDecoder]: 

66 """Resolve an extension to a decoder class, or raise a helpful error.""" 

67 if ext in _READER_MAPPING: 

68 return _READER_MAPPING[ext] 

69 if ext in _UNAVAILABLE: 

70 raise ImportError(f"File extension {ext} is supported, but {_UNAVAILABLE[ext]}") 

71 raise ValueError( 

72 f"File extension {ext} not supported, available extensions: " 

73 f"{sorted(_READER_MAPPING.keys() | _UNAVAILABLE.keys())}" 

74 ) 

75 

76 

77def get_reader_from_filename(file: Path) -> Type[EventDecoder]: 

78 """Get the appropriate reader for the given file. 

79 

80 Parameters 

81 ---------- 

82 file 

83 File to read 

84 

85 Returns 

86 ------- 

87 EventDecoder 

88 Reader object for the file 

89 

90 """ 

91 return _lookup(file.suffix.lower()) 

92 

93 

94# Content sniffers: (predicate over the first bytes -> decoder class). Tried in 

95# order when the filename extension is unknown or absent (streams, USB). 

96def _header_lines(head: bytes) -> list[str]: 

97 """The ``"% ..."`` ASCII header lines at the start of ``head``.""" 

98 text = head.decode("ascii", "ignore") 

99 return [ln for ln in text.split("\n") if ln.startswith("% ")] 

100 

101 

102def _sniff_dat(head: bytes) -> bool: 

103 """Prophesee DAT: header carries ``% Version`` / ``% Data file containing``. 

104 

105 DAT and RAW/EVT both open with a ``%`` header, so they can only be told 

106 apart by the keywords inside it. 

107 """ 

108 for ln in _header_lines(head): 

109 low = ln.lower() 

110 if "data file containing" in low or low.startswith("% version"): 

111 return True 

112 return False 

113 

114 

115def _sniff_evt(head: bytes) -> bool: 

116 """Prophesee RAW/EVT: header carries ``% evt`` / ``% format EVT`` / ``% geometry``.""" 

117 for ln in _header_lines(head): 

118 low = ln.lower() 

119 if (low.startswith("% evt") 

120 or low.startswith("% format evt") 

121 or low.startswith("% geometry")): 

122 return True 

123 return False 

124 

125 

126def _sniff_prophesee(head: bytes) -> bool: 

127 """Fallback: any other ``"% "``-headed stream is treated as RAW/EVT.""" 

128 return head[:2] == b"% " 

129 

130 

131def _sniff_aedat(head: bytes) -> bool: 

132 """Check if the first bytes match an AEDAT version line. 

133 

134 Parameters 

135 ---------- 

136 head : bytes 

137 The first bytes of the file/stream. 

138 

139 Returns 

140 ------- 

141 bool 

142 True if it matches the AEDAT format, False otherwise. 

143 

144 """ 

145 return head.startswith(b"#!AER-DAT") 

146 

147 

148_SNIFFERS = [ 

149 (_sniff_dat, "EventDecoder_Dat"), 

150 (_sniff_evt, "EventDecoder_EVT"), 

151 (_sniff_aedat, "EventDecoder_Aedat"), 

152 (_sniff_prophesee, "EventDecoder_EVT"), # generic "% "-headed fallback 

153] 

154 

155 

156def resolve_decoder_cls(source: Any) -> Type[EventDecoder]: 

157 """Determine the decoder class for a :class:`ByteSource`. 

158 

159 Tries the filename extension first (cheap, usually right), then falls back 

160 to sniffing the leading bytes -- which works for streams and USB devices 

161 that have no filename. 

162 

163 Parameters 

164 ---------- 

165 source 

166 A ByteSource (see :mod:`evutils.io._source`). 

167 

168 Returns 

169 ------- 

170 Type[EventDecoder] 

171 The decoder class to instantiate with the source. 

172 

173 """ 

174 name = getattr(source, "name", None) 

175 if name: 

176 ext = Path(name).suffix.lower() 

177 if ext in _READER_MAPPING or ext in _UNAVAILABLE: 

178 return _lookup(ext) 

179 

180 try: 

181 head = source.peek(512) 

182 except Exception: 

183 head = b"" 

184 

185 for matches, cls_name in _SNIFFERS: 

186 if matches(head): 

187 return cast(Type[EventDecoder], globals()[cls_name]) 

188 

189 raise ValueError( 

190 "Could not determine the event format: unknown/absent extension " 

191 f"({name!r}) and no known magic bytes. Pass an explicit decoder." 

192 ) 

193 

194 

195__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']