ONE-api 3.0b1__py3-none-any.whl → 3.0b4__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.
Files changed (33) hide show
  1. {ONE_api-3.0b1.dist-info → ONE_api-3.0b4.dist-info}/LICENSE +21 -21
  2. {ONE_api-3.0b1.dist-info → ONE_api-3.0b4.dist-info}/METADATA +115 -115
  3. ONE_api-3.0b4.dist-info/RECORD +37 -0
  4. one/__init__.py +2 -2
  5. one/alf/__init__.py +1 -1
  6. one/alf/cache.py +640 -653
  7. one/alf/exceptions.py +105 -105
  8. one/alf/io.py +876 -876
  9. one/alf/path.py +1450 -1450
  10. one/alf/spec.py +519 -504
  11. one/api.py +2949 -2973
  12. one/converters.py +850 -850
  13. one/params.py +414 -414
  14. one/registration.py +845 -845
  15. one/remote/__init__.py +1 -1
  16. one/remote/aws.py +313 -313
  17. one/remote/base.py +142 -142
  18. one/remote/globus.py +1254 -1254
  19. one/tests/fixtures/params/.caches +6 -6
  20. one/tests/fixtures/params/.test.alyx.internationalbrainlab.org +8 -8
  21. one/tests/fixtures/rest_responses/1f187d80fd59677b395fcdb18e68e4401bfa1cc9 +1 -1
  22. one/tests/fixtures/rest_responses/47893cf67c985e6361cdee009334963f49fb0746 +1 -1
  23. one/tests/fixtures/rest_responses/535d0e9a1e2c1efbdeba0d673b131e00361a2edb +1 -1
  24. one/tests/fixtures/rest_responses/6dc96f7e9bcc6ac2e7581489b9580a6cd3f28293 +1 -1
  25. one/tests/fixtures/rest_responses/db1731fb8df0208944ae85f76718430813a8bf50 +1 -1
  26. one/tests/fixtures/rest_responses/dcce48259bb929661f60a02a48563f70aa6185b3 +1 -1
  27. one/tests/fixtures/rest_responses/f530d6022f61cdc9e38cc66beb3cb71f3003c9a1 +1 -1
  28. one/tests/fixtures/test_dbs.json +14 -14
  29. one/util.py +524 -524
  30. one/webclient.py +1366 -1354
  31. ONE_api-3.0b1.dist-info/RECORD +0 -37
  32. {ONE_api-3.0b1.dist-info → ONE_api-3.0b4.dist-info}/WHEEL +0 -0
  33. {ONE_api-3.0b1.dist-info → ONE_api-3.0b4.dist-info}/top_level.txt +0 -0
one/alf/path.py CHANGED
@@ -1,1450 +1,1450 @@
1
- """Module for identifying and parsing ALF file names.
2
-
3
- An ALF file has the following components (those in brackets are optional):
4
- (_namespace_)object.attribute(_timescale)(.extra.parts).ext
5
-
6
- Note the following:
7
- Object attributes may not contain an underscore unless followed by 'times' or 'intervals'.
8
- A namespace must not contain extra underscores (i.e. `name_space` and `__namespace__` are not
9
- valid).
10
- ALF files must always have an extension.
11
-
12
- For more information, see the following documentation:
13
- https://int-brain-lab.github.io/ONE/alf_intro.html
14
-
15
-
16
- ALFPath differences
17
- -------------------
18
- ALFPath.iter_datasets returns full paths (close the pathlib.Path.iterdir), whereas
19
- alf.io.iter_datasets returns relative paths as POSIX strings (TODO).
20
-
21
- ALFPath.parse_* methods return a dict by default, whereas parse_* functions return
22
- tuples by default. Additionally, the parse_* functions raise ALFInvalid errors by
23
- default if the path can't be parsed. ALFPath.parse_* methods have no validation
24
- option.
25
-
26
- ALFPath properties return empty str instead of None if ALF part isn't present..
27
- """
28
- import os
29
- import pathlib
30
- from collections import OrderedDict
31
- from datetime import datetime
32
- from typing import Union, Optional, Iterable
33
- import logging
34
-
35
- from iblutil.util import Listable
36
-
37
- from .exceptions import ALFInvalid
38
- from . import spec
39
- from .spec import SESSION_SPEC, COLLECTION_SPEC, FILE_SPEC, REL_PATH_SPEC
40
-
41
- _logger = logging.getLogger(__name__)
42
- __all__ = [
43
- 'ALFPath', 'PureALFPath', 'WindowsALFPath', 'PosixALFPath',
44
- 'PureWindowsALFPath', 'PurePosixALFPath'
45
- ]
46
-
47
-
48
- def rel_path_parts(rel_path, as_dict=False, assert_valid=True):
49
- """Parse a relative path into the relevant parts.
50
-
51
- A relative path follows the pattern
52
- (collection/)(#revision#/)_namespace_object.attribute_timescale.extra.extension
53
-
54
- Parameters
55
- ----------
56
- rel_path : str, pathlib.Path
57
- A relative path string.
58
- as_dict : bool
59
- If true, an OrderedDict of parts are returned with the keys ('lab', 'subject', 'date',
60
- 'number'), otherwise a tuple of values are returned.
61
- assert_valid : bool
62
- If true an ALFInvalid is raised when the session cannot be parsed, otherwise an empty
63
- dict of tuple of Nones is returned.
64
-
65
- Returns
66
- -------
67
- OrderedDict, tuple
68
- A dict if as_dict is true, or a tuple of parsed values.
69
-
70
- """
71
- return _path_parts(rel_path, REL_PATH_SPEC, True, as_dict, assert_valid)
72
-
73
-
74
- def session_path_parts(session_path, as_dict=False, assert_valid=True):
75
- """Parse a session path into the relevant parts.
76
-
77
- Return keys:
78
- - lab
79
- - subject
80
- - date
81
- - number
82
-
83
- Parameters
84
- ----------
85
- session_path : str, pathlib.Path
86
- A session path string.
87
- as_dict : bool
88
- If true, an OrderedDict of parts are returned with the keys ('lab', 'subject', 'date',
89
- 'number'), otherwise a tuple of values are returned.
90
- assert_valid : bool
91
- If true an ALFInvalid is raised when the session cannot be parsed, otherwise an empty
92
- dict of tuple of Nones is returned.
93
-
94
- Returns
95
- -------
96
- OrderedDict, tuple
97
- A dict if as_dict is true, or a tuple of parsed values.
98
-
99
- Raises
100
- ------
101
- ALFInvalid
102
- Invalid ALF session path (assert_valid is True).
103
-
104
- """
105
- return _path_parts(session_path, SESSION_SPEC, False, as_dict, assert_valid)
106
-
107
-
108
- def _path_parts(path, spec_str, match=True, as_dict=False, assert_valid=True):
109
- """Given a ALF and a spec string, parse into parts.
110
-
111
- Parameters
112
- ----------
113
- path : str, pathlib.Path
114
- An ALF path or dataset.
115
- match : bool
116
- If True, string must match exactly, otherwise search for expression within path.
117
- as_dict : bool
118
- When true a dict of matches is returned.
119
- assert_valid : bool
120
- When true an exception is raised when the filename cannot be parsed.
121
-
122
- Returns
123
- -------
124
- OrderedDict, tuple
125
- A dict if as_dict is true, or a tuple of parsed values.
126
-
127
- Raises
128
- ------
129
- ALFInvalid
130
- Invalid ALF path (assert_valid is True).
131
-
132
- """
133
- if hasattr(path, 'as_posix'):
134
- path = path.as_posix()
135
- pattern = spec.regex(spec_str)
136
- empty = OrderedDict.fromkeys(pattern.groupindex.keys())
137
- parsed = (pattern.match if match else pattern.search)(path)
138
- if parsed: # py3.8
139
- parsed_dict = parsed.groupdict()
140
- return OrderedDict(parsed_dict) if as_dict else tuple(parsed_dict.values())
141
- elif assert_valid:
142
- raise ALFInvalid(path)
143
- else:
144
- return empty if as_dict else tuple(empty.values())
145
-
146
-
147
- def filename_parts(filename, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
148
- """Return the parsed elements of a given ALF filename.
149
-
150
- Parameters
151
- ----------
152
- filename : str
153
- The name of the file.
154
- as_dict : bool
155
- When true a dict of matches is returned.
156
- assert_valid : bool
157
- When true an exception is raised when the filename cannot be parsed.
158
-
159
- Returns
160
- -------
161
- namespace : str
162
- The _namespace_ or None if not present.
163
- object : str
164
- ALF object.
165
- attribute : str
166
- The ALF attribute.
167
- timescale : str
168
- The ALF _timescale or None if not present.
169
- extra : str
170
- Any extra parts to the filename, or None if not present.
171
- extension : str
172
- The file extension.
173
-
174
- Examples
175
- --------
176
- >>> filename_parts('_namespace_obj.times_timescale.extra.foo.ext')
177
- ('namespace', 'obj', 'times', 'timescale', 'extra.foo', 'ext')
178
- >>> filename_parts('spikes.clusters.npy', as_dict=True)
179
- {'namespace': None,
180
- 'object': 'spikes',
181
- 'attribute': 'clusters',
182
- 'timescale': None,
183
- 'extra': None,
184
- 'extension': 'npy'}
185
- >>> filename_parts('spikes.times_ephysClock.npy')
186
- (None, 'spikes', 'times', 'ephysClock', None, 'npy')
187
- >>> filename_parts('_iblmic_audioSpectrogram.frequencies.npy')
188
- ('iblmic', 'audioSpectrogram', 'frequencies', None, None, 'npy')
189
- >>> filename_parts('_spikeglx_ephysData_g0_t0.imec.wiring.json')
190
- ('spikeglx', 'ephysData_g0_t0', 'imec', None, 'wiring', 'json')
191
- >>> filename_parts('_spikeglx_ephysData_g0_t0.imec0.lf.bin')
192
- ('spikeglx', 'ephysData_g0_t0', 'imec0', None, 'lf', 'bin')
193
- >>> filename_parts('_ibl_trials.goCue_times_bpod.csv')
194
- ('ibl', 'trials', 'goCue_times', 'bpod', None, 'csv')
195
-
196
- Raises
197
- ------
198
- ALFInvalid
199
- Invalid ALF dataset (assert_valid is True).
200
-
201
- """
202
- return _path_parts(filename, FILE_SPEC, True, as_dict, assert_valid)
203
-
204
-
205
- def full_path_parts(path, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
206
- """Parse all filename and folder parts.
207
-
208
- Parameters
209
- ----------
210
- path : str, pathlib.Path.
211
- The ALF path
212
- as_dict : bool
213
- When true a dict of matches is returned.
214
- assert_valid : bool
215
- When true an exception is raised when the filename cannot be parsed.
216
-
217
- Returns
218
- -------
219
- OrderedDict, tuple
220
- A dict if as_dict is true, or a tuple of parsed values.
221
-
222
- Examples
223
- --------
224
- >>> full_path_parts(
225
- ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
226
- ... '_namespace_obj.times_timescale.extra.foo.ext')
227
- ('lab', 'subject', '2020-01-01', '001', 'collection', 'revision',
228
- 'namespace', 'obj', 'times','timescale', 'extra.foo', 'ext')
229
- >>> full_path_parts('spikes.clusters.npy', as_dict=True)
230
- {'lab': None,
231
- 'subject': None,
232
- 'date': None,
233
- 'number': None,
234
- 'collection': None,
235
- 'revision': None,
236
- 'namespace': None,
237
- 'object': 'spikes',
238
- 'attribute': 'clusters',
239
- 'timescale': None,
240
- 'extra': None,
241
- 'extension': 'npy'}
242
-
243
- Raises
244
- ------
245
- ALFInvalid
246
- Invalid ALF path (assert_valid is True).
247
-
248
- """
249
- path = pathlib.Path(path)
250
- # NB We try to determine whether we have a folder or filename path. Filenames contain at
251
- # least two periods, however it is currently permitted to have any number of periods in a
252
- # collection, making the ALF path ambiguous.
253
- if sum(x == '.' for x in path.name) < 2: # folder only
254
- folders = folder_parts(path, as_dict, assert_valid)
255
- if assert_valid:
256
- # Edge case: ensure is indeed folder by checking that name is in parts
257
- invalid_file = path.name not in (folders.values() if as_dict else folders)
258
- is_revision = f'#{folders["revision"] if as_dict else folders[-1]}#' == path.name
259
- if not is_revision and invalid_file:
260
- raise ALFInvalid(path)
261
- dataset = filename_parts('', as_dict, assert_valid=False)
262
- elif '/' not in path.as_posix(): # filename only
263
- folders = folder_parts('', as_dict, assert_valid=False)
264
- dataset = filename_parts(path.name, as_dict, assert_valid)
265
- else: # full filepath
266
- folders = folder_parts(path.parent, as_dict, assert_valid)
267
- dataset = filename_parts(path.name, as_dict, assert_valid)
268
- if as_dict:
269
- return OrderedDict(**folders, **dataset)
270
- else:
271
- return folders + dataset
272
-
273
-
274
- def folder_parts(folder_path, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
275
- """Parse all folder parts, including session, collection and revision.
276
-
277
- Parameters
278
- ----------
279
- folder_path : str, pathlib.Path
280
- The ALF folder path.
281
- as_dict : bool
282
- When true a dict of matches is returned.
283
- assert_valid : bool
284
- When true an exception is raised when the filename cannot be parsed.
285
-
286
- Returns
287
- -------
288
- OrderedDict, tuple
289
- A dict if as_dict is true, or a tuple of parsed values.
290
-
291
- Examples
292
- --------
293
- >>> folder_parts('lab/Subjects/subject/2020-01-01/001/collection/#revision#')
294
- ('lab', 'subject', '2020-01-01', '001', 'collection', 'revision')
295
- >>> folder_parts(Path('lab/Subjects/subject/2020-01-01/001'), as_dict=True)
296
- {'lab': 'lab',
297
- 'subject': 'subject',
298
- 'date': '2020-01-01',
299
- 'number': '001',
300
- 'collection': None,
301
- 'revision': None}
302
-
303
- Raises
304
- ------
305
- ALFInvalid
306
- Invalid ALF path (assert_valid is True).
307
-
308
- """
309
- if hasattr(folder_path, 'as_posix'):
310
- folder_path = folder_path.as_posix()
311
- if folder_path and folder_path[-1] != '/': # Slash required for regex pattern
312
- folder_path = folder_path + '/'
313
- spec_str = f'{SESSION_SPEC}/{COLLECTION_SPEC}'
314
- return _path_parts(folder_path, spec_str, False, as_dict, assert_valid)
315
-
316
-
317
- def _isdatetime(s: str) -> bool:
318
- """Returns True if input is valid ISO date string."""
319
- try:
320
- datetime.strptime(s, '%Y-%m-%d')
321
- return True
322
- except ValueError:
323
- return False
324
-
325
-
326
- def get_session_path(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]:
327
- """Return full session path from any file path if the date/number pattern is found.
328
-
329
- Returns
330
- -------
331
- pathlib.Path
332
- The session path part of the input path or None if path invalid.
333
-
334
- Examples
335
- --------
336
- >>> get_session_path('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
337
- Path('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
338
-
339
- >>> get_session_path('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy')
340
- Path('C:/Data/subject/2020-01-01/1')
341
-
342
- """
343
- if path is None:
344
- return
345
- if isinstance(path, str):
346
- path = pathlib.Path(path)
347
- for i, p in enumerate(path.parts):
348
- if p.isdigit() and _isdatetime(path.parts[i - 1]):
349
- return path.__class__().joinpath(*path.parts[:i + 1])
350
-
351
-
352
- def get_alf_path(path: Union[str, pathlib.Path]) -> str:
353
- """Returns the ALF part of a path or filename.
354
-
355
- Attempts to return the first valid part of the path, first searching for a session path,
356
- then relative path (collection/revision/filename), then just the filename. If all invalid,
357
- None is returned.
358
-
359
- Parameters
360
- ----------
361
- path : str, pathlib.Path
362
- A path to parse.
363
-
364
- Returns
365
- -------
366
- str
367
- A string containing the full ALF path, session path, relative path or filename.
368
-
369
- Examples
370
- --------
371
- >>> get_alf_path('etc/etc/lab/Subjects/subj/2021-01-21/001')
372
- 'lab/Subjects/subj/2021-01-21/001/collection/file.attr.ext'
373
-
374
- >>> get_alf_path('etc/etc/subj/2021-01-21/001/collection/file.attr.ext')
375
- 'subj/2021-01-21/001/collection/file.attr.ext'
376
-
377
- >>> get_alf_path('collection/file.attr.ext')
378
- 'collection/file.attr.ext'
379
-
380
- """
381
- if not isinstance(path, str):
382
- path = pathlib.Path(path).as_posix()
383
- path = path.strip('/')
384
-
385
- # Check if session path
386
- if match_session := spec.regex(SESSION_SPEC).search(path):
387
- return path[match_session.start():]
388
-
389
- # Check if filename / relative path (i.e. collection + filename)
390
- parts = path.rsplit('/', 1)
391
- if spec.regex(FILE_SPEC).match(parts[-1]):
392
- return path if spec.regex(f'{COLLECTION_SPEC}{FILE_SPEC}').match(path) else parts[-1]
393
-
394
-
395
- def add_uuid_string(file_path, uuid):
396
- """Add a UUID to the filename of an ALF path.
397
-
398
- Adds a UUID to an ALF filename as an extra part, e.g.
399
- 'obj.attr.ext' -> 'obj.attr.a976e418-c8b8-4d24-be47-d05120b18341.ext'.
400
-
401
- Parameters
402
- ----------
403
- file_path : str, pathlib.Path, pathlib.PurePath
404
- An ALF path to add the UUID to.
405
- uuid : str, uuid.UUID
406
- The UUID to add.
407
-
408
- Returns
409
- -------
410
- pathlib.Path, pathlib.PurePath
411
- A new Path or PurePath object with a UUID in the filename.
412
-
413
- Examples
414
- --------
415
- >>> add_uuid_string('/path/to/trials.intervals.npy', 'a976e418-c8b8-4d24-be47-d05120b18341')
416
- Path('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
417
-
418
- Raises
419
- ------
420
- ValueError
421
- `uuid` must be a valid hyphen-separated hexadecimal UUID.
422
-
423
- See Also
424
- --------
425
- one.alf.path.ALFPath.with_uuid
426
- one.alf.path.remove_uuid_string
427
- one.alf.spec.is_uuid
428
-
429
- """
430
- if isinstance(uuid, str) and not spec.is_uuid_string(uuid):
431
- raise ValueError('Should provide a valid UUID v4')
432
- uuid = str(uuid)
433
- # NB: Only instantiate as Path if not already a Path, otherwise we risk changing the class
434
- if isinstance(file_path, str):
435
- file_path = pathlib.Path(file_path)
436
- name_parts = file_path.stem.split('.')
437
- if spec.is_uuid(name_parts[-1]):
438
- *name_parts, old_uuid = name_parts
439
- if old_uuid == uuid:
440
- _logger.warning(f'UUID already found in file name: {file_path.name}: IGNORE')
441
- return file_path
442
- else:
443
- _logger.debug('Replacing %s with %s in %s', old_uuid, uuid, file_path)
444
- return file_path.parent.joinpath(f"{'.'.join(name_parts)}.{uuid}{file_path.suffix}")
445
-
446
-
447
- def remove_uuid_string(file_path):
448
- """Remove UUID from a filename of an ALF path.
449
-
450
- Parameters
451
- ----------
452
- file_path : str, pathlib.Path, pathlib.PurePath
453
- An ALF path to add the UUID to.
454
-
455
- Returns
456
- -------
457
- ALFPath, PureALFPath, pathlib.Path, pathlib.PurePath
458
- A new Path or PurePath object without a UUID in the filename.
459
-
460
- Examples
461
- --------
462
- >>> add_uuid_string('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
463
- Path('/path/to/trials.intervals.npy')
464
-
465
- >>> add_uuid_string('/path/to/trials.intervals.npy')
466
- Path('/path/to/trials.intervals.npy')
467
-
468
- See Also
469
- --------
470
- one.alf.path.ALFPath.without_uuid
471
- one.alf.path.add_uuid_string
472
-
473
- """
474
- if isinstance(file_path, str):
475
- file_path = pathlib.Path(file_path)
476
- name_parts = file_path.stem.split('.')
477
-
478
- if spec.is_uuid_string(name_parts[-1]):
479
- file_path = file_path.with_name('.'.join(name_parts[:-1]) + file_path.suffix)
480
- return file_path
481
-
482
-
483
- def padded_sequence(file_path):
484
- """Ensures a file path contains a zero-padded experiment sequence folder.
485
-
486
- Parameters
487
- ----------
488
- file_path : str, pathlib.Path, pathlib.PurePath
489
- A session or file path to convert.
490
-
491
- Returns
492
- -------
493
- ALFPath, PureALFPath
494
- The same path but with the experiment sequence folder zero-padded. If a PurePath was
495
- passed, a PurePath will be returned, otherwise a Path object is returned.
496
-
497
- Examples
498
- --------
499
- >>> file_path = '/iblrigdata/subject/2023-01-01/1/_ibl_experiment.description.yaml'
500
- >>> padded_sequence(file_path)
501
- pathlib.Path('/iblrigdata/subject/2023-01-01/001/_ibl_experiment.description.yaml')
502
-
503
- Supports folders and will not affect already padded paths
504
-
505
- >>> session_path = pathlib.PurePosixPath('subject/2023-01-01/001')
506
- >>> padded_sequence(file_path)
507
- pathlib.PurePosixPath('subject/2023-01-01/001')
508
-
509
- """
510
- file_path = ensure_alf_path(file_path)
511
- if (session_path := get_session_path(file_path)) is None:
512
- raise ValueError('path must include a valid ALF session path, e.g. subject/YYYY-MM-DD/N')
513
- idx = len(file_path.parts) - len(session_path.parts)
514
- sequence = str(int(session_path.parts[-1])).zfill(3) # zero-pad if necessary
515
- return file_path.parents[idx].joinpath(sequence, file_path.relative_to(session_path))
516
-
517
-
518
- def without_revision(file_path):
519
- """Return file path without a revision folder.
520
-
521
- Parameters
522
- ----------
523
- file_path : str, pathlib.Path
524
- A valid ALF dataset path.
525
-
526
- Returns
527
- -------
528
- pathlib.Path
529
- The input file path without a revision folder.
530
-
531
- Examples
532
- --------
533
- >>> without_revision('/lab/Subjects/subject/2023-01-01/001/collection/#revision#/obj.attr.ext')
534
- Path('/lab/Subjects/subject/2023-01-01/001/collection/obj.attr.ext')
535
-
536
- """
537
- if isinstance(file_path, str):
538
- file_path = pathlib.Path(file_path)
539
- *_, collection, revision = folder_parts(file_path.parent)
540
- return get_session_path(file_path).joinpath(*filter(None, (collection, file_path.name)))
541
-
542
-
543
- class PureALFPath(pathlib.PurePath): # py3.12 supports direct subclassing
544
- """Base class for manipulating Alyx file (ALF) paths without I/O.
545
-
546
- Similar to a pathlib PurePath object but with methods for validating, parsing, and replacing
547
- ALF path parts.
548
-
549
- Parameters
550
- ----------
551
- args : str, pathlib.PurePath
552
- One or more pathlike objects to combine into an ALF path object.
553
-
554
- """
555
-
556
- def __new__(cls, *args):
557
- """Construct a ALFPurePath from one or several strings and or existing PurePath objects.
558
-
559
- The strings and path objects are combined so as to yield a canonicalized path, which is
560
- incorporated into the new PurePath object.
561
- """
562
- if cls is PureALFPath:
563
- cls = PureWindowsALFPath if os.name == 'nt' else PurePosixALFPath
564
- return super().__new__(cls, *args)
565
-
566
- def is_dataset(self):
567
- """Determine if path is an ALF dataset, rather than a folder.
568
-
569
- Returns
570
- -------
571
- bool
572
- True if filename is ALF dataset.
573
-
574
- """
575
- return spec.is_valid(self.name)
576
-
577
- def is_valid_alf(path) -> bool:
578
- """Check if path is a valid ALF path.
579
-
580
- This returns true if the input path matches any part of the ALF path specification.
581
- This method can be used as a static method with any pathlike input, or as an instance
582
- method. This will validate both directory paths and file paths.
583
-
584
- Parameters
585
- ----------
586
- path : str, pathlib.PurePath
587
- A path to check the validity of.
588
-
589
- Returns
590
- -------
591
- bool
592
- True if the path is recognized as a valid ALF path.
593
-
594
- Examples
595
- --------
596
- >>> ALFPath('/home/foo/2020-01-01/001').is_valid_alf()
597
- True
598
-
599
- >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_valid_alf()
600
- True
601
-
602
- >>> ALFPath.is_valid_alf('_ibl_wheel.timestamps.npy')
603
- True
604
-
605
- >>> ALFPath.is_valid_alf('foo.bar')
606
- False
607
-
608
- See Also
609
- --------
610
- PureALFPath.is_dataset - Test whether file name is valid as well as directory path.
611
- full_path_parts - Validates path and returns the parsed ALF path parts.
612
-
613
- """
614
- try:
615
- return any(full_path_parts(path))
616
- except ALFInvalid:
617
- return False
618
-
619
- def is_session_path(path) -> bool:
620
- """Check if path is a valid ALF session path.
621
-
622
- This returns true if the input path matches the ALF session path specification.
623
- This method can be used as a static method with any pathlike input, or as an instance
624
- method.
625
-
626
- Parameters
627
- ----------
628
- path : str, pathlib.PurePath
629
- A session path to check the validity of.
630
-
631
- Returns
632
- -------
633
- bool
634
- True if the path is recognized as a valid ALF session path.
635
-
636
- Examples
637
- --------
638
- >>> ALFPath('/home/foo/2020-01-01/001').is_session_path()
639
- True
640
-
641
- >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_session_path()
642
- False
643
-
644
- >>> ALFPath.is_session_path('_ibl_wheel.timestamps.npy')
645
- False
646
-
647
- >>> ALFPath.is_valid_alf('lab/Subjects/foo/2020-01-01/001')
648
- True
649
-
650
- See Also
651
- --------
652
- PureALFPath.is_valid_alf - Test whether path is generally valid a valid ALF path.
653
- PureALFPath.session_path_parts - Returns parsed session path parts as tuple of str.
654
-
655
- """
656
- return spec.is_session_path(path)
657
-
658
- def session_path(self):
659
- """Extract the full session path.
660
-
661
- Returns the session path from the filepath if the date/number pattern is found,
662
- including the root directory.
663
-
664
- Returns
665
- -------
666
- PureALFPath
667
- The session path part of the input path or None if path invalid.
668
-
669
- Examples
670
- --------
671
- >>> ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001').session_path()
672
- ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
673
-
674
- >>> ALFPath('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy').session_path()
675
- ALFPath('C:/Data/subject/2020-01-01/1')
676
-
677
- """
678
- return get_session_path(self)
679
-
680
- def session_path_short(self, include_lab=False) -> str:
681
- """Return only the ALF session path as a posix str.
682
-
683
- Params
684
- ------
685
- include_lab : bool
686
- If true, the lab/subject/date/number is returned, otherwise the lab part is dropped.
687
-
688
- Returns
689
- -------
690
- str
691
- The session path part of the input path or None if path invalid.
692
-
693
- Examples
694
- --------
695
- >>> ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001').session_path_short()
696
- 'subject/2020-01-01/001'
697
-
698
- >>> alfpath = ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
699
- >>> alfpath.session_path_short(include_lab=True)
700
- 'lab/subject/2020-01-01/001'
701
-
702
- >>> ALFPath('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy').session_path_short()
703
- 'subject/2020-01-01/1'
704
-
705
- """
706
- idx = 0 if include_lab else 1
707
- if any(parts := self.session_parts[idx:]):
708
- return '/'.join(parts)
709
-
710
- def without_lab(self) -> 'PureALFPath':
711
- """Return path without the <lab>/Subjects/ part.
712
-
713
- If the <lab>/Subjects pattern is not found, the same path is returned.
714
-
715
- Returns
716
- -------
717
- PureALFPath
718
- The same path without the <lab>/Subjects part.
719
-
720
- """
721
- p = self.as_posix()
722
- if m := spec.regex('{lab}/Subjects/').search(p):
723
- return self.__class__(p[:m.start()], p[m.end():])
724
- else:
725
- return self
726
-
727
- def relative_to_lab(self) -> 'PureALFPath':
728
- """Return path relative to <lab>/Subjects/ part.
729
-
730
- Returns
731
- -------
732
- PureALFPath
733
- The same path, relative to the <lab>/Subjects/ part.
734
-
735
- Raises
736
- ------
737
- ValueError
738
- The path doesn't contain a <lab>/Subjects/ pattern.
739
-
740
- """
741
- p = self.as_posix()
742
- if m := spec.regex('{lab}/Subjects/').search(p):
743
- return self.__class__(p[m.end():])
744
- else:
745
- raise ValueError(f'{self} does not contain <lab>/Subjects pattern')
746
-
747
- def relative_to_session(self):
748
- """Return path relative to session part.
749
-
750
- Returns
751
- -------
752
- PureALFPath
753
- The same path, relative to the <lab>/Subjects/<subject>/<date>/<number> part.
754
-
755
- Raises
756
- ------
757
- ValueError
758
- The path doesn't contain a <lab>/Subjects/ pattern.
759
-
760
- """
761
- if (session_path := self.session_path()):
762
- return self.relative_to(session_path)
763
- else:
764
- raise ValueError(f'{self} does not contain session path pattern')
765
-
766
- def parse_alf_path(self, as_dict=True):
767
- """Parse all filename and folder parts.
768
-
769
- Parameters
770
- ----------
771
- as_dict : bool
772
- When true a dict of matches is returned.
773
-
774
- Returns
775
- -------
776
- OrderedDict, tuple
777
- A dict if as_dict is true, or a tuple of parsed values.
778
-
779
- Examples
780
- --------
781
- >>> alfpath = PureALFPath(
782
- ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
783
- ... '_namespace_obj.times_timescale.extra.foo.ext')
784
- >>> alfpath.parse_alf_path()
785
- {'lab': 'lab',
786
- 'subject': 'subject',
787
- 'date': '2020-01-01',
788
- 'number': '001',
789
- 'collection': 'collection',
790
- 'revision': 'revision',
791
- 'namespace': 'namespace',
792
- 'object': 'obj',
793
- 'attribute': 'times',
794
- 'timescale': 'timescale',
795
- 'extra': 'extra.foo',
796
- 'extension': 'ext'}
797
-
798
- >>> PureALFPath('_namespace_obj.times_timescale.extra.foo.ext').parse_alf_path()
799
- (None, None, None, None, None, None, 'namespace',
800
- 'obj', 'times','timescale', 'extra.foo', 'ext')
801
-
802
- """
803
- return full_path_parts(self, assert_valid=False, as_dict=as_dict)
804
-
805
- def parse_alf_name(self, as_dict=True):
806
- """Return the parsed elements of a given ALF filename.
807
-
808
- Parameters
809
- ----------
810
- as_dict : bool
811
- When true a dict of matches is returned.
812
-
813
- Returns
814
- -------
815
- namespace : str
816
- The _namespace_ or None if not present.
817
- object : str
818
- ALF object.
819
- attribute : str
820
- The ALF attribute.
821
- timescale : str
822
- The ALF _timescale or None if not present.
823
- extra : str
824
- Any extra parts to the filename, or None if not present.
825
- extension : str
826
- The file extension.
827
-
828
- Examples
829
- --------
830
- >>> alfpath = PureALFPath(
831
- ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
832
- ... '_namespace_obj.times_timescale.extra.foo.ext')
833
- >>> alfpath.parse_alf_name()
834
- {'namespace': 'namespace',
835
- 'object': 'obj',
836
- 'attribute': 'times',
837
- 'timescale': 'timescale',
838
- 'extra': 'extra.foo',
839
- 'extension': 'ext'}
840
-
841
- >>> PureALFPath('spikes.clusters.npy', as_dict=False)
842
- (None, 'spikes', 'clusters', None, None, npy)
843
-
844
- """
845
- return filename_parts(self.name, assert_valid=False, as_dict=as_dict)
846
-
847
- @property
848
- def dataset_name_parts(self):
849
- """tuple of str: the dataset name parts, with empty strings for missing parts."""
850
- return tuple(p or '' for p in self.parse_alf_name(as_dict=False))
851
-
852
- @property
853
- def session_parts(self):
854
- """tuple of str: the session path parts, with empty strings for missing parts."""
855
- return tuple(p or '' for p in session_path_parts(self, assert_valid=False))
856
-
857
- @property
858
- def alf_parts(self):
859
- """tuple of str: the full ALF path parts, with empty strings for missing parts."""
860
- return tuple(p or '' for p in self.parse_alf_path(as_dict=False))
861
-
862
- @property
863
- def namespace(self):
864
- """str: The namespace part of the ALF name, or and empty str if not present."""
865
- return self.dataset_name_parts[0]
866
-
867
- @property
868
- def object(self):
869
- """str: The object part of the ALF name, or and empty str if not present."""
870
- return self.dataset_name_parts[1]
871
-
872
- @property
873
- def attribute(self):
874
- """str: The attribute part of the ALF name, or and empty str if not present."""
875
- return self.dataset_name_parts[2]
876
-
877
- @property
878
- def timescale(self):
879
- """str: The timescale part of the ALF name, or and empty str if not present."""
880
- return self.dataset_name_parts[3]
881
-
882
- @property
883
- def extra(self):
884
- """str: The extra part of the ALF name, or and empty str if not present."""
885
- return self.dataset_name_parts[4]
886
-
887
- def with_object(self, obj):
888
- """Return a new path with the ALF object changed.
889
-
890
- Parameters
891
- ----------
892
- obj : str
893
- An ALF object name part to use.
894
-
895
- Returns
896
- -------
897
- PureALFPath
898
- The same file path but with the object part replaced with the input.
899
-
900
- Raises
901
- ------
902
- ALFInvalid
903
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
904
- contains invalid characters).
905
-
906
- """
907
- if not self.is_dataset():
908
- raise ALFInvalid(str(self))
909
- ns_obj, rest = self.name.split('.', 1)
910
- ns, _ = spec.regex(FILE_SPEC.split('\\.')[0]).match(ns_obj).groups()
911
- ns = f'_{ns}_' if ns else ''
912
- return self.with_name(f'{ns}{obj}.{rest}')
913
-
914
- def with_namespace(self, ns):
915
- """Return a new path with the ALF namespace added or changed.
916
-
917
- Parameters
918
- ----------
919
- namespace : str
920
- An ALF namespace part to use.
921
-
922
- Returns
923
- -------
924
- PureALFPath
925
- The same file path but with the namespace part added/replaced with the input.
926
-
927
- Raises
928
- ------
929
- ALFInvalid
930
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
931
- contains invalid characters).
932
-
933
- """
934
- if not self.is_dataset():
935
- raise ALFInvalid(self)
936
- ns_obj, rest = self.name.split('.', 1)
937
- _, obj = spec.regex(FILE_SPEC.split('\\.')[0]).match(ns_obj).groups()
938
- ns = f'_{ns}_' if ns else ''
939
- return self.with_name(f'{ns}{obj}.{rest}')
940
-
941
- def with_attribute(self, attr):
942
- """Return a new path with the ALF attribute changed.
943
-
944
- Parameters
945
- ----------
946
- attribute : str
947
- An ALF attribute part to use.
948
-
949
- Returns
950
- -------
951
- PureALFPath
952
- The same file path but with the attribute part replaced with the input.
953
-
954
- Raises
955
- ------
956
- ALFInvalid
957
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
958
- contains invalid characters).
959
-
960
- """
961
- if not self.is_dataset():
962
- raise ALFInvalid(self)
963
- ns_obj, attr_ts, rest = self.name.split('.', 2)
964
- _, ts = spec.regex('{attribute}(?:_{timescale})?').match(attr_ts).groups()
965
- ts = f'_{ts}' if ts else ''
966
- return self.with_name(f'{ns_obj}.{attr}{ts}.{rest}')
967
-
968
- def with_timescale(self, timescale):
969
- """Return a new path with the ALF timescale added or changed.
970
-
971
- Parameters
972
- ----------
973
- timescale : str
974
- An ALF timescale part to use.
975
-
976
- Returns
977
- -------
978
- PureALFPath
979
- The same file path but with the timescale part added/replaced with the input.
980
-
981
- Raises
982
- ------
983
- ALFInvalid
984
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
985
- contains invalid characters).
986
-
987
- """
988
- if not self.is_dataset():
989
- raise ALFInvalid(self)
990
- ns_obj, attr_ts, rest = self.name.split('.', 2)
991
- attr, _ = spec.regex('{attribute}(?:_{timescale})?').match(attr_ts).groups()
992
- ts = f'_{timescale}' if timescale else ''
993
- return self.with_name(f'{ns_obj}.{attr}{ts}.{rest}')
994
-
995
- def with_extra(self, extra, append=False):
996
- """Return a new path with extra ALF parts added or changed.
997
-
998
- Parameters
999
- ----------
1000
- extra : str, list of str
1001
- Extra ALF parts to add/replace.
1002
- append : bool
1003
- When false (default) any existing extra parts are replaced instead of added to.
1004
-
1005
- Returns
1006
- -------
1007
- PureALFPath
1008
- The same file path but with the extra part(s) replaced or appended to with the input.
1009
-
1010
- Raises
1011
- ------
1012
- ALFInvalid
1013
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
1014
- contains invalid characters).
1015
-
1016
- """
1017
- if not self.is_dataset():
1018
- raise ALFInvalid(self)
1019
- parts = self.stem.split('.', 2)
1020
- if isinstance(extra, str):
1021
- extra = extra.strip('.').split('.')
1022
- if (prev := parts.pop() if len(parts) > 2 else None) and append:
1023
- extra = (prev, *extra)
1024
- obj_attr = '.'.join(parts)
1025
- if extra := '.'.join(filter(None, extra)):
1026
- return self.with_stem(f'{obj_attr}.{extra}')
1027
- else:
1028
- return self.with_stem(obj_attr)
1029
-
1030
- def with_extension(self, ext):
1031
- """Return a new path with the ALF extension (suffix) changed.
1032
-
1033
- Note that unlike PurePath's `with_suffix` method, this asserts that the filename is a valid
1034
- ALF dataset and the `ext` argument should be without the period.
1035
-
1036
- Parameters
1037
- ----------
1038
- ext : str
1039
- An ALF extension part to use (sans period).
1040
-
1041
- Returns
1042
- -------
1043
- PureALFPath
1044
- The same file path but with the extension part replaced with the input.
1045
-
1046
- Raises
1047
- ------
1048
- ALFInvalid
1049
- The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
1050
- contains invalid characters).
1051
-
1052
- """
1053
- if not self.is_dataset():
1054
- raise ALFInvalid(str(self))
1055
- return self.with_suffix(f'.{ext}')
1056
-
1057
- def with_padded_sequence(path):
1058
- """Ensures a file path contains a zero-padded experiment sequence folder.
1059
-
1060
- Parameters
1061
- ----------
1062
- path : str pathlib.PurePath
1063
- A session or file path to convert.
1064
-
1065
- Returns
1066
- -------
1067
- ALFPath, PureALFPath
1068
- The same path but with the experiment sequence folder zero-padded. If a PurePath was
1069
- passed, a PurePath will be returned, otherwise a Path object is returned.
1070
-
1071
- Examples
1072
- --------
1073
- Supports calling as static function
1074
-
1075
- >>> file_path = '/iblrigdata/subject/2023-01-01/1/_ibl_experiment.description.yaml'
1076
- >>> ALFPath.with_padded_sequence(file_path)
1077
- ALFPath('/iblrigdata/subject/2023-01-01/001/_ibl_experiment.description.yaml')
1078
-
1079
- Supports folders and will not affect already padded paths
1080
-
1081
- >>> ALFPath('subject/2023-01-01/001').with_padded_sequence(file_path)
1082
- ALFPath('subject/2023-01-01/001')
1083
-
1084
- """
1085
- return padded_sequence(path)
1086
-
1087
- def with_revision(self, revision):
1088
- """Return a new path with the ALF revision part added/changed.
1089
-
1090
- Parameters
1091
- ----------
1092
- revision : str
1093
- An ALF revision part to use (NB: do not include the pound sign '#').
1094
-
1095
- Returns
1096
- -------
1097
- PureALFPath
1098
- The same file path but with the revision part added or replaced with the input.
1099
-
1100
- Examples
1101
- --------
1102
- If not in the ALF path, one will be added
1103
-
1104
- >>> ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext').with_revision('revision')
1105
- ALFPath('/subject/2023-01-01/1/alf/#xxx#/obj.attr.ext')
1106
-
1107
- If a revision is already in the ALF path it will be replaced
1108
-
1109
- >>> ALFPath('/subject/2023-01-01/1/alf/#revision#/obj.attr.ext').with_revision('xxx')
1110
- ALFPath('/subject/2023-01-01/1/alf/#xxx#/obj.attr.ext')
1111
-
1112
- Raises
1113
- ------
1114
- ALFInvalid
1115
- The ALF path is not valid or is relative to the session path. The path must include
1116
- the session parts otherwise the path is too ambiguous to determine validity.
1117
- ALFInvalid
1118
- The revision provided does not match the ALF specification pattern.
1119
-
1120
- See Also
1121
- --------
1122
- PureALFPath.without_revision
1123
-
1124
- """
1125
- # Validate the revision input
1126
- revision, = _path_parts(revision, '^{revision}$', match=True, assert_valid=True)
1127
- if PureALFPath.is_dataset(self):
1128
- return self.without_revision().parent / f'#{revision}#' / self.name
1129
- else:
1130
- return self.without_revision() / f'#{revision}#'
1131
-
1132
- def without_revision(self):
1133
- """Return a new path with the ALF revision part removed.
1134
-
1135
- Returns
1136
- -------
1137
- PureALFPath
1138
- The same file path but with the revision part removed.
1139
-
1140
- Examples
1141
- --------
1142
- If not in the ALF path, no change occurs
1143
-
1144
- >>> ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext').with_revision('revision')
1145
- ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext')
1146
-
1147
- If a revision is in the ALF path it will be removed
1148
-
1149
- >>> ALFPath('/subject/2023-01-01/1/alf/#revision#/obj.attr.ext').without_revision()
1150
- ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext')
1151
-
1152
- Raises
1153
- ------
1154
- ALFInvalid
1155
- The ALF path is not valid or is relative to the session path. The path must include
1156
- the session parts otherwise the path is too ambiguous to determine validity.
1157
-
1158
- See Also
1159
- --------
1160
- PureALFPath.with_revision
1161
-
1162
- """
1163
- if PureALFPath.is_dataset(self):
1164
- # Is a file path (rather than folder path)
1165
- return without_revision(self)
1166
- if not self.is_valid_alf():
1167
- raise ALFInvalid(f'{self} not a valid ALF path or is relative to session')
1168
- elif spec.regex('^#{revision}#$').match(self.name):
1169
- # Includes revision
1170
- return self.parent
1171
- else:
1172
- # Does not include revision
1173
- return self
1174
-
1175
- def with_uuid(self, uuid):
1176
- """Return a new path with the ALF UUID part added/changed.
1177
-
1178
- Parameters
1179
- ----------
1180
- uuid : str, uuid.UUID
1181
- The UUID to add.
1182
-
1183
- Returns
1184
- -------
1185
- PureALFPath
1186
- A new ALFPath object with a UUID in the filename.
1187
-
1188
- Examples
1189
- --------
1190
- >>> uuid = 'a976e418-c8b8-4d24-be47-d05120b18341'
1191
- >>> ALFPath('/path/to/trials.intervals.npy').with_uuid(uuid)
1192
- ALFPath('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
1193
-
1194
- Raises
1195
- ------
1196
- ValueError
1197
- `uuid` must be a valid hyphen-separated hexadecimal UUID.
1198
- ALFInvalid
1199
- Path is not a valid ALF file path.
1200
-
1201
- """
1202
- if not self.is_dataset():
1203
- raise ALFInvalid(f'{self} is not a valid ALF dataset file path')
1204
- return add_uuid_string(self, uuid)
1205
-
1206
- def without_uuid(self):
1207
- """Return a new path with the ALF UUID part removed.
1208
-
1209
- Returns
1210
- -------
1211
- PureALFPath
1212
- A new ALFPath object with a UUID removed from the filename, if present.
1213
-
1214
- Examples
1215
- --------
1216
- >>> alfpath = ALFPath('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
1217
- >>> alfpath.without_uuid(uuid)
1218
- ALFPath('/path/to/trials.intervals.npy')
1219
-
1220
- >>> ALFPath('/path/to/trials.intervals.npy').without_uuid(uuid)
1221
- ALFPath('/path/to/trials.intervals.npy')
1222
-
1223
- """
1224
- return remove_uuid_string(self) if self.is_dataset() else self
1225
-
1226
-
1227
- class ALFPath(PureALFPath):
1228
- """Base class for manipulating Alyx file (ALF) paths with system calls.
1229
-
1230
- Similar to a pathlib Path object but with methods for validating, parsing, and replacing ALF
1231
- path parts. This class also contains methods that work on system files.
1232
-
1233
- Parameters
1234
- ----------
1235
- args : str, pathlib.PurePath
1236
- One or more pathlike objects to combine into an ALF path object.
1237
-
1238
- """
1239
-
1240
- def __new__(cls, *args):
1241
- """Construct a ALFPurePath from one or several strings and or existing PurePath objects.
1242
-
1243
- The strings and path objects are combined so as to yield a canonicalized path, which is
1244
- incorporated into the new PurePath object.
1245
- """
1246
- return super().__new__(WindowsALFPath if os.name == 'nt' else PosixALFPath, *args)
1247
-
1248
- def is_dataset(self) -> bool:
1249
- """Determine if path is an ALF dataset, rather than a folder.
1250
-
1251
- Unlike pathlib and PureALFPath methods, this will return False if the path exists but
1252
- is a folder, otherwise this simply tests the path name, whether it exists or not.
1253
-
1254
- Returns
1255
- -------
1256
- bool
1257
- True if filename is ALF dataset.
1258
-
1259
- """
1260
- return not self.is_dir() and spec.is_valid(self.name)
1261
-
1262
- def is_session_path(self) -> bool:
1263
- """Check if path is a valid ALF session path.
1264
-
1265
- This returns true if the input path matches the ALF session path specification.
1266
- This method can be used as a static method with any pathlike input, or as an instance
1267
- method.
1268
-
1269
- Unlike the PureALFPath method, this will return false if the path matches but is in fact
1270
- a file on disk.
1271
-
1272
- Parameters
1273
- ----------
1274
- path : str, pathlib.PurePath
1275
- A session path to check the validity of.
1276
-
1277
- Returns
1278
- -------
1279
- bool
1280
- True if the path is recognized as a valid ALF session path.
1281
-
1282
- Examples
1283
- --------
1284
- >>> ALFPath('/home/foo/2020-01-01/001').is_session_path()
1285
- True
1286
-
1287
- >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_session_path()
1288
- False
1289
-
1290
- >>> ALFPath.is_session_path('_ibl_wheel.timestamps.npy')
1291
- False
1292
-
1293
- >>> ALFPath.is_valid_alf('lab/Subjects/foo/2020-01-01/001')
1294
- True
1295
-
1296
- See Also
1297
- --------
1298
- PureALFPath.is_valid_alf - Test whether path is generally valid a valid ALF path.
1299
- PureALFPath.session_path_parts - Returns parsed session path parts as tuple of str.
1300
-
1301
- """
1302
- return not self.is_file() and spec.is_session_path(self)
1303
-
1304
- def is_valid_alf(path) -> bool:
1305
- """Check if path is a valid ALF path.
1306
-
1307
- This returns true if the input path matches any part of the ALF path specification.
1308
- This method can be used as a static method with any pathlike input, or as an instance
1309
- method. This will validate both directory paths and file paths.
1310
-
1311
- Unlike the PureALFPath method, this one will return false if the path matches a dataset
1312
- file pattern but is actually a folder on disk, or if the path matches as a file but is
1313
- is a folder on disk.
1314
-
1315
- Parameters
1316
- ----------
1317
- path : str, pathlib.PurePath
1318
- A path to check the validity of.
1319
-
1320
- Returns
1321
- -------
1322
- bool
1323
- True if the path is recognized as a valid ALF path.
1324
-
1325
- Examples
1326
- --------
1327
- >>> ALFPath('/home/foo/2020-01-01/001').is_valid_alf()
1328
- True
1329
-
1330
- >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_valid_alf()
1331
- True
1332
-
1333
- >>> ALFPath.is_valid_alf('_ibl_wheel.timestamps.npy')
1334
- True
1335
-
1336
- >>> ALFPath.is_valid_alf('foo.bar')
1337
- False
1338
-
1339
- See Also
1340
- --------
1341
- PureALFPath.is_dataset - Test whether file name is valid as well as directory path.
1342
- full_path_parts - Validates path and returns the parsed ALF path parts.
1343
-
1344
- """
1345
- try:
1346
- parsed = full_path_parts(path, as_dict=True)
1347
- except ALFInvalid:
1348
- return False
1349
- is_dataset = parsed['object'] is not None
1350
- if isinstance(path, str):
1351
- path = ALFPath(path)
1352
- if hasattr(path, 'is_file') and path.is_file():
1353
- return is_dataset
1354
- elif hasattr(path, 'is_dir') and path.is_dir():
1355
- return not is_dataset
1356
- return True
1357
-
1358
- def iter_datasets(self, recursive=False):
1359
- """Iterate over all files in path, and yield relative dataset paths.
1360
-
1361
- Parameters
1362
- ----------
1363
- recursive : bool
1364
- If true, yield datasets in subdirectories.
1365
-
1366
- Yields
1367
- ------
1368
- ALFPath
1369
- The next valid dataset path in lexicographical order.
1370
-
1371
- See Also
1372
- --------
1373
- one.alf.io.iter_datasets - Equivalent function that can take any pathlike input and returns
1374
- paths relative to the input path.
1375
-
1376
- """
1377
- glob = self.rglob if recursive else self.glob
1378
- for p in sorted(glob('*.*.*')):
1379
- if not p.is_dir() and p.is_dataset:
1380
- yield p
1381
-
1382
-
1383
- class PureWindowsALFPath(pathlib.PureWindowsPath, PureALFPath):
1384
- """PureALFPath subclass for Windows systems."""
1385
-
1386
- pass
1387
-
1388
-
1389
- class PurePosixALFPath(pathlib.PurePosixPath, PureALFPath):
1390
- """PureALFPath subclass for non-Windows systems."""
1391
-
1392
- pass
1393
-
1394
-
1395
- class WindowsALFPath(pathlib.WindowsPath, ALFPath):
1396
- """ALFPath subclass for Windows systems."""
1397
-
1398
- pass
1399
-
1400
-
1401
- class PosixALFPath(pathlib.PosixPath, ALFPath):
1402
- """ALFPath subclass for non-Windows systems."""
1403
-
1404
- pass
1405
-
1406
-
1407
- def ensure_alf_path(path) -> Listable(PureALFPath):
1408
- """Ensure path is a PureALFPath instance.
1409
-
1410
- Ensures the path entered is cast to a PureALFPath instance. If input class is PureALFPath or
1411
- pathlib.PurePath, a PureALFPath instance is returned, otherwise an ALFPath instance is
1412
- returned.
1413
-
1414
- Parameters
1415
- ----------
1416
- path : str, pathlib.PurePath, ALFPath, iterable
1417
- One or more path-like objects.
1418
-
1419
- Returns
1420
- -------
1421
- ALFPath, PureALFPath, list of ALFPath, list of PureALFPath
1422
- One or more ALFPath objects.
1423
-
1424
- Raises
1425
- ------
1426
- TypeError
1427
- Unexpected path instance; input must be a str or pathlib.PurePath instance, or an
1428
- iterable thereof.
1429
-
1430
- """
1431
- if isinstance(path, PureALFPath):
1432
- # Already an ALFPath instance
1433
- return path
1434
- if isinstance(path, pathlib.PurePath):
1435
- # Cast pathlib instance to equivalent ALFPath
1436
- if isinstance(path, pathlib.Path):
1437
- return ALFPath(path)
1438
- elif isinstance(path, pathlib.PurePosixPath):
1439
- return PurePosixALFPath(path)
1440
- elif isinstance(path, pathlib.PureWindowsPath):
1441
- return PureWindowsALFPath(path)
1442
- else:
1443
- return PureALFPath(path)
1444
- if isinstance(path, str):
1445
- # Cast str to ALFPath
1446
- return ALFPath(path)
1447
- if isinstance(path, Iterable):
1448
- # Cast list, generator, tuple, etc. to list of ALFPath
1449
- return list(map(ensure_alf_path, path))
1450
- raise TypeError(f'expected os.PathLike type, got {type(path)} instead')
1
+ """Module for identifying and parsing ALF file names.
2
+
3
+ An ALF file has the following components (those in brackets are optional):
4
+ (_namespace_)object.attribute(_timescale)(.extra.parts).ext
5
+
6
+ Note the following:
7
+ Object attributes may not contain an underscore unless followed by 'times' or 'intervals'.
8
+ A namespace must not contain extra underscores (i.e. `name_space` and `__namespace__` are not
9
+ valid).
10
+ ALF files must always have an extension.
11
+
12
+ For more information, see the following documentation:
13
+ https://int-brain-lab.github.io/ONE/alf_intro.html
14
+
15
+
16
+ ALFPath differences
17
+ -------------------
18
+ ALFPath.iter_datasets returns full paths (close the pathlib.Path.iterdir), whereas
19
+ alf.io.iter_datasets returns relative paths as POSIX strings (TODO).
20
+
21
+ ALFPath.parse_* methods return a dict by default, whereas parse_* functions return
22
+ tuples by default. Additionally, the parse_* functions raise ALFInvalid errors by
23
+ default if the path can't be parsed. ALFPath.parse_* methods have no validation
24
+ option.
25
+
26
+ ALFPath properties return empty str instead of None if ALF part isn't present..
27
+ """
28
+ import os
29
+ import pathlib
30
+ from collections import OrderedDict
31
+ from datetime import datetime
32
+ from typing import Union, Optional, Iterable
33
+ import logging
34
+
35
+ from iblutil.util import Listable
36
+
37
+ from .exceptions import ALFInvalid
38
+ from . import spec
39
+ from .spec import SESSION_SPEC, COLLECTION_SPEC, FILE_SPEC, REL_PATH_SPEC
40
+
41
+ _logger = logging.getLogger(__name__)
42
+ __all__ = [
43
+ 'ALFPath', 'PureALFPath', 'WindowsALFPath', 'PosixALFPath',
44
+ 'PureWindowsALFPath', 'PurePosixALFPath'
45
+ ]
46
+
47
+
48
+ def rel_path_parts(rel_path, as_dict=False, assert_valid=True):
49
+ """Parse a relative path into the relevant parts.
50
+
51
+ A relative path follows the pattern
52
+ (collection/)(#revision#/)_namespace_object.attribute_timescale.extra.extension
53
+
54
+ Parameters
55
+ ----------
56
+ rel_path : str, pathlib.Path
57
+ A relative path string.
58
+ as_dict : bool
59
+ If true, an OrderedDict of parts are returned with the keys ('lab', 'subject', 'date',
60
+ 'number'), otherwise a tuple of values are returned.
61
+ assert_valid : bool
62
+ If true an ALFInvalid is raised when the session cannot be parsed, otherwise an empty
63
+ dict of tuple of Nones is returned.
64
+
65
+ Returns
66
+ -------
67
+ OrderedDict, tuple
68
+ A dict if as_dict is true, or a tuple of parsed values.
69
+
70
+ """
71
+ return _path_parts(rel_path, REL_PATH_SPEC, True, as_dict, assert_valid)
72
+
73
+
74
+ def session_path_parts(session_path, as_dict=False, assert_valid=True):
75
+ """Parse a session path into the relevant parts.
76
+
77
+ Return keys:
78
+ - lab
79
+ - subject
80
+ - date
81
+ - number
82
+
83
+ Parameters
84
+ ----------
85
+ session_path : str, pathlib.Path
86
+ A session path string.
87
+ as_dict : bool
88
+ If true, an OrderedDict of parts are returned with the keys ('lab', 'subject', 'date',
89
+ 'number'), otherwise a tuple of values are returned.
90
+ assert_valid : bool
91
+ If true an ALFInvalid is raised when the session cannot be parsed, otherwise an empty
92
+ dict of tuple of Nones is returned.
93
+
94
+ Returns
95
+ -------
96
+ OrderedDict, tuple
97
+ A dict if as_dict is true, or a tuple of parsed values.
98
+
99
+ Raises
100
+ ------
101
+ ALFInvalid
102
+ Invalid ALF session path (assert_valid is True).
103
+
104
+ """
105
+ return _path_parts(session_path, SESSION_SPEC, False, as_dict, assert_valid)
106
+
107
+
108
+ def _path_parts(path, spec_str, match=True, as_dict=False, assert_valid=True):
109
+ """Given a ALF and a spec string, parse into parts.
110
+
111
+ Parameters
112
+ ----------
113
+ path : str, pathlib.Path
114
+ An ALF path or dataset.
115
+ match : bool
116
+ If True, string must match exactly, otherwise search for expression within path.
117
+ as_dict : bool
118
+ When true a dict of matches is returned.
119
+ assert_valid : bool
120
+ When true an exception is raised when the filename cannot be parsed.
121
+
122
+ Returns
123
+ -------
124
+ OrderedDict, tuple
125
+ A dict if as_dict is true, or a tuple of parsed values.
126
+
127
+ Raises
128
+ ------
129
+ ALFInvalid
130
+ Invalid ALF path (assert_valid is True).
131
+
132
+ """
133
+ if hasattr(path, 'as_posix'):
134
+ path = path.as_posix()
135
+ pattern = spec.regex(spec_str)
136
+ empty = OrderedDict.fromkeys(pattern.groupindex.keys())
137
+ parsed = (pattern.match if match else pattern.search)(path)
138
+ if parsed: # py3.8
139
+ parsed_dict = parsed.groupdict()
140
+ return OrderedDict(parsed_dict) if as_dict else tuple(parsed_dict.values())
141
+ elif assert_valid:
142
+ raise ALFInvalid(path)
143
+ else:
144
+ return empty if as_dict else tuple(empty.values())
145
+
146
+
147
+ def filename_parts(filename, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
148
+ """Return the parsed elements of a given ALF filename.
149
+
150
+ Parameters
151
+ ----------
152
+ filename : str
153
+ The name of the file.
154
+ as_dict : bool
155
+ When true a dict of matches is returned.
156
+ assert_valid : bool
157
+ When true an exception is raised when the filename cannot be parsed.
158
+
159
+ Returns
160
+ -------
161
+ namespace : str
162
+ The _namespace_ or None if not present.
163
+ object : str
164
+ ALF object.
165
+ attribute : str
166
+ The ALF attribute.
167
+ timescale : str
168
+ The ALF _timescale or None if not present.
169
+ extra : str
170
+ Any extra parts to the filename, or None if not present.
171
+ extension : str
172
+ The file extension.
173
+
174
+ Examples
175
+ --------
176
+ >>> filename_parts('_namespace_obj.times_timescale.extra.foo.ext')
177
+ ('namespace', 'obj', 'times', 'timescale', 'extra.foo', 'ext')
178
+ >>> filename_parts('spikes.clusters.npy', as_dict=True)
179
+ {'namespace': None,
180
+ 'object': 'spikes',
181
+ 'attribute': 'clusters',
182
+ 'timescale': None,
183
+ 'extra': None,
184
+ 'extension': 'npy'}
185
+ >>> filename_parts('spikes.times_ephysClock.npy')
186
+ (None, 'spikes', 'times', 'ephysClock', None, 'npy')
187
+ >>> filename_parts('_iblmic_audioSpectrogram.frequencies.npy')
188
+ ('iblmic', 'audioSpectrogram', 'frequencies', None, None, 'npy')
189
+ >>> filename_parts('_spikeglx_ephysData_g0_t0.imec.wiring.json')
190
+ ('spikeglx', 'ephysData_g0_t0', 'imec', None, 'wiring', 'json')
191
+ >>> filename_parts('_spikeglx_ephysData_g0_t0.imec0.lf.bin')
192
+ ('spikeglx', 'ephysData_g0_t0', 'imec0', None, 'lf', 'bin')
193
+ >>> filename_parts('_ibl_trials.goCue_times_bpod.csv')
194
+ ('ibl', 'trials', 'goCue_times', 'bpod', None, 'csv')
195
+
196
+ Raises
197
+ ------
198
+ ALFInvalid
199
+ Invalid ALF dataset (assert_valid is True).
200
+
201
+ """
202
+ return _path_parts(filename, FILE_SPEC, True, as_dict, assert_valid)
203
+
204
+
205
+ def full_path_parts(path, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
206
+ """Parse all filename and folder parts.
207
+
208
+ Parameters
209
+ ----------
210
+ path : str, pathlib.Path.
211
+ The ALF path
212
+ as_dict : bool
213
+ When true a dict of matches is returned.
214
+ assert_valid : bool
215
+ When true an exception is raised when the filename cannot be parsed.
216
+
217
+ Returns
218
+ -------
219
+ OrderedDict, tuple
220
+ A dict if as_dict is true, or a tuple of parsed values.
221
+
222
+ Examples
223
+ --------
224
+ >>> full_path_parts(
225
+ ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
226
+ ... '_namespace_obj.times_timescale.extra.foo.ext')
227
+ ('lab', 'subject', '2020-01-01', '001', 'collection', 'revision',
228
+ 'namespace', 'obj', 'times','timescale', 'extra.foo', 'ext')
229
+ >>> full_path_parts('spikes.clusters.npy', as_dict=True)
230
+ {'lab': None,
231
+ 'subject': None,
232
+ 'date': None,
233
+ 'number': None,
234
+ 'collection': None,
235
+ 'revision': None,
236
+ 'namespace': None,
237
+ 'object': 'spikes',
238
+ 'attribute': 'clusters',
239
+ 'timescale': None,
240
+ 'extra': None,
241
+ 'extension': 'npy'}
242
+
243
+ Raises
244
+ ------
245
+ ALFInvalid
246
+ Invalid ALF path (assert_valid is True).
247
+
248
+ """
249
+ path = pathlib.Path(path)
250
+ # NB We try to determine whether we have a folder or filename path. Filenames contain at
251
+ # least two periods, however it is currently permitted to have any number of periods in a
252
+ # collection, making the ALF path ambiguous.
253
+ if sum(x == '.' for x in path.name) < 2: # folder only
254
+ folders = folder_parts(path, as_dict, assert_valid)
255
+ if assert_valid:
256
+ # Edge case: ensure is indeed folder by checking that name is in parts
257
+ invalid_file = path.name not in (folders.values() if as_dict else folders)
258
+ is_revision = f'#{folders["revision"] if as_dict else folders[-1]}#' == path.name
259
+ if not is_revision and invalid_file:
260
+ raise ALFInvalid(path)
261
+ dataset = filename_parts('', as_dict, assert_valid=False)
262
+ elif '/' not in path.as_posix(): # filename only
263
+ folders = folder_parts('', as_dict, assert_valid=False)
264
+ dataset = filename_parts(path.name, as_dict, assert_valid)
265
+ else: # full filepath
266
+ folders = folder_parts(path.parent, as_dict, assert_valid)
267
+ dataset = filename_parts(path.name, as_dict, assert_valid)
268
+ if as_dict:
269
+ return OrderedDict(**folders, **dataset)
270
+ else:
271
+ return folders + dataset
272
+
273
+
274
+ def folder_parts(folder_path, as_dict=False, assert_valid=True) -> Union[dict, tuple]:
275
+ """Parse all folder parts, including session, collection and revision.
276
+
277
+ Parameters
278
+ ----------
279
+ folder_path : str, pathlib.Path
280
+ The ALF folder path.
281
+ as_dict : bool
282
+ When true a dict of matches is returned.
283
+ assert_valid : bool
284
+ When true an exception is raised when the filename cannot be parsed.
285
+
286
+ Returns
287
+ -------
288
+ OrderedDict, tuple
289
+ A dict if as_dict is true, or a tuple of parsed values.
290
+
291
+ Examples
292
+ --------
293
+ >>> folder_parts('lab/Subjects/subject/2020-01-01/001/collection/#revision#')
294
+ ('lab', 'subject', '2020-01-01', '001', 'collection', 'revision')
295
+ >>> folder_parts(Path('lab/Subjects/subject/2020-01-01/001'), as_dict=True)
296
+ {'lab': 'lab',
297
+ 'subject': 'subject',
298
+ 'date': '2020-01-01',
299
+ 'number': '001',
300
+ 'collection': None,
301
+ 'revision': None}
302
+
303
+ Raises
304
+ ------
305
+ ALFInvalid
306
+ Invalid ALF path (assert_valid is True).
307
+
308
+ """
309
+ if hasattr(folder_path, 'as_posix'):
310
+ folder_path = folder_path.as_posix()
311
+ if folder_path and folder_path[-1] != '/': # Slash required for regex pattern
312
+ folder_path = folder_path + '/'
313
+ spec_str = f'{SESSION_SPEC}/{COLLECTION_SPEC}'
314
+ return _path_parts(folder_path, spec_str, False, as_dict, assert_valid)
315
+
316
+
317
+ def _isdatetime(s: str) -> bool:
318
+ """Returns True if input is valid ISO date string."""
319
+ try:
320
+ datetime.strptime(s, '%Y-%m-%d')
321
+ return True
322
+ except ValueError:
323
+ return False
324
+
325
+
326
+ def get_session_path(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]:
327
+ """Return full session path from any file path if the date/number pattern is found.
328
+
329
+ Returns
330
+ -------
331
+ pathlib.Path
332
+ The session path part of the input path or None if path invalid.
333
+
334
+ Examples
335
+ --------
336
+ >>> get_session_path('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
337
+ Path('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
338
+
339
+ >>> get_session_path('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy')
340
+ Path('C:/Data/subject/2020-01-01/1')
341
+
342
+ """
343
+ if path is None:
344
+ return
345
+ if isinstance(path, str):
346
+ path = pathlib.Path(path)
347
+ for i, p in enumerate(path.parts):
348
+ if p.isdigit() and _isdatetime(path.parts[i - 1]):
349
+ return path.__class__().joinpath(*path.parts[:i + 1])
350
+
351
+
352
+ def get_alf_path(path: Union[str, pathlib.Path]) -> str:
353
+ """Returns the ALF part of a path or filename.
354
+
355
+ Attempts to return the first valid part of the path, first searching for a session path,
356
+ then relative path (collection/revision/filename), then just the filename. If all invalid,
357
+ None is returned.
358
+
359
+ Parameters
360
+ ----------
361
+ path : str, pathlib.Path
362
+ A path to parse.
363
+
364
+ Returns
365
+ -------
366
+ str
367
+ A string containing the full ALF path, session path, relative path or filename.
368
+
369
+ Examples
370
+ --------
371
+ >>> get_alf_path('etc/etc/lab/Subjects/subj/2021-01-21/001')
372
+ 'lab/Subjects/subj/2021-01-21/001/collection/file.attr.ext'
373
+
374
+ >>> get_alf_path('etc/etc/subj/2021-01-21/001/collection/file.attr.ext')
375
+ 'subj/2021-01-21/001/collection/file.attr.ext'
376
+
377
+ >>> get_alf_path('collection/file.attr.ext')
378
+ 'collection/file.attr.ext'
379
+
380
+ """
381
+ if not isinstance(path, str):
382
+ path = pathlib.Path(path).as_posix()
383
+ path = path.strip('/')
384
+
385
+ # Check if session path
386
+ if match_session := spec.regex(SESSION_SPEC).search(path):
387
+ return path[match_session.start():]
388
+
389
+ # Check if filename / relative path (i.e. collection + filename)
390
+ parts = path.rsplit('/', 1)
391
+ if spec.regex(FILE_SPEC).match(parts[-1]):
392
+ return path if spec.regex(f'{COLLECTION_SPEC}{FILE_SPEC}').match(path) else parts[-1]
393
+
394
+
395
+ def add_uuid_string(file_path, uuid):
396
+ """Add a UUID to the filename of an ALF path.
397
+
398
+ Adds a UUID to an ALF filename as an extra part, e.g.
399
+ 'obj.attr.ext' -> 'obj.attr.a976e418-c8b8-4d24-be47-d05120b18341.ext'.
400
+
401
+ Parameters
402
+ ----------
403
+ file_path : str, pathlib.Path, pathlib.PurePath
404
+ An ALF path to add the UUID to.
405
+ uuid : str, uuid.UUID
406
+ The UUID to add.
407
+
408
+ Returns
409
+ -------
410
+ pathlib.Path, pathlib.PurePath
411
+ A new Path or PurePath object with a UUID in the filename.
412
+
413
+ Examples
414
+ --------
415
+ >>> add_uuid_string('/path/to/trials.intervals.npy', 'a976e418-c8b8-4d24-be47-d05120b18341')
416
+ Path('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
417
+
418
+ Raises
419
+ ------
420
+ ValueError
421
+ `uuid` must be a valid hyphen-separated hexadecimal UUID.
422
+
423
+ See Also
424
+ --------
425
+ one.alf.path.ALFPath.with_uuid
426
+ one.alf.path.remove_uuid_string
427
+ one.alf.spec.is_uuid
428
+
429
+ """
430
+ if isinstance(uuid, str) and not spec.is_uuid_string(uuid):
431
+ raise ValueError('Should provide a valid UUID v4')
432
+ uuid = str(uuid)
433
+ # NB: Only instantiate as Path if not already a Path, otherwise we risk changing the class
434
+ if isinstance(file_path, str):
435
+ file_path = pathlib.Path(file_path)
436
+ name_parts = file_path.stem.split('.')
437
+ if spec.is_uuid(name_parts[-1]):
438
+ *name_parts, old_uuid = name_parts
439
+ if old_uuid == uuid:
440
+ _logger.warning(f'UUID already found in file name: {file_path.name}: IGNORE')
441
+ return file_path
442
+ else:
443
+ _logger.debug('Replacing %s with %s in %s', old_uuid, uuid, file_path)
444
+ return file_path.parent.joinpath(f"{'.'.join(name_parts)}.{uuid}{file_path.suffix}")
445
+
446
+
447
+ def remove_uuid_string(file_path):
448
+ """Remove UUID from a filename of an ALF path.
449
+
450
+ Parameters
451
+ ----------
452
+ file_path : str, pathlib.Path, pathlib.PurePath
453
+ An ALF path to add the UUID to.
454
+
455
+ Returns
456
+ -------
457
+ ALFPath, PureALFPath, pathlib.Path, pathlib.PurePath
458
+ A new Path or PurePath object without a UUID in the filename.
459
+
460
+ Examples
461
+ --------
462
+ >>> add_uuid_string('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
463
+ Path('/path/to/trials.intervals.npy')
464
+
465
+ >>> add_uuid_string('/path/to/trials.intervals.npy')
466
+ Path('/path/to/trials.intervals.npy')
467
+
468
+ See Also
469
+ --------
470
+ one.alf.path.ALFPath.without_uuid
471
+ one.alf.path.add_uuid_string
472
+
473
+ """
474
+ if isinstance(file_path, str):
475
+ file_path = pathlib.Path(file_path)
476
+ name_parts = file_path.stem.split('.')
477
+
478
+ if spec.is_uuid_string(name_parts[-1]):
479
+ file_path = file_path.with_name('.'.join(name_parts[:-1]) + file_path.suffix)
480
+ return file_path
481
+
482
+
483
+ def padded_sequence(file_path):
484
+ """Ensures a file path contains a zero-padded experiment sequence folder.
485
+
486
+ Parameters
487
+ ----------
488
+ file_path : str, pathlib.Path, pathlib.PurePath
489
+ A session or file path to convert.
490
+
491
+ Returns
492
+ -------
493
+ ALFPath, PureALFPath
494
+ The same path but with the experiment sequence folder zero-padded. If a PurePath was
495
+ passed, a PurePath will be returned, otherwise a Path object is returned.
496
+
497
+ Examples
498
+ --------
499
+ >>> file_path = '/iblrigdata/subject/2023-01-01/1/_ibl_experiment.description.yaml'
500
+ >>> padded_sequence(file_path)
501
+ pathlib.Path('/iblrigdata/subject/2023-01-01/001/_ibl_experiment.description.yaml')
502
+
503
+ Supports folders and will not affect already padded paths
504
+
505
+ >>> session_path = pathlib.PurePosixPath('subject/2023-01-01/001')
506
+ >>> padded_sequence(file_path)
507
+ pathlib.PurePosixPath('subject/2023-01-01/001')
508
+
509
+ """
510
+ file_path = ensure_alf_path(file_path)
511
+ if (session_path := get_session_path(file_path)) is None:
512
+ raise ValueError('path must include a valid ALF session path, e.g. subject/YYYY-MM-DD/N')
513
+ idx = len(file_path.parts) - len(session_path.parts)
514
+ sequence = str(int(session_path.parts[-1])).zfill(3) # zero-pad if necessary
515
+ return file_path.parents[idx].joinpath(sequence, file_path.relative_to(session_path))
516
+
517
+
518
+ def without_revision(file_path):
519
+ """Return file path without a revision folder.
520
+
521
+ Parameters
522
+ ----------
523
+ file_path : str, pathlib.Path
524
+ A valid ALF dataset path.
525
+
526
+ Returns
527
+ -------
528
+ pathlib.Path
529
+ The input file path without a revision folder.
530
+
531
+ Examples
532
+ --------
533
+ >>> without_revision('/lab/Subjects/subject/2023-01-01/001/collection/#revision#/obj.attr.ext')
534
+ Path('/lab/Subjects/subject/2023-01-01/001/collection/obj.attr.ext')
535
+
536
+ """
537
+ if isinstance(file_path, str):
538
+ file_path = pathlib.Path(file_path)
539
+ *_, collection, revision = folder_parts(file_path.parent)
540
+ return get_session_path(file_path).joinpath(*filter(None, (collection, file_path.name)))
541
+
542
+
543
+ class PureALFPath(pathlib.PurePath): # py3.12 supports direct subclassing
544
+ """Base class for manipulating Alyx file (ALF) paths without I/O.
545
+
546
+ Similar to a pathlib PurePath object but with methods for validating, parsing, and replacing
547
+ ALF path parts.
548
+
549
+ Parameters
550
+ ----------
551
+ args : str, pathlib.PurePath
552
+ One or more pathlike objects to combine into an ALF path object.
553
+
554
+ """
555
+
556
+ def __new__(cls, *args):
557
+ """Construct a ALFPurePath from one or several strings and or existing PurePath objects.
558
+
559
+ The strings and path objects are combined so as to yield a canonicalized path, which is
560
+ incorporated into the new PurePath object.
561
+ """
562
+ if cls is PureALFPath:
563
+ cls = PureWindowsALFPath if os.name == 'nt' else PurePosixALFPath
564
+ return super().__new__(cls, *args)
565
+
566
+ def is_dataset(self):
567
+ """Determine if path is an ALF dataset, rather than a folder.
568
+
569
+ Returns
570
+ -------
571
+ bool
572
+ True if filename is ALF dataset.
573
+
574
+ """
575
+ return spec.is_valid(self.name)
576
+
577
+ def is_valid_alf(path) -> bool:
578
+ """Check if path is a valid ALF path.
579
+
580
+ This returns true if the input path matches any part of the ALF path specification.
581
+ This method can be used as a static method with any pathlike input, or as an instance
582
+ method. This will validate both directory paths and file paths.
583
+
584
+ Parameters
585
+ ----------
586
+ path : str, pathlib.PurePath
587
+ A path to check the validity of.
588
+
589
+ Returns
590
+ -------
591
+ bool
592
+ True if the path is recognized as a valid ALF path.
593
+
594
+ Examples
595
+ --------
596
+ >>> ALFPath('/home/foo/2020-01-01/001').is_valid_alf()
597
+ True
598
+
599
+ >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_valid_alf()
600
+ True
601
+
602
+ >>> ALFPath.is_valid_alf('_ibl_wheel.timestamps.npy')
603
+ True
604
+
605
+ >>> ALFPath.is_valid_alf('foo.bar')
606
+ False
607
+
608
+ See Also
609
+ --------
610
+ PureALFPath.is_dataset - Test whether file name is valid as well as directory path.
611
+ full_path_parts - Validates path and returns the parsed ALF path parts.
612
+
613
+ """
614
+ try:
615
+ return any(full_path_parts(path))
616
+ except ALFInvalid:
617
+ return False
618
+
619
+ def is_session_path(path) -> bool:
620
+ """Check if path is a valid ALF session path.
621
+
622
+ This returns true if the input path matches the ALF session path specification.
623
+ This method can be used as a static method with any pathlike input, or as an instance
624
+ method.
625
+
626
+ Parameters
627
+ ----------
628
+ path : str, pathlib.PurePath
629
+ A session path to check the validity of.
630
+
631
+ Returns
632
+ -------
633
+ bool
634
+ True if the path is recognized as a valid ALF session path.
635
+
636
+ Examples
637
+ --------
638
+ >>> ALFPath('/home/foo/2020-01-01/001').is_session_path()
639
+ True
640
+
641
+ >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_session_path()
642
+ False
643
+
644
+ >>> ALFPath.is_session_path('_ibl_wheel.timestamps.npy')
645
+ False
646
+
647
+ >>> ALFPath.is_valid_alf('lab/Subjects/foo/2020-01-01/001')
648
+ True
649
+
650
+ See Also
651
+ --------
652
+ PureALFPath.is_valid_alf - Test whether path is generally valid a valid ALF path.
653
+ PureALFPath.session_path_parts - Returns parsed session path parts as tuple of str.
654
+
655
+ """
656
+ return spec.is_session_path(path)
657
+
658
+ def session_path(self):
659
+ """Extract the full session path.
660
+
661
+ Returns the session path from the filepath if the date/number pattern is found,
662
+ including the root directory.
663
+
664
+ Returns
665
+ -------
666
+ PureALFPath
667
+ The session path part of the input path or None if path invalid.
668
+
669
+ Examples
670
+ --------
671
+ >>> ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001').session_path()
672
+ ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
673
+
674
+ >>> ALFPath('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy').session_path()
675
+ ALFPath('C:/Data/subject/2020-01-01/1')
676
+
677
+ """
678
+ return get_session_path(self)
679
+
680
+ def session_path_short(self, include_lab=False) -> str:
681
+ """Return only the ALF session path as a posix str.
682
+
683
+ Params
684
+ ------
685
+ include_lab : bool
686
+ If true, the lab/subject/date/number is returned, otherwise the lab part is dropped.
687
+
688
+ Returns
689
+ -------
690
+ str
691
+ The session path part of the input path or None if path invalid.
692
+
693
+ Examples
694
+ --------
695
+ >>> ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001').session_path_short()
696
+ 'subject/2020-01-01/001'
697
+
698
+ >>> alfpath = ALFPath('/mnt/sd0/Data/lab/Subjects/subject/2020-01-01/001')
699
+ >>> alfpath.session_path_short(include_lab=True)
700
+ 'lab/subject/2020-01-01/001'
701
+
702
+ >>> ALFPath('C:\\Data\\subject\\2020-01-01\\1\\trials.intervals.npy').session_path_short()
703
+ 'subject/2020-01-01/1'
704
+
705
+ """
706
+ idx = 0 if include_lab else 1
707
+ if any(parts := self.session_parts[idx:]):
708
+ return '/'.join(parts)
709
+
710
+ def without_lab(self) -> 'PureALFPath':
711
+ """Return path without the <lab>/Subjects/ part.
712
+
713
+ If the <lab>/Subjects pattern is not found, the same path is returned.
714
+
715
+ Returns
716
+ -------
717
+ PureALFPath
718
+ The same path without the <lab>/Subjects part.
719
+
720
+ """
721
+ p = self.as_posix()
722
+ if m := spec.regex('{lab}/Subjects/').search(p):
723
+ return self.__class__(p[:m.start()], p[m.end():])
724
+ else:
725
+ return self
726
+
727
+ def relative_to_lab(self) -> 'PureALFPath':
728
+ """Return path relative to <lab>/Subjects/ part.
729
+
730
+ Returns
731
+ -------
732
+ PureALFPath
733
+ The same path, relative to the <lab>/Subjects/ part.
734
+
735
+ Raises
736
+ ------
737
+ ValueError
738
+ The path doesn't contain a <lab>/Subjects/ pattern.
739
+
740
+ """
741
+ p = self.as_posix()
742
+ if m := spec.regex('{lab}/Subjects/').search(p):
743
+ return self.__class__(p[m.end():])
744
+ else:
745
+ raise ValueError(f'{self} does not contain <lab>/Subjects pattern')
746
+
747
+ def relative_to_session(self):
748
+ """Return path relative to session part.
749
+
750
+ Returns
751
+ -------
752
+ PureALFPath
753
+ The same path, relative to the <lab>/Subjects/<subject>/<date>/<number> part.
754
+
755
+ Raises
756
+ ------
757
+ ValueError
758
+ The path doesn't contain a <lab>/Subjects/ pattern.
759
+
760
+ """
761
+ if (session_path := self.session_path()):
762
+ return self.relative_to(session_path)
763
+ else:
764
+ raise ValueError(f'{self} does not contain session path pattern')
765
+
766
+ def parse_alf_path(self, as_dict=True):
767
+ """Parse all filename and folder parts.
768
+
769
+ Parameters
770
+ ----------
771
+ as_dict : bool
772
+ When true a dict of matches is returned.
773
+
774
+ Returns
775
+ -------
776
+ OrderedDict, tuple
777
+ A dict if as_dict is true, or a tuple of parsed values.
778
+
779
+ Examples
780
+ --------
781
+ >>> alfpath = PureALFPath(
782
+ ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
783
+ ... '_namespace_obj.times_timescale.extra.foo.ext')
784
+ >>> alfpath.parse_alf_path()
785
+ {'lab': 'lab',
786
+ 'subject': 'subject',
787
+ 'date': '2020-01-01',
788
+ 'number': '001',
789
+ 'collection': 'collection',
790
+ 'revision': 'revision',
791
+ 'namespace': 'namespace',
792
+ 'object': 'obj',
793
+ 'attribute': 'times',
794
+ 'timescale': 'timescale',
795
+ 'extra': 'extra.foo',
796
+ 'extension': 'ext'}
797
+
798
+ >>> PureALFPath('_namespace_obj.times_timescale.extra.foo.ext').parse_alf_path()
799
+ (None, None, None, None, None, None, 'namespace',
800
+ 'obj', 'times','timescale', 'extra.foo', 'ext')
801
+
802
+ """
803
+ return full_path_parts(self, assert_valid=False, as_dict=as_dict)
804
+
805
+ def parse_alf_name(self, as_dict=True):
806
+ """Return the parsed elements of a given ALF filename.
807
+
808
+ Parameters
809
+ ----------
810
+ as_dict : bool
811
+ When true a dict of matches is returned.
812
+
813
+ Returns
814
+ -------
815
+ namespace : str
816
+ The _namespace_ or None if not present.
817
+ object : str
818
+ ALF object.
819
+ attribute : str
820
+ The ALF attribute.
821
+ timescale : str
822
+ The ALF _timescale or None if not present.
823
+ extra : str
824
+ Any extra parts to the filename, or None if not present.
825
+ extension : str
826
+ The file extension.
827
+
828
+ Examples
829
+ --------
830
+ >>> alfpath = PureALFPath(
831
+ ... 'lab/Subjects/subject/2020-01-01/001/collection/#revision#/'
832
+ ... '_namespace_obj.times_timescale.extra.foo.ext')
833
+ >>> alfpath.parse_alf_name()
834
+ {'namespace': 'namespace',
835
+ 'object': 'obj',
836
+ 'attribute': 'times',
837
+ 'timescale': 'timescale',
838
+ 'extra': 'extra.foo',
839
+ 'extension': 'ext'}
840
+
841
+ >>> PureALFPath('spikes.clusters.npy', as_dict=False)
842
+ (None, 'spikes', 'clusters', None, None, npy)
843
+
844
+ """
845
+ return filename_parts(self.name, assert_valid=False, as_dict=as_dict)
846
+
847
+ @property
848
+ def dataset_name_parts(self):
849
+ """tuple of str: the dataset name parts, with empty strings for missing parts."""
850
+ return tuple(p or '' for p in self.parse_alf_name(as_dict=False))
851
+
852
+ @property
853
+ def session_parts(self):
854
+ """tuple of str: the session path parts, with empty strings for missing parts."""
855
+ return tuple(p or '' for p in session_path_parts(self, assert_valid=False))
856
+
857
+ @property
858
+ def alf_parts(self):
859
+ """tuple of str: the full ALF path parts, with empty strings for missing parts."""
860
+ return tuple(p or '' for p in self.parse_alf_path(as_dict=False))
861
+
862
+ @property
863
+ def namespace(self):
864
+ """str: The namespace part of the ALF name, or and empty str if not present."""
865
+ return self.dataset_name_parts[0]
866
+
867
+ @property
868
+ def object(self):
869
+ """str: The object part of the ALF name, or and empty str if not present."""
870
+ return self.dataset_name_parts[1]
871
+
872
+ @property
873
+ def attribute(self):
874
+ """str: The attribute part of the ALF name, or and empty str if not present."""
875
+ return self.dataset_name_parts[2]
876
+
877
+ @property
878
+ def timescale(self):
879
+ """str: The timescale part of the ALF name, or and empty str if not present."""
880
+ return self.dataset_name_parts[3]
881
+
882
+ @property
883
+ def extra(self):
884
+ """str: The extra part of the ALF name, or and empty str if not present."""
885
+ return self.dataset_name_parts[4]
886
+
887
+ def with_object(self, obj):
888
+ """Return a new path with the ALF object changed.
889
+
890
+ Parameters
891
+ ----------
892
+ obj : str
893
+ An ALF object name part to use.
894
+
895
+ Returns
896
+ -------
897
+ PureALFPath
898
+ The same file path but with the object part replaced with the input.
899
+
900
+ Raises
901
+ ------
902
+ ALFInvalid
903
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
904
+ contains invalid characters).
905
+
906
+ """
907
+ if not self.is_dataset():
908
+ raise ALFInvalid(str(self))
909
+ ns_obj, rest = self.name.split('.', 1)
910
+ ns, _ = spec.regex(FILE_SPEC.split('\\.')[0]).match(ns_obj).groups()
911
+ ns = f'_{ns}_' if ns else ''
912
+ return self.with_name(f'{ns}{obj}.{rest}')
913
+
914
+ def with_namespace(self, ns):
915
+ """Return a new path with the ALF namespace added or changed.
916
+
917
+ Parameters
918
+ ----------
919
+ namespace : str
920
+ An ALF namespace part to use.
921
+
922
+ Returns
923
+ -------
924
+ PureALFPath
925
+ The same file path but with the namespace part added/replaced with the input.
926
+
927
+ Raises
928
+ ------
929
+ ALFInvalid
930
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
931
+ contains invalid characters).
932
+
933
+ """
934
+ if not self.is_dataset():
935
+ raise ALFInvalid(self)
936
+ ns_obj, rest = self.name.split('.', 1)
937
+ _, obj = spec.regex(FILE_SPEC.split('\\.')[0]).match(ns_obj).groups()
938
+ ns = f'_{ns}_' if ns else ''
939
+ return self.with_name(f'{ns}{obj}.{rest}')
940
+
941
+ def with_attribute(self, attr):
942
+ """Return a new path with the ALF attribute changed.
943
+
944
+ Parameters
945
+ ----------
946
+ attribute : str
947
+ An ALF attribute part to use.
948
+
949
+ Returns
950
+ -------
951
+ PureALFPath
952
+ The same file path but with the attribute part replaced with the input.
953
+
954
+ Raises
955
+ ------
956
+ ALFInvalid
957
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
958
+ contains invalid characters).
959
+
960
+ """
961
+ if not self.is_dataset():
962
+ raise ALFInvalid(self)
963
+ ns_obj, attr_ts, rest = self.name.split('.', 2)
964
+ _, ts = spec.regex('{attribute}(?:_{timescale})?').match(attr_ts).groups()
965
+ ts = f'_{ts}' if ts else ''
966
+ return self.with_name(f'{ns_obj}.{attr}{ts}.{rest}')
967
+
968
+ def with_timescale(self, timescale):
969
+ """Return a new path with the ALF timescale added or changed.
970
+
971
+ Parameters
972
+ ----------
973
+ timescale : str
974
+ An ALF timescale part to use.
975
+
976
+ Returns
977
+ -------
978
+ PureALFPath
979
+ The same file path but with the timescale part added/replaced with the input.
980
+
981
+ Raises
982
+ ------
983
+ ALFInvalid
984
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
985
+ contains invalid characters).
986
+
987
+ """
988
+ if not self.is_dataset():
989
+ raise ALFInvalid(self)
990
+ ns_obj, attr_ts, rest = self.name.split('.', 2)
991
+ attr, _ = spec.regex('{attribute}(?:_{timescale})?').match(attr_ts).groups()
992
+ ts = f'_{timescale}' if timescale else ''
993
+ return self.with_name(f'{ns_obj}.{attr}{ts}.{rest}')
994
+
995
+ def with_extra(self, extra, append=False):
996
+ """Return a new path with extra ALF parts added or changed.
997
+
998
+ Parameters
999
+ ----------
1000
+ extra : str, list of str
1001
+ Extra ALF parts to add/replace.
1002
+ append : bool
1003
+ When false (default) any existing extra parts are replaced instead of added to.
1004
+
1005
+ Returns
1006
+ -------
1007
+ PureALFPath
1008
+ The same file path but with the extra part(s) replaced or appended to with the input.
1009
+
1010
+ Raises
1011
+ ------
1012
+ ALFInvalid
1013
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
1014
+ contains invalid characters).
1015
+
1016
+ """
1017
+ if not self.is_dataset():
1018
+ raise ALFInvalid(self)
1019
+ parts = self.stem.split('.', 2)
1020
+ if isinstance(extra, str):
1021
+ extra = extra.strip('.').split('.')
1022
+ if (prev := parts.pop() if len(parts) > 2 else None) and append:
1023
+ extra = (prev, *extra)
1024
+ obj_attr = '.'.join(parts)
1025
+ if extra := '.'.join(filter(None, extra)):
1026
+ return self.with_stem(f'{obj_attr}.{extra}')
1027
+ else:
1028
+ return self.with_stem(obj_attr)
1029
+
1030
+ def with_extension(self, ext):
1031
+ """Return a new path with the ALF extension (suffix) changed.
1032
+
1033
+ Note that unlike PurePath's `with_suffix` method, this asserts that the filename is a valid
1034
+ ALF dataset and the `ext` argument should be without the period.
1035
+
1036
+ Parameters
1037
+ ----------
1038
+ ext : str
1039
+ An ALF extension part to use (sans period).
1040
+
1041
+ Returns
1042
+ -------
1043
+ PureALFPath
1044
+ The same file path but with the extension part replaced with the input.
1045
+
1046
+ Raises
1047
+ ------
1048
+ ALFInvalid
1049
+ The path is not a valid ALF dataset (e.g. doesn't have a three-part filename, or
1050
+ contains invalid characters).
1051
+
1052
+ """
1053
+ if not self.is_dataset():
1054
+ raise ALFInvalid(str(self))
1055
+ return self.with_suffix(f'.{ext}')
1056
+
1057
+ def with_padded_sequence(path):
1058
+ """Ensures a file path contains a zero-padded experiment sequence folder.
1059
+
1060
+ Parameters
1061
+ ----------
1062
+ path : str pathlib.PurePath
1063
+ A session or file path to convert.
1064
+
1065
+ Returns
1066
+ -------
1067
+ ALFPath, PureALFPath
1068
+ The same path but with the experiment sequence folder zero-padded. If a PurePath was
1069
+ passed, a PurePath will be returned, otherwise a Path object is returned.
1070
+
1071
+ Examples
1072
+ --------
1073
+ Supports calling as static function
1074
+
1075
+ >>> file_path = '/iblrigdata/subject/2023-01-01/1/_ibl_experiment.description.yaml'
1076
+ >>> ALFPath.with_padded_sequence(file_path)
1077
+ ALFPath('/iblrigdata/subject/2023-01-01/001/_ibl_experiment.description.yaml')
1078
+
1079
+ Supports folders and will not affect already padded paths
1080
+
1081
+ >>> ALFPath('subject/2023-01-01/001').with_padded_sequence(file_path)
1082
+ ALFPath('subject/2023-01-01/001')
1083
+
1084
+ """
1085
+ return padded_sequence(path)
1086
+
1087
+ def with_revision(self, revision):
1088
+ """Return a new path with the ALF revision part added/changed.
1089
+
1090
+ Parameters
1091
+ ----------
1092
+ revision : str
1093
+ An ALF revision part to use (NB: do not include the pound sign '#').
1094
+
1095
+ Returns
1096
+ -------
1097
+ PureALFPath
1098
+ The same file path but with the revision part added or replaced with the input.
1099
+
1100
+ Examples
1101
+ --------
1102
+ If not in the ALF path, one will be added
1103
+
1104
+ >>> ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext').with_revision('revision')
1105
+ ALFPath('/subject/2023-01-01/1/alf/#xxx#/obj.attr.ext')
1106
+
1107
+ If a revision is already in the ALF path it will be replaced
1108
+
1109
+ >>> ALFPath('/subject/2023-01-01/1/alf/#revision#/obj.attr.ext').with_revision('xxx')
1110
+ ALFPath('/subject/2023-01-01/1/alf/#xxx#/obj.attr.ext')
1111
+
1112
+ Raises
1113
+ ------
1114
+ ALFInvalid
1115
+ The ALF path is not valid or is relative to the session path. The path must include
1116
+ the session parts otherwise the path is too ambiguous to determine validity.
1117
+ ALFInvalid
1118
+ The revision provided does not match the ALF specification pattern.
1119
+
1120
+ See Also
1121
+ --------
1122
+ PureALFPath.without_revision
1123
+
1124
+ """
1125
+ # Validate the revision input
1126
+ revision, = _path_parts(revision, '^{revision}$', match=True, assert_valid=True)
1127
+ if PureALFPath.is_dataset(self):
1128
+ return self.without_revision().parent / f'#{revision}#' / self.name
1129
+ else:
1130
+ return self.without_revision() / f'#{revision}#'
1131
+
1132
+ def without_revision(self):
1133
+ """Return a new path with the ALF revision part removed.
1134
+
1135
+ Returns
1136
+ -------
1137
+ PureALFPath
1138
+ The same file path but with the revision part removed.
1139
+
1140
+ Examples
1141
+ --------
1142
+ If not in the ALF path, no change occurs
1143
+
1144
+ >>> ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext').with_revision('revision')
1145
+ ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext')
1146
+
1147
+ If a revision is in the ALF path it will be removed
1148
+
1149
+ >>> ALFPath('/subject/2023-01-01/1/alf/#revision#/obj.attr.ext').without_revision()
1150
+ ALFPath('/subject/2023-01-01/1/alf/obj.attr.ext')
1151
+
1152
+ Raises
1153
+ ------
1154
+ ALFInvalid
1155
+ The ALF path is not valid or is relative to the session path. The path must include
1156
+ the session parts otherwise the path is too ambiguous to determine validity.
1157
+
1158
+ See Also
1159
+ --------
1160
+ PureALFPath.with_revision
1161
+
1162
+ """
1163
+ if PureALFPath.is_dataset(self):
1164
+ # Is a file path (rather than folder path)
1165
+ return without_revision(self)
1166
+ if not self.is_valid_alf():
1167
+ raise ALFInvalid(f'{self} not a valid ALF path or is relative to session')
1168
+ elif spec.regex('^#{revision}#$').match(self.name):
1169
+ # Includes revision
1170
+ return self.parent
1171
+ else:
1172
+ # Does not include revision
1173
+ return self
1174
+
1175
+ def with_uuid(self, uuid):
1176
+ """Return a new path with the ALF UUID part added/changed.
1177
+
1178
+ Parameters
1179
+ ----------
1180
+ uuid : str, uuid.UUID
1181
+ The UUID to add.
1182
+
1183
+ Returns
1184
+ -------
1185
+ PureALFPath
1186
+ A new ALFPath object with a UUID in the filename.
1187
+
1188
+ Examples
1189
+ --------
1190
+ >>> uuid = 'a976e418-c8b8-4d24-be47-d05120b18341'
1191
+ >>> ALFPath('/path/to/trials.intervals.npy').with_uuid(uuid)
1192
+ ALFPath('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
1193
+
1194
+ Raises
1195
+ ------
1196
+ ValueError
1197
+ `uuid` must be a valid hyphen-separated hexadecimal UUID.
1198
+ ALFInvalid
1199
+ Path is not a valid ALF file path.
1200
+
1201
+ """
1202
+ if not self.is_dataset():
1203
+ raise ALFInvalid(f'{self} is not a valid ALF dataset file path')
1204
+ return add_uuid_string(self, uuid)
1205
+
1206
+ def without_uuid(self):
1207
+ """Return a new path with the ALF UUID part removed.
1208
+
1209
+ Returns
1210
+ -------
1211
+ PureALFPath
1212
+ A new ALFPath object with a UUID removed from the filename, if present.
1213
+
1214
+ Examples
1215
+ --------
1216
+ >>> alfpath = ALFPath('/path/to/trials.intervals.a976e418-c8b8-4d24-be47-d05120b18341.npy')
1217
+ >>> alfpath.without_uuid(uuid)
1218
+ ALFPath('/path/to/trials.intervals.npy')
1219
+
1220
+ >>> ALFPath('/path/to/trials.intervals.npy').without_uuid(uuid)
1221
+ ALFPath('/path/to/trials.intervals.npy')
1222
+
1223
+ """
1224
+ return remove_uuid_string(self) if self.is_dataset() else self
1225
+
1226
+
1227
+ class ALFPath(PureALFPath):
1228
+ """Base class for manipulating Alyx file (ALF) paths with system calls.
1229
+
1230
+ Similar to a pathlib Path object but with methods for validating, parsing, and replacing ALF
1231
+ path parts. This class also contains methods that work on system files.
1232
+
1233
+ Parameters
1234
+ ----------
1235
+ args : str, pathlib.PurePath
1236
+ One or more pathlike objects to combine into an ALF path object.
1237
+
1238
+ """
1239
+
1240
+ def __new__(cls, *args):
1241
+ """Construct a ALFPurePath from one or several strings and or existing PurePath objects.
1242
+
1243
+ The strings and path objects are combined so as to yield a canonicalized path, which is
1244
+ incorporated into the new PurePath object.
1245
+ """
1246
+ return super().__new__(WindowsALFPath if os.name == 'nt' else PosixALFPath, *args)
1247
+
1248
+ def is_dataset(self) -> bool:
1249
+ """Determine if path is an ALF dataset, rather than a folder.
1250
+
1251
+ Unlike pathlib and PureALFPath methods, this will return False if the path exists but
1252
+ is a folder, otherwise this simply tests the path name, whether it exists or not.
1253
+
1254
+ Returns
1255
+ -------
1256
+ bool
1257
+ True if filename is ALF dataset.
1258
+
1259
+ """
1260
+ return not self.is_dir() and spec.is_valid(self.name)
1261
+
1262
+ def is_session_path(self) -> bool:
1263
+ """Check if path is a valid ALF session path.
1264
+
1265
+ This returns true if the input path matches the ALF session path specification.
1266
+ This method can be used as a static method with any pathlike input, or as an instance
1267
+ method.
1268
+
1269
+ Unlike the PureALFPath method, this will return false if the path matches but is in fact
1270
+ a file on disk.
1271
+
1272
+ Parameters
1273
+ ----------
1274
+ path : str, pathlib.PurePath
1275
+ A session path to check the validity of.
1276
+
1277
+ Returns
1278
+ -------
1279
+ bool
1280
+ True if the path is recognized as a valid ALF session path.
1281
+
1282
+ Examples
1283
+ --------
1284
+ >>> ALFPath('/home/foo/2020-01-01/001').is_session_path()
1285
+ True
1286
+
1287
+ >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_session_path()
1288
+ False
1289
+
1290
+ >>> ALFPath.is_session_path('_ibl_wheel.timestamps.npy')
1291
+ False
1292
+
1293
+ >>> ALFPath.is_valid_alf('lab/Subjects/foo/2020-01-01/001')
1294
+ True
1295
+
1296
+ See Also
1297
+ --------
1298
+ PureALFPath.is_valid_alf - Test whether path is generally valid a valid ALF path.
1299
+ PureALFPath.session_path_parts - Returns parsed session path parts as tuple of str.
1300
+
1301
+ """
1302
+ return not self.is_file() and spec.is_session_path(self)
1303
+
1304
+ def is_valid_alf(path) -> bool:
1305
+ """Check if path is a valid ALF path.
1306
+
1307
+ This returns true if the input path matches any part of the ALF path specification.
1308
+ This method can be used as a static method with any pathlike input, or as an instance
1309
+ method. This will validate both directory paths and file paths.
1310
+
1311
+ Unlike the PureALFPath method, this one will return false if the path matches a dataset
1312
+ file pattern but is actually a folder on disk, or if the path matches as a file but is
1313
+ is a folder on disk.
1314
+
1315
+ Parameters
1316
+ ----------
1317
+ path : str, pathlib.PurePath
1318
+ A path to check the validity of.
1319
+
1320
+ Returns
1321
+ -------
1322
+ bool
1323
+ True if the path is recognized as a valid ALF path.
1324
+
1325
+ Examples
1326
+ --------
1327
+ >>> ALFPath('/home/foo/2020-01-01/001').is_valid_alf()
1328
+ True
1329
+
1330
+ >>> ALFPath('/home/foo/2020-01-01/001/alf/spikes.times.npy').is_valid_alf()
1331
+ True
1332
+
1333
+ >>> ALFPath.is_valid_alf('_ibl_wheel.timestamps.npy')
1334
+ True
1335
+
1336
+ >>> ALFPath.is_valid_alf('foo.bar')
1337
+ False
1338
+
1339
+ See Also
1340
+ --------
1341
+ PureALFPath.is_dataset - Test whether file name is valid as well as directory path.
1342
+ full_path_parts - Validates path and returns the parsed ALF path parts.
1343
+
1344
+ """
1345
+ try:
1346
+ parsed = full_path_parts(path, as_dict=True)
1347
+ except ALFInvalid:
1348
+ return False
1349
+ is_dataset = parsed['object'] is not None
1350
+ if isinstance(path, str):
1351
+ path = ALFPath(path)
1352
+ if hasattr(path, 'is_file') and path.is_file():
1353
+ return is_dataset
1354
+ elif hasattr(path, 'is_dir') and path.is_dir():
1355
+ return not is_dataset
1356
+ return True
1357
+
1358
+ def iter_datasets(self, recursive=False):
1359
+ """Iterate over all files in path, and yield relative dataset paths.
1360
+
1361
+ Parameters
1362
+ ----------
1363
+ recursive : bool
1364
+ If true, yield datasets in subdirectories.
1365
+
1366
+ Yields
1367
+ ------
1368
+ ALFPath
1369
+ The next valid dataset path in lexicographical order.
1370
+
1371
+ See Also
1372
+ --------
1373
+ one.alf.io.iter_datasets - Equivalent function that can take any pathlike input and returns
1374
+ paths relative to the input path.
1375
+
1376
+ """
1377
+ glob = self.rglob if recursive else self.glob
1378
+ for p in sorted(glob('*.*.*')):
1379
+ if not p.is_dir() and p.is_dataset:
1380
+ yield p
1381
+
1382
+
1383
+ class PureWindowsALFPath(pathlib.PureWindowsPath, PureALFPath):
1384
+ """PureALFPath subclass for Windows systems."""
1385
+
1386
+ pass
1387
+
1388
+
1389
+ class PurePosixALFPath(pathlib.PurePosixPath, PureALFPath):
1390
+ """PureALFPath subclass for non-Windows systems."""
1391
+
1392
+ pass
1393
+
1394
+
1395
+ class WindowsALFPath(pathlib.WindowsPath, ALFPath):
1396
+ """ALFPath subclass for Windows systems."""
1397
+
1398
+ pass
1399
+
1400
+
1401
+ class PosixALFPath(pathlib.PosixPath, ALFPath):
1402
+ """ALFPath subclass for non-Windows systems."""
1403
+
1404
+ pass
1405
+
1406
+
1407
+ def ensure_alf_path(path) -> Listable(PureALFPath):
1408
+ """Ensure path is a PureALFPath instance.
1409
+
1410
+ Ensures the path entered is cast to a PureALFPath instance. If input class is PureALFPath or
1411
+ pathlib.PurePath, a PureALFPath instance is returned, otherwise an ALFPath instance is
1412
+ returned.
1413
+
1414
+ Parameters
1415
+ ----------
1416
+ path : str, pathlib.PurePath, ALFPath, iterable
1417
+ One or more path-like objects.
1418
+
1419
+ Returns
1420
+ -------
1421
+ ALFPath, PureALFPath, list of ALFPath, list of PureALFPath
1422
+ One or more ALFPath objects.
1423
+
1424
+ Raises
1425
+ ------
1426
+ TypeError
1427
+ Unexpected path instance; input must be a str or pathlib.PurePath instance, or an
1428
+ iterable thereof.
1429
+
1430
+ """
1431
+ if isinstance(path, PureALFPath):
1432
+ # Already an ALFPath instance
1433
+ return path
1434
+ if isinstance(path, pathlib.PurePath):
1435
+ # Cast pathlib instance to equivalent ALFPath
1436
+ if isinstance(path, pathlib.Path):
1437
+ return ALFPath(path)
1438
+ elif isinstance(path, pathlib.PurePosixPath):
1439
+ return PurePosixALFPath(path)
1440
+ elif isinstance(path, pathlib.PureWindowsPath):
1441
+ return PureWindowsALFPath(path)
1442
+ else:
1443
+ return PureALFPath(path)
1444
+ if isinstance(path, str):
1445
+ # Cast str to ALFPath
1446
+ return ALFPath(path)
1447
+ if isinstance(path, Iterable):
1448
+ # Cast list, generator, tuple, etc. to list of ALFPath
1449
+ return list(map(ensure_alf_path, path))
1450
+ raise TypeError(f'expected os.PathLike type, got {type(path)} instead')