fmatoolbox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fmatoolbox/data.py ADDED
@@ -0,0 +1,546 @@
1
+ ''' Session data handling functions for FMAToolbox '''
2
+ from typing import Any, Callable
3
+ import pathlib
4
+ import scipy.io
5
+ import numpy as np
6
+ import ast
7
+ import datetime
8
+ import traceback
9
+ import re
10
+ import fmatoolbox.exceptions
11
+ import collections
12
+ import concurrent.futures
13
+
14
+
15
+ def loadSpikeTimes(session:str, output:str='dict', anat_file:str=None, return_elec:bool=False, return_loc:bool=False, reload:bool=False):
16
+ # load spikes from a session
17
+ #
18
+ # arguments:
19
+ # session string, path to session .xml file, spike files must be in session folder
20
+ # output string = None, determines output type, can be:
21
+ # 'dict', dictionary of spike times per unit
22
+ # 'compact', (:,2) array of [timestamps, unit ids]
23
+ # 'full', (:,2) array of [timestamps, electrode groups, clusters]
24
+ # 'regions', dict of {region id: dict}, entries are {'spikes': region spikes, 'units': region units}
25
+ # anat_file str = None, DESCRIBE
26
+ # return_elec bool = False, if true, return cluster_loc
27
+ # return_loc bool = False, if true, return cluster_loc
28
+ # reload bool = False, load original files, bypassing Regions/<basename>_spikes.npz backup (for 'output' = 'regions')
29
+ #
30
+ # output:
31
+ # spikes (see 'output')
32
+ # electrodes (n) float, optional, electrode id per unit
33
+ # cluster_loc (n) float, optional, index of max spike-amplitude cluster per unit
34
+
35
+ file_root = pathlib.Path(session).with_suffix('')
36
+ cell_metrics_file = file_root.with_suffix('.cell_metrics.cellinfo.mat')
37
+ if cell_metrics_file.exists():
38
+ if return_elec or return_loc:
39
+ spikes, electrode_id, cluster_loc = loadCellMetricsFile(session,output=output,anat_file=anat_file,return_extra=True,reload=reload)
40
+ else:
41
+ spikes = loadCellMetricsFile(session,output=output,anat_file=anat_file,reload=reload)
42
+ else:
43
+ if return_elec or return_loc:
44
+ raise ValueError("'return_elec' and 'return_loc' are not implemented for .clu files")
45
+ spikes = loadCluFiles(session,output=output,anat_file=anat_file,reload=reload)
46
+
47
+ if not return_elec and not return_loc:
48
+ return spikes
49
+ out = (spikes, electrode_id, cluster_loc) # prepare tuple to return requested outputs
50
+ return out[:2+return_loc:2-return_elec]
51
+
52
+
53
+ def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, return_extra:bool=False, reload:bool=False):
54
+
55
+ file_root = pathlib.Path(session).with_suffix('')
56
+
57
+ # load .cell_metrics.cellinfo.mat file only if necessary
58
+ if output != 'regions' or reload or return_extra:
59
+ cell_metrics_file = file_root.with_suffix('.cell_metrics.cellinfo.mat')
60
+ if not cell_metrics_file.exists():
61
+ raise FileNotFoundError(f'{cell_metrics_file} not found')
62
+
63
+ data = scipy.io.loadmat(cell_metrics_file,simplify_cells=True)['cell_metrics']
64
+ unit_id = data['UID'] - 1
65
+ electrode_id = data['electrodeGroup'] # starts at 1
66
+ # starts from 0 CHECK WITH CLUSTER LOC, there's also maxWaveformChannelOrder, there's also Putative cell type!!
67
+ cluster_loc = data['maxWaveformCh']
68
+ spikes = data['spikes']['times']
69
+
70
+ if output == 'dict':
71
+ spikes = dict(zip(unit_id,spikes))
72
+
73
+ elif output == 'compact':
74
+ ids = np.repeat(unit_id, [len(s) for s in spikes])
75
+ spikes = np.stack((np.concatenate(spikes),ids), axis=1)
76
+ spikes = spikes[spikes[:, 0].argsort()]
77
+
78
+ elif output == 'full':
79
+ electrodes = np.repeat(electrode_id, [len(s) for s in spikes])
80
+ cluster_id = data['cluID'] # min is 2, as 0 and 1 are excluded clusters?
81
+ clusters = np.repeat(cluster_id, [len(s) for s in spikes])
82
+ spikes = np.stack((np.concatenate(spikes), electrodes, clusters), axis=1)
83
+ spikes = spikes[spikes[:, 0].argsort()]
84
+
85
+ elif output == 'regions':
86
+ # try loading
87
+ spike_file = file_root.parent / 'Regions' / (file_root.stem + '_spikes.npz')
88
+ if not reload and spike_file.exists():
89
+ spikes = loadRegionSpikes(spike_file)
90
+ else:
91
+ spikes_dict = dict(zip(unit_id,spikes))
92
+ if anat_file is None:
93
+ raise ValueError("'anat_file' must be provided when 'output' = 'regions'")
94
+ anat = loadAnatomyFile(anat_file)
95
+ anat = anat[anat['rat'] == int(file_root.stem[3:6])] # keep rat of interest, deduced from file name
96
+ ids = np.unique(anat['region'])
97
+ spikes = {str(id): {} for id in ids}
98
+ for id in ids:
99
+ spikes[id]['e_group'] = {g: [] for g in np.unique(anat[anat['region'] == id]['electrode'])}
100
+ s = []
101
+ for electrode in spikes[id]['e_group']:
102
+ electrode_units = unit_id[electrode_id == electrode]
103
+ spikes[id]['e_group'][electrode] = electrode_units
104
+ for unit in electrode_units:
105
+ s.append(np.array([spikes_dict[unit], [unit] * spikes_dict[unit].size]).T)
106
+ # assing spikes, sorted by time
107
+ if len(s):
108
+ s = np.concatenate(s)
109
+ spikes[id]['spikes'] = s[s[:,0].argsort()]
110
+ else:
111
+ spikes[id]['spikes'] = np.array(s,ndmin=2)
112
+ # save
113
+ saveRegionSpikes(spike_file,spikes)
114
+
115
+ else:
116
+ raise ValueError("'output' must be 'dict', 'compact', 'full', or 'regions'")
117
+
118
+ if return_extra:
119
+ return spikes, electrode_id, cluster_loc
120
+ return spikes
121
+
122
+
123
+ def loadCluFiles(session:str, rate:float=20000, output:str='dict', anat_file:str=None, reload:bool=False):
124
+
125
+ if output not in ['dict','compact','full','regions']:
126
+ raise ValueError("'output' must be 'dict', 'compact', 'full', or 'regions'")
127
+
128
+ # load .res and .clu files
129
+ froot = pathlib.Path(session).with_suffix('')
130
+ res_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
131
+ if p.is_file() and p.name.startswith(froot.name + '.res.') and p.name[len(froot.name)+5:].isdigit()}
132
+ clu_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
133
+ if p.is_file() and p.name.startswith(froot.name + '.clu.') and p.name[len(froot.name)+5:].isdigit()}
134
+ res = {r: np.loadtxt(res_files[r],comments='%',delimiter=',') for r in res_files}
135
+ clu = {c: np.loadtxt(clu_files[c],comments='%',delimiter=',') for c in clu_files}
136
+
137
+ # create valid spike groups (i.e., electrodes where .res and .clu files have compatible length)
138
+ groups = np.sort(list(res.keys()))
139
+ clu_spikes = {}
140
+ for group in groups:
141
+ if group in res and group in clu and len(res[group]) == len(clu[group])-1:
142
+ clu_spikes[group] = np.array([res[group], np.full(len(res[group]),group), clu[group][1:]]).T
143
+ keep = (clu_spikes[group][:,-1] != 0) & (clu_spikes[group][:,-1] != 1) # remove artifacts and MUA
144
+ clu_spikes[group] = clu_spikes[group][keep,:]
145
+ clu_spikes[group][:,0] /= rate
146
+ if not clu_spikes:
147
+ raise FileNotFoundError('no valid .res or .clu file found')
148
+
149
+ if output == 'dict':
150
+ # loop through groups, find all units, add to dict
151
+ spikes = {}
152
+ id = 0
153
+ for group in clu_spikes:
154
+ units, idx = np.unique(clu_spikes[group][:,1:],axis=0,return_inverse=True)
155
+ for u in range(len(units)):
156
+ spikes[id] = clu_spikes[group][idx==u,0]
157
+ id += 1
158
+
159
+ elif output == 'compact':
160
+ # concatenate all values in spikes, use unique for ids
161
+ spikes = np.concatenate(tuple(clu_spikes.values()),axis=0)
162
+ units, idx = np.unique(spikes[:,1:],axis=0,return_inverse=True)
163
+ spikes = np.stack((spikes[:,0],idx),axis=1)
164
+ spikes = spikes[spikes[:,0].argsort()]
165
+
166
+ elif output == 'full':
167
+ spikes = np.concatenate(tuple(clu_spikes.values()),axis=0)
168
+ spikes = spikes[spikes[:,0].argsort()]
169
+
170
+ else:
171
+ # try loading
172
+ spike_file = froot.parent / 'Regions' / (froot.stem + '_spikes.npz')
173
+ if not reload and spike_file.exists():
174
+ spikes = loadRegionSpikes(spike_file)
175
+ else:
176
+ if anat_file is None:
177
+ raise ValueError("'anat_file' must be provided when 'output' = 'regions'")
178
+ anat = loadAnatomyFile(anat_file)
179
+ anat = anat[anat['rat'] == int(froot.stem[3:6])] # keep rat of interest, deduced from file name
180
+ ids = np.unique(anat['region'])
181
+ spikes = {id: {} for id in ids}
182
+ unit_count = 0
183
+ for id in ids:
184
+ spikes[id]['e_group'] = {g: [] for g in np.unique(anat[anat['region'] == id]['electrode'])}
185
+ sp = []
186
+ for electrode in spikes[id]['e_group']:
187
+ if electrode in clu_spikes:
188
+ # label units
189
+ s = clu_spikes[electrode]
190
+ u, idx = np.unique(s[:,1:], axis=0, return_inverse=True)
191
+ sp.append(np.stack((s[:,0],idx+unit_count),axis=1))
192
+ spikes[id]['e_group'][electrode] = np.arange(unit_count,unit_count+len(u))
193
+ unit_count = unit_count + len(u)
194
+ if len(sp):
195
+ sp = np.concatenate(sp)
196
+ spikes[id]['spikes'] = sp[sp[:,0].argsort()] # sort by time
197
+ else:
198
+ spikes[id]['spikes'] = np.array(sp,ndmin=2)
199
+
200
+ # save
201
+ saveRegionSpikes(spike_file,spikes)
202
+
203
+ return spikes
204
+
205
+
206
+ def loadAnatomyFile(file_path):
207
+ # load .anat file, whose columns must be [rat, electrode, brain region] (comma separated)
208
+ #
209
+ # arguments:
210
+ # file_path string = None, path to .anat file
211
+
212
+ return np.genfromtxt(file_path,delimiter=",",comments="%",dtype=[("rat",int),("electrode",int),("region","U50")])
213
+
214
+
215
+ def loadRegionSpikes(file):
216
+ spike_npz = np.load(file)
217
+ spikes = {r[3:]: {'spikes': spike_npz[r]} for r in spike_npz.files if r.startswith('sp_')}
218
+ for reg in spikes:
219
+ spikes[reg]['e_group'] = {int(r.rsplit('_',1)[-1]) : spike_npz[r] for r in spike_npz.files if r.startswith(f'un_{reg}')}
220
+
221
+ return spikes
222
+
223
+
224
+ def saveRegionSpikes(file,spikes):
225
+ file = pathlib.Path(file)
226
+ if not file.parent.exists():
227
+ pathlib.Path.mkdir(file.parent)
228
+ np.savez(file, **{f'sp_{r}': s['spikes'] for r, s in spikes.items()},
229
+ **{f'un_{r}_{g}': units for r, s in spikes.items() for g, units in s['e_group'].items()})
230
+
231
+ return
232
+
233
+
234
+ def loadEventFile(filename: str, compact: bool = False):
235
+ # load events from a .evt file
236
+ #
237
+ # arguments:
238
+ # filename string, .evt file, each line must have format 'beginning of basename_event1_1'
239
+ # compact bool = false, if true, return events as compact dictionary
240
+ #
241
+ # output:
242
+ # events dict, keys are either 'times' and 'descriptions' or event names if 'compact' = True
243
+
244
+ with open(filename,'r') as f:
245
+ lines = f.read().splitlines()
246
+
247
+ # extract first non-whitespace token
248
+ times = [line.split()[0] for line in lines]
249
+ times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
250
+
251
+ # remove first token and following whitespace
252
+ descriptions = []
253
+ for line in lines:
254
+ parts = line.split(maxsplit=1)
255
+ descriptions.append(parts[1] if len(parts) > 1 else '')
256
+
257
+ if compact:
258
+ # group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
259
+ events = {} #collections.defaultdict(lambda: collections.defaultdict(list))
260
+ for t, d in zip(times,descriptions):
261
+ if " of " not in d:
262
+ raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
263
+ phase, full_id = d.split(" of ",1)
264
+ phase = phase.strip()
265
+ full_id = full_id.strip()
266
+ # remove trailing 'bis' and '_1'
267
+ if full_id.endswith('bis'):
268
+ full_id = full_id[:-3]
269
+ parts = full_id.split('_')
270
+ try:
271
+ if parts[-1].isdigit():
272
+ part = parts[-2]
273
+ else:
274
+ part = parts[-1]
275
+ except:
276
+ raise ValueError(f"Invalid event ID format: '{full_id}'")
277
+ event_id = part
278
+ # concatenate values
279
+ if event_id in events:
280
+ if phase in events[event_id]:
281
+ events[event_id][phase].append(t)
282
+ else:
283
+ events[event_id][phase] = [t]
284
+ else:
285
+ events[event_id] = {phase : [t]}
286
+ else:
287
+ events = { 'time': times, 'description': descriptions }
288
+
289
+ return events
290
+
291
+
292
+ def loadEvents(session:str, extra:str|list[str]):
293
+ # load event files from a session
294
+ #
295
+ # arguments:
296
+ # session string, path to session .xml file, event files must be in session folder
297
+ # extra (:) string, extensions of other event files named <basename>.extra[i] to load,
298
+ # can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
299
+ #
300
+ # output:
301
+ # events dict, keys are event names
302
+ # cat_names (:) string, names of events contained in <basename>.cat.evt
303
+
304
+ session = pathlib.Path(session)
305
+ file_root = session.parent
306
+ # accept single string input
307
+ if isinstance(extra,str):
308
+ extra = [extra]
309
+
310
+ # load all *.evt files
311
+ events = {}
312
+ cat_names = []
313
+ for file in file_root.glob("*.evt"):
314
+ try:
315
+ events.update(loadEventFile(file,compact=True))
316
+ except fmatoolbox.exceptions.FileFormatError:
317
+ pass
318
+ if str(file)[-8:] == '.cat.evt':
319
+ cat_names = list(events.keys())
320
+
321
+ # load other file types
322
+ for extension in extra:
323
+ # handle subfolders, e.g., 'subfolder/name'
324
+ e = pathlib.Path(extension).name
325
+ p = pathlib.Path(extension).parent
326
+ this_froot = file_root / p / session.stem
327
+ if this_froot.with_suffix(f'.{e}.npz').exists():
328
+ # load .npz file
329
+ data = np.load(this_froot.with_suffix(f'.{e}.npz'))
330
+ for d in data:
331
+ events[d] = {f'col{i}': data[d][:,i] for i in range(data[d].shape[1])}
332
+ elif this_froot.with_suffix(f'.{e}.events.mat').exists():
333
+ # load .events.mat file
334
+ events[e] = scipy.io.loadmat(this_froot.with_suffix(f'.{e}.events.mat'),simplify_cells=True)[e]
335
+ else:
336
+ # load text file
337
+ data = np.loadtxt(this_froot.with_suffix(f'.{e}'),comments='%',delimiter=',',ndmin=2)
338
+ events[e] = {f'col{i}': data[:,i] for i in range(data.shape[1])}
339
+
340
+ return events, cat_names
341
+
342
+
343
+ def loadSpikeWaveforms(session: str):
344
+
345
+ file_root = pathlib.Path(session).with_suffix('')
346
+ data = scipy.io.loadmat(file_root.with_suffix('.cell_metrics.cellinfo.mat'),simplify_cells=True)['cell_metrics']
347
+ waveforms = data['waveforms']
348
+ # INSPECT struct TO FIND OUTPUT
349
+ print('implement!')
350
+
351
+ return
352
+
353
+
354
+ def loadLFP(session: str):
355
+
356
+ print('implement!')
357
+
358
+ return
359
+
360
+
361
+ def saveMatrix():
362
+ # save matrix in standard FMAT format, prepending metadata header
363
+
364
+ print('implement!')
365
+
366
+ return
367
+
368
+ # functions to run batch
369
+
370
+ def readBatchFile(file_path: str):
371
+ # read batch file
372
+ #
373
+ # arguments:
374
+ # file_path batch file
375
+ #
376
+ # output:
377
+ # sessions DESCRIBE
378
+ # args DESCRIBE
379
+
380
+ sessions = []
381
+ args = []
382
+
383
+ try:
384
+ with open(file_path, 'r') as f:
385
+ lines = f.readlines()
386
+ except OSError:
387
+ raise IOError(f"Unable to open {file_path}")
388
+
389
+ for line in lines:
390
+
391
+ # strip spaces, remove inline comments (anything after a %)
392
+ line = line.strip().split('%',1)[0].strip()
393
+
394
+ # split into words
395
+ words = line.split()
396
+ if not words:
397
+ continue
398
+
399
+ # first word is the session name
400
+ sessions.append(words[0])
401
+
402
+ # remaining words are arguments
403
+ session_args = []
404
+ for w in words[1:]:
405
+ try:
406
+ # evaluate numbers, lists, etc.
407
+ value = ast.literal_eval(w)
408
+ except (ValueError, SyntaxError):
409
+ # if not a literal, keep as string
410
+ value = w
411
+ session_args.append(value)
412
+
413
+ args.append(session_args)
414
+
415
+ return sessions, args
416
+
417
+
418
+ def _batchWorker(payload):
419
+ # private function to unpack payload and dispatch it to `func` inside runBatc
420
+ i, func, session, args, extra_args, kwargs, seed = payload
421
+ try:
422
+ result = func(session,*args,*extra_args,**kwargs,**seed)
423
+ return i, result
424
+ except Exception as e:
425
+ return -i-1, e
426
+
427
+
428
+ def runBatch(batch_file:str, func:Callable, args:list[list[Any]]=None, rnd_seed:str=None, kwargs:dict|list[dict]=None, ignore_args:bool=False,
429
+ sessions:list[int]=None, parallel:bool|int=False, verbose:bool=True) -> tuple[list, ...]:
430
+ # run a routine on multiple sessions
431
+ #
432
+ # arguments:
433
+ # batch_file string, path to batch file
434
+ # func function to call for each session, must take session path as first arg
435
+ # args list of list = [[]], positional arguments for 'func', one per session or a single list for all
436
+ # rnd_seed str = None, if given, spawn numpy random seeds, passed to 'func' as keyword argument, necessary to use np.random with 'parallel'
437
+ # kwargs list of dict = [{}], keyword arguments for 'func', a dict per session or one for all
438
+ # ignore_args bool = False, if True, ignore extra arguments from batch file
439
+ # sessions (:) int = None, indices of session to process (default is all sessions from batch file)
440
+ # parallel bool | int = False, parallelize calls of `func` with concurrent.futures
441
+ # verbose: bool = True, log progress
442
+ #
443
+ # output:
444
+ # variable outputs matching func's signature
445
+
446
+ # parse batch file
447
+ sessions_list, extra_args = readBatchFile(batch_file)
448
+ if sessions is not None:
449
+ if not isinstance(sessions, collections.abc.Iterable):
450
+ sessions = [sessions]
451
+ sessions_list = [sessions_list[i] for i in sessions]
452
+ extra_args = [extra_args[i] for i in sessions]
453
+ n_sessions = len(sessions_list)
454
+ if ignore_args:
455
+ extra_args = [[]] * n_sessions
456
+
457
+ # validate optional arguments
458
+ if args is None:
459
+ args = [[]]
460
+ if len(args) == 1:
461
+ args = args * n_sessions
462
+ elif len(args) != n_sessions:
463
+ raise ValueError("Argument 'args' must contain one list per session")
464
+ if rnd_seed is not None:
465
+ seed_gen = np.random.SeedSequence()
466
+ seeds = seed_gen.spawn(n_sessions)
467
+ seeds = [{rnd_seed: s} for s in seeds]
468
+ else:
469
+ seeds = [{}] * n_sessions
470
+ if kwargs is None:
471
+ kwargs = [{}]
472
+ elif isinstance(kwargs,dict):
473
+ kwargs = [kwargs]
474
+ if len(kwargs) == 1:
475
+ kwargs = kwargs * n_sessions
476
+ elif len(kwargs) != n_sessions:
477
+ raise ValueError("Argument 'kwargs' must contain one dict per session")
478
+
479
+ verbose and print(f"\nStarting Batch, {datetime.datetime.now()} \n")
480
+ results = [None] * n_sessions
481
+ errors = {}
482
+ RED = "\033[31m"
483
+ RESET = "\033[0m"
484
+
485
+ # 1. Serial Batch
486
+ if not parallel:
487
+ for i, session in enumerate(sessions_list):
488
+ verbose and print(f'Batch progress: {session}, {i+1} out of {n_sessions}')
489
+ try:
490
+ results[i] = func(session,*args[i],*extra_args[i],**kwargs[i],**seeds[i])
491
+ except Exception as e:
492
+ # log error to console
493
+ errors[-i-1] = e
494
+ print(f'Error in session {session}\n{str(e)}\nTraceback:')
495
+ tb = e.__traceback__
496
+ while tb:
497
+ fcode = tb.tb_frame.f_code
498
+ print(f'{RED}{fcode.co_filename}, line {tb.tb_lineno}, in {fcode.co_name}{RESET}')
499
+ tb = tb.tb_next
500
+ verbose and print()
501
+
502
+ # 2. Parallel Batch
503
+ else:
504
+ # pack arguments into payloads
505
+ payloads = [(i,func,s,args[i],extra_args[i],kwargs[i],seeds[i]) for i, s in enumerate(sessions_list)]
506
+ if parallel is True:
507
+ parallel = None # to keep default max_workers
508
+ with concurrent.futures.ProcessPoolExecutor(max_workers=parallel) as ex:
509
+ for i, result in ex.map(_batchWorker, payloads):
510
+ if i >= 0:
511
+ results[i] = result
512
+ else:
513
+ errors[i] = result
514
+ # log errors to console
515
+ for i, e in errors.items():
516
+ print(f'Error in session {sessions_list[-i-1]} ({-i-1})\n{str(e)}\nTraceback:')
517
+ tb = e.__traceback__
518
+ while tb: # NOT WORKING FOR NOW
519
+ fcode = tb.tb_frame.f_code
520
+ print(f'{RED}{fcode.co_filename}, line {tb.tb_lineno}, in {fcode.co_name}{RESET}')
521
+ tb = tb.tb_next
522
+
523
+ # determine output number (expected to be constant across func calls)
524
+ n_outs = None
525
+ outputs = [None] * n_sessions
526
+ for result in results:
527
+ if result is not None:
528
+ if isinstance(result,tuple):
529
+ n_outs = len(result)
530
+ outputs = [outputs.copy() for _ in range(n_outs)]
531
+ else:
532
+ n_outs = 0 # 0 marks the single output case
533
+ break
534
+ # assign outputs
535
+ if n_outs is not None:
536
+ for i in range(n_sessions):
537
+ if results[i] is not None:
538
+ if n_outs == 0:
539
+ outputs[i] = results[i]
540
+ else:
541
+ for j in range(n_outs):
542
+ outputs[j][i] = results[i][j]
543
+
544
+ verbose and print(f'Batch completed with {len(errors)} errors, {datetime.datetime.now()}')
545
+
546
+ return tuple(outputs) if n_outs else outputs
@@ -0,0 +1,4 @@
1
+ ''' Custom exceptions '''
2
+
3
+ class FileFormatError(Exception):
4
+ pass