legend-pydataobj 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ """Variable-length code compression algorithms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ import numba
9
+ import numpy as np
10
+ from numpy import int32, ubyte, uint32
11
+ from numpy.typing import NDArray
12
+
13
+ from .. import types as lgdo
14
+ from .base import WaveformCodec
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ULEB128ZigZagDiff(WaveformCodec):
21
+ """ZigZag [#WikiZZ]_ encoding followed by Unsigned Little Endian Base 128 (ULEB128) [#WikiULEB128]_ encoding of array differences.
22
+
23
+ .. [#WikiZZ] https://wikipedia.org/wiki/Variable-length_quantity#Zigzag_encoding
24
+ .. [#WikiULEB128] https://wikipedia.org/wiki/LEB128#Unsigned_LEB128
25
+ """
26
+
27
+ codec: str = "uleb128_zigzag_diff"
28
+
29
+
30
+ def encode(
31
+ sig_in: NDArray | lgdo.VectorOfVectors | lgdo.ArrayOfEqualSizedArrays,
32
+ sig_out: NDArray[ubyte] = None,
33
+ ) -> (NDArray[ubyte], NDArray[uint32]) | lgdo.VectorOfEncodedVectors:
34
+ """Compress digital signal(s) with a variable-length encoding of its derivative.
35
+
36
+ Wraps :func:`uleb128_zigzag_diff_array_encode` and adds support for encoding
37
+ LGDOs.
38
+
39
+ Note
40
+ ----
41
+ If `sig_in` is a NumPy array, no resizing of `sig_out` is performed. Not
42
+ even of the internally allocated one.
43
+
44
+ Because of the current implementation, providing a pre-allocated
45
+ :class:`.VectorOfEncodedVectors` as `sig_out` is not possible.
46
+
47
+ Parameters
48
+ ----------
49
+ sig_in
50
+ array(s) holding the input signal(s).
51
+ sig_out
52
+ pre-allocated unsigned 8-bit integer array(s) for the compressed
53
+ signal(s). If not provided, a new one will be allocated.
54
+
55
+ Returns
56
+ -------
57
+ sig_out, nbytes
58
+ given pre-allocated `sig_out` structure or new structure of unsigned
59
+ 8-bit integers, plus the number of bytes (length) of the encoded
60
+ signal. If `sig_in` is an :class:`.LGDO`, only a newly allocated
61
+ :class:`.VectorOfEncodedVectors` is returned.
62
+
63
+ See Also
64
+ --------
65
+ uleb128_zigzag_diff_array_encode
66
+ """
67
+ if isinstance(sig_in, np.ndarray):
68
+ s = sig_in.shape
69
+ if len(sig_in) == 0:
70
+ return np.empty(s[:-1] + (0,), dtype=ubyte), np.empty(0, dtype=uint32)
71
+
72
+ if sig_out is None:
73
+ # the encoded signal is an array of bytes
74
+ # pre-allocate ubyte (uint8) array with a generous (but safe) size
75
+ max_b = int(np.ceil(np.iinfo(sig_in.dtype).bits / 16) * 5)
76
+ # expand last dimension
77
+ sig_out = np.empty(s[:-1] + (s[-1] * max_b,), dtype=ubyte)
78
+
79
+ if sig_out.dtype != ubyte:
80
+ raise ValueError("sig_out must be of type ubyte")
81
+
82
+ # nbytes has one dimension less (the last one)
83
+ nbytes = np.empty(s[:-1], dtype=uint32)
84
+
85
+ uleb128_zigzag_diff_array_encode(sig_in, sig_out, nbytes)
86
+
87
+ # return without resizing
88
+ return sig_out, nbytes
89
+
90
+ elif isinstance(sig_in, lgdo.VectorOfVectors):
91
+ if sig_out:
92
+ log.warning(
93
+ "a pre-allocated VectorOfEncodedVectors was given "
94
+ "to hold an encoded ArrayOfEqualSizedArrays. "
95
+ "This is not supported at the moment, so a new one "
96
+ "will be allocated to replace it"
97
+ )
98
+ # convert VectorOfVectors to ArrayOfEqualSizedArrays so it can be
99
+ # directly passed to the low-level encoding routine
100
+ sig_out_nda, nbytes = encode(sig_in.to_aoesa())
101
+
102
+ # build the encoded LGDO
103
+ encoded_data = lgdo.ArrayOfEqualSizedArrays(nda=sig_out_nda).to_vov(
104
+ cumulative_length=np.cumsum(nbytes, dtype=uint32)
105
+ )
106
+ # decoded_size is an array, compute it by diff'ing the original VOV
107
+ decoded_size = np.diff(sig_in.cumulative_length, prepend=uint32(0))
108
+
109
+ sig_out = lgdo.VectorOfEncodedVectors(encoded_data, decoded_size)
110
+
111
+ return sig_out
112
+
113
+ elif isinstance(sig_in, lgdo.ArrayOfEqualSizedArrays):
114
+ if sig_out:
115
+ log.warning(
116
+ "a pre-allocated VectorOfEncodedVectors was given "
117
+ "to hold an encoded ArrayOfEqualSizedArrays. "
118
+ "This is not supported at the moment, so a new one "
119
+ "will be allocated to replace it"
120
+ )
121
+
122
+ # encode the internal numpy array
123
+ sig_out_nda, nbytes = encode(sig_in.nda)
124
+
125
+ # build the encoded LGDO
126
+ encoded_data = lgdo.ArrayOfEqualSizedArrays(nda=sig_out_nda).to_vov(
127
+ cumulative_length=np.cumsum(nbytes, dtype=uint32)
128
+ )
129
+ sig_out = lgdo.ArrayOfEncodedEqualSizedArrays(
130
+ encoded_data, decoded_size=sig_in.nda.shape[1]
131
+ )
132
+
133
+ return sig_out
134
+
135
+ elif isinstance(sig_in, lgdo.Array):
136
+ # encode the internal numpy array
137
+ sig_out_nda, nbytes = encode(sig_in.nda, sig_out)
138
+ return lgdo.Array(sig_out_nda), nbytes
139
+
140
+ else:
141
+ raise ValueError(f"unsupported input signal type ({type(sig_in)})")
142
+
143
+
144
+ def decode(
145
+ sig_in: (NDArray[ubyte], NDArray[uint32]) | lgdo.VectorOfEncodedVectors,
146
+ sig_out: NDArray | lgdo.VectorOfVectors | lgdo.ArrayOfEqualSizedArrays = None,
147
+ ) -> NDArray | lgdo.VectorOfVectors | lgdo.ArrayOfEqualSizedArrays:
148
+ """Deompress digital signal(s) with a variable-length encoding of its derivative.
149
+
150
+ Wraps :func:`uleb128_zigzag_diff_array_decode` and adds support for decoding
151
+ LGDOs.
152
+
153
+ Note
154
+ ----
155
+ If `sig_in` is a NumPy array, no resizing (along the last dimension) of
156
+ `sig_out` to its actual length is performed. Not even of the internally
157
+ allocated one. If a pre-allocated :class:`.ArrayOfEqualSizedArrays` is
158
+ provided, it won't be resized too. The internally allocated
159
+ :class:`.ArrayOfEqualSizedArrays` `sig_out` has instead always the correct
160
+ size.
161
+
162
+ Because of the current implementation, providing a pre-allocated
163
+ :class:`.VectorOfVectors` as `sig_out` is not possible.
164
+
165
+ Parameters
166
+ ----------
167
+ sig_in
168
+ array(s) holding the input, compressed signal(s). Output of
169
+ :func:`.encode`.
170
+ sig_out
171
+ pre-allocated array(s) for the decompressed signal(s). If not
172
+ provided, will allocate a 32-bit integer array(s) structure.
173
+
174
+ Returns
175
+ -------
176
+ sig_out
177
+ given pre-allocated structure or new structure of 32-bit integers.
178
+
179
+ See Also
180
+ --------
181
+ uleb128_zigzag_diff_array_decode
182
+ """
183
+ # expect the output of encode()
184
+ if isinstance(sig_in, tuple):
185
+ if sig_out is None:
186
+ # allocate output array of the same shape (generous)
187
+ sig_out = np.empty_like(sig_in[0], dtype=int32)
188
+
189
+ # siglen has one dimension less (the last)
190
+ s = sig_in[0].shape
191
+ siglen = np.empty(s[:-1], dtype=uint32)
192
+
193
+ if len(sig_in[0]) == 0:
194
+ return sig_out, siglen
195
+
196
+ # call low-level routine
197
+ uleb128_zigzag_diff_array_decode(sig_in[0], sig_in[1], sig_out, siglen)
198
+
199
+ return sig_out, siglen
200
+
201
+ elif isinstance(sig_in, lgdo.ArrayOfEncodedEqualSizedArrays):
202
+ if not sig_out:
203
+ # initialize output structure with decoded_size
204
+ sig_out = lgdo.ArrayOfEqualSizedArrays(
205
+ dims=(1, 1),
206
+ shape=(len(sig_in), sig_in.decoded_size.value),
207
+ dtype=int32,
208
+ attrs=sig_in.getattrs(),
209
+ )
210
+
211
+ siglen = np.empty(len(sig_in), dtype=uint32)
212
+ # save original encoded vector lengths
213
+ nbytes = np.diff(sig_in.encoded_data.cumulative_length.nda, prepend=uint32(0))
214
+
215
+ if len(sig_in) == 0:
216
+ return sig_out
217
+
218
+ # convert vector of vectors to array of equal sized arrays
219
+ # can now decode on the 2D matrix together with number of bytes to read per row
220
+ _, siglen = decode(
221
+ (sig_in.encoded_data.to_aoesa(preserve_dtype=True).nda, nbytes), sig_out.nda
222
+ )
223
+
224
+ # sanity check
225
+ assert np.all(sig_in.decoded_size.value == siglen)
226
+
227
+ return sig_out
228
+
229
+ elif isinstance(sig_in, lgdo.VectorOfEncodedVectors):
230
+ if sig_out:
231
+ log.warning(
232
+ "a pre-allocated VectorOfVectors was given "
233
+ "to hold an encoded VectorOfVectors. "
234
+ "This is not supported at the moment, so a new one "
235
+ "will be allocated to replace it"
236
+ )
237
+
238
+ siglen = np.empty(len(sig_in), dtype=uint32)
239
+ # save original encoded vector lengths
240
+ nbytes = np.diff(sig_in.encoded_data.cumulative_length.nda, prepend=uint32(0))
241
+
242
+ # convert vector of vectors to array of equal sized arrays
243
+ # can now decode on the 2D matrix together with number of bytes to read per row
244
+ sig_out, siglen = decode(
245
+ (sig_in.encoded_data.to_aoesa(preserve_dtype=True).nda, nbytes)
246
+ )
247
+
248
+ # sanity check
249
+ assert np.array_equal(sig_in.decoded_size, siglen)
250
+
251
+ # converto to VOV before returning
252
+ return sig_out.to_vov(np.cumsum(siglen, dtype=uint32))
253
+
254
+ else:
255
+ raise ValueError("unsupported input signal type")
256
+
257
+
258
+ @numba.vectorize(
259
+ ["uint64(int64)", "uint32(int32)", "uint16(int16)"],
260
+ nopython=True,
261
+ )
262
+ def zigzag_encode(x: int | NDArray[int]) -> int | NDArray[int]:
263
+ """ZigZag-encode [#WikiZZ]_ signed integer numbers."""
264
+ return (x >> 31) ^ (x << 1)
265
+
266
+
267
+ @numba.vectorize(
268
+ ["int64(uint64)", "int32(uint32)", "int16(uint16)"],
269
+ nopython=True,
270
+ )
271
+ def zigzag_decode(x: int | NDArray[int]) -> int | NDArray[int]:
272
+ """ZigZag-decode [#WikiZZ]_ signed integer numbers."""
273
+ return (x >> 1) ^ -(x & 1)
274
+
275
+
276
+ @numba.jit(["uint32(int64, byte[:])"], nopython=True)
277
+ def uleb128_encode(x: int, encx: NDArray[ubyte]) -> int:
278
+ """Compute a variable-length representation of an unsigned integer.
279
+
280
+ Implements the Unsigned Little Endian Base-128 encoding [#WikiULEB128]_.
281
+ Only positive numbers are expected, as no *two’s complement* is applied.
282
+
283
+ Parameters
284
+ ----------
285
+ x
286
+ the number to be encoded.
287
+ encx
288
+ the encoded varint as a NumPy array of bytes.
289
+
290
+ Returns
291
+ -------
292
+ nbytes
293
+ size of varint in bytes
294
+ """
295
+ i = 0
296
+ bits = x & 0x7F
297
+ x >>= 7
298
+ while x:
299
+ encx[i] = 0x80 | bits
300
+ bits = x & 0x7F
301
+ i += 1
302
+ x >>= 7
303
+
304
+ encx[i] = bits
305
+ # return size of varint in bytes
306
+ return i + 1
307
+
308
+
309
+ @numba.jit(["UniTuple(uint32, 2)(byte[:])"], nopython=True)
310
+ def uleb128_decode(encx: NDArray[ubyte]) -> (int, int):
311
+ """Decode a variable-length integer into an unsigned integer.
312
+
313
+ Implements the Unsigned Little Endian Base-128 decoding [#WikiULEB128]_.
314
+ Only encoded positive numbers are expected, as no *two’s complement* is
315
+ applied.
316
+
317
+ Parameters
318
+ ----------
319
+ encx
320
+ the encoded varint as a NumPy array of bytes.
321
+
322
+ Returns
323
+ -------
324
+ x, nread
325
+ the decoded value and the number of bytes read from the input array.
326
+ """
327
+ if len(encx) <= 0:
328
+ raise ValueError("input bytes array is empty")
329
+
330
+ x = pos = uint32(0)
331
+ for b in encx:
332
+ x = x | ((b & 0x7F) << pos)
333
+ if (b & 0x80) == 0:
334
+ return (x, int(pos / 7 + 1))
335
+ else:
336
+ pos += 7
337
+
338
+ if pos >= 64:
339
+ raise OverflowError("overflow during decoding of varint encoded number")
340
+
341
+ raise RuntimeError("malformed varint")
342
+
343
+
344
+ @numba.guvectorize(
345
+ [
346
+ "void(uint16[:], byte[:], uint32[:])",
347
+ "void(uint32[:], byte[:], uint32[:])",
348
+ "void(uint64[:], byte[:], uint32[:])",
349
+ "void(int16[:], byte[:], uint32[:])",
350
+ "void(int32[:], byte[:], uint32[:])",
351
+ "void(int64[:], byte[:], uint32[:])",
352
+ ],
353
+ "(n),(m),()",
354
+ nopython=True,
355
+ )
356
+ def uleb128_zigzag_diff_array_encode(
357
+ sig_in: NDArray[int], sig_out: NDArray[ubyte], nbytes: int
358
+ ) -> None:
359
+ """Encode an array of integer numbers.
360
+
361
+ The algorithm computes the derivative (prepending 0 first) of `sig_in`,
362
+ maps it to positive numbers by applying :func:`zigzag_encode` and finally
363
+ computes its variable-length binary representation with
364
+ :func:`uleb128_encode`.
365
+
366
+ The encoded data is stored in `sig_out` as an array of bytes. The number of
367
+ bytes written is stored in `nbytes`. The actual encoded data can therefore
368
+ be found in ``sig_out[:nbytes]``.
369
+
370
+ Parameters
371
+ ----------
372
+ sig_in
373
+ the input array of integers.
374
+ sig_out
375
+ pre-allocated bytes array for the output encoded data.
376
+ nbytes
377
+ pre-allocated output array holding the number of bytes written (stored
378
+ in the first index).
379
+
380
+ See Also
381
+ --------
382
+ .uleb128_zigzag_diff_array_decode
383
+ """
384
+ pos = uint32(0)
385
+ last = int32(0)
386
+ for s in sig_in:
387
+ zzdiff = zigzag_encode(int32(s - last))
388
+ pos += uleb128_encode(zzdiff, sig_out[pos:])
389
+ last = s
390
+
391
+ nbytes[0] = pos
392
+
393
+
394
+ @numba.guvectorize(
395
+ [
396
+ "void(byte[:], uint32[:], uint16[:], uint32[:])",
397
+ "void(byte[:], uint32[:], uint32[:], uint32[:])",
398
+ "void(byte[:], uint32[:], uint64[:], uint32[:])",
399
+ "void(byte[:], uint32[:], int16[:], uint32[:])",
400
+ "void(byte[:], uint32[:], int32[:], uint32[:])",
401
+ "void(byte[:], uint32[:], int64[:], uint32[:])",
402
+ ],
403
+ "(n),(),(m),()",
404
+ nopython=True,
405
+ )
406
+ def uleb128_zigzag_diff_array_decode(
407
+ sig_in: NDArray[ubyte],
408
+ nbytes: int,
409
+ sig_out: NDArray[int],
410
+ siglen: int,
411
+ ) -> None:
412
+ """Decode an array of variable-length integers.
413
+
414
+ The algorithm inverts :func:`.uleb128_zigzag_diff_array_encode` by decoding
415
+ the variable-length binary data in `sig_in` with :func:`uleb128_decode`,
416
+ then reconstructing the original signal derivative with
417
+ :func:`zigzag_decode` and finally computing its cumulative (i.e. the
418
+ original signal).
419
+
420
+ Parameters
421
+ ----------
422
+ sig_in
423
+ the array of bytes encoding the variable-length integers.
424
+ nbytes
425
+ the number of bytes to read from `sig_in` (stored in the first index of
426
+ this array).
427
+ sig_out
428
+ pre-allocated array for the output decoded signal.
429
+ siglen
430
+ the length of the decoded signal, (stored in the first index of this
431
+ array).
432
+
433
+ See Also
434
+ --------
435
+ .uleb128_zigzag_diff_array_encode
436
+ """
437
+ if len(sig_in) <= 0:
438
+ raise ValueError("input bytes array is empty")
439
+
440
+ _nbytes = min(nbytes[0], len(sig_in))
441
+ pos = i = uint32(0)
442
+ last = int32(0)
443
+ while pos < _nbytes:
444
+ x, nread = uleb128_decode(sig_in[pos:])
445
+ sig_out[i] = last = zigzag_decode(x) + last
446
+ i += 1
447
+ pos += nread
448
+
449
+ siglen[0] = i
lgdo/lgdo_utils.py ADDED
@@ -0,0 +1,196 @@
1
+ """Implements utilities for LEGEND Data Objects."""
2
+ from __future__ import annotations
3
+
4
+ import glob
5
+ import logging
6
+ import os
7
+ import string
8
+
9
+ import numpy as np
10
+
11
+ from . import types as lgdo
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def get_element_type(obj: object) -> str:
17
+ """Get the LGDO element type of a scalar or array.
18
+
19
+ For use in LGDO datatype attributes.
20
+
21
+ Parameters
22
+ ----------
23
+ obj
24
+ if a ``str``, will automatically return ``string`` if the object has
25
+ a :class:`numpy.dtype`, that will be used for determining the element
26
+ type otherwise will attempt to case the type of the object to a
27
+ :class:`numpy.dtype`.
28
+
29
+ Returns
30
+ -------
31
+ element_type
32
+ A string stating the determined element type of the object.
33
+ """
34
+
35
+ # special handling for strings
36
+ if isinstance(obj, str):
37
+ return "string"
38
+
39
+ # the rest use dtypes
40
+ dt = obj.dtype if hasattr(obj, "dtype") else np.dtype(type(obj))
41
+ kind = dt.kind
42
+
43
+ if kind == "b":
44
+ return "bool"
45
+ if kind == "V":
46
+ return "blob"
47
+ if kind in ["i", "u", "f"]:
48
+ return "real"
49
+ if kind == "c":
50
+ return "complex"
51
+ if kind in ["S", "U"]:
52
+ return "string"
53
+
54
+ # couldn't figure it out
55
+ raise ValueError(
56
+ "cannot determine lgdo element_type for object of type", type(obj).__name__
57
+ )
58
+
59
+
60
+ def copy(obj: lgdo.LGDO, dtype: np.dtype = None) -> lgdo.LGDO:
61
+ """Return a copy of an LGDO.
62
+
63
+ Parameters
64
+ ----------
65
+ obj
66
+ the LGDO to be copied.
67
+ dtype
68
+ NumPy dtype to be used for the copied object.
69
+
70
+ """
71
+ if dtype is None:
72
+ dtype = obj.dtype
73
+
74
+ if isinstance(obj, lgdo.Array):
75
+ return lgdo.Array(
76
+ np.array(obj.nda, dtype=dtype, copy=True), attrs=dict(obj.attrs)
77
+ )
78
+
79
+ if isinstance(obj, lgdo.VectorOfVectors):
80
+ return lgdo.VectorOfVectors(
81
+ flattened_data=copy(obj.flattened_data, dtype=dtype),
82
+ cumulative_length=copy(obj.cumulative_length),
83
+ attrs=dict(obj.attrs),
84
+ )
85
+
86
+ else:
87
+ raise ValueError(f"copy of {type(obj)} not supported")
88
+
89
+
90
+ def parse_datatype(datatype: str) -> tuple[str, tuple[int, ...], str | list[str]]:
91
+ """Parse datatype string and return type, dimensions and elements.
92
+
93
+ Parameters
94
+ ----------
95
+ datatype
96
+ a LGDO-formatted datatype string.
97
+
98
+ Returns
99
+ -------
100
+ element_type
101
+ the datatype name dims if not ``None``, a tuple of dimensions for the
102
+ LGDO. Note this is not the same as the NumPy shape of the underlying
103
+ data object. See the LGDO specification for more information. Also see
104
+ :class:`~.types.ArrayOfEqualSizedArrays` and
105
+ :meth:`.lh5_store.LH5Store.read_object` for example code elements for
106
+ numeric objects, the element type for struct-like objects, the list of
107
+ fields in the struct.
108
+ """
109
+ if "{" not in datatype:
110
+ return "scalar", None, datatype
111
+
112
+ # for other datatypes, need to parse the datatype string
113
+ from parse import parse
114
+
115
+ datatype, element_description = parse("{}{{{}}}", datatype)
116
+ if datatype.endswith(">"):
117
+ datatype, dims = parse("{}<{}>", datatype)
118
+ dims = [int(i) for i in dims.split(",")]
119
+ return datatype, tuple(dims), element_description
120
+ else:
121
+ return datatype, None, element_description.split(",")
122
+
123
+
124
+ def expand_vars(expr: str, substitute: dict[str, str] = None) -> str:
125
+ """Expand (environment) variables.
126
+
127
+ Note
128
+ ----
129
+ Malformed variable names and references to non-existing variables are left
130
+ unchanged.
131
+
132
+ Parameters
133
+ ----------
134
+ expr
135
+ string expression, which may include (environment) variables prefixed by
136
+ ``$``.
137
+ substitute
138
+ use this dictionary to substitute variables. Environment variables take
139
+ precedence.
140
+ """
141
+ if substitute is None:
142
+ substitute = {}
143
+
144
+ # expand env variables first
145
+ # then try using provided mapping
146
+ return string.Template(os.path.expandvars(expr)).safe_substitute(substitute)
147
+
148
+
149
+ def expand_path(
150
+ path: str,
151
+ substitute: dict[str, str] = None,
152
+ list: bool = False,
153
+ base_path: str = None,
154
+ ) -> str | list:
155
+ """Expand (environment) variables and wildcards to return absolute paths.
156
+
157
+ Parameters
158
+ ----------
159
+ path
160
+ name of path, which may include environment variables and wildcards.
161
+ list
162
+ if ``True``, return a list. If ``False``, return a string; if ``False``
163
+ and a unique file is not found, raise an exception.
164
+ substitute
165
+ use this dictionary to substitute variables. Environment variables take
166
+ precedence.
167
+ base_path
168
+ name of base path. Returned paths will be relative to base.
169
+
170
+ Returns
171
+ -------
172
+ path or list of paths
173
+ Unique absolute path, or list of all absolute paths
174
+ """
175
+ if base_path is not None and base_path != "":
176
+ base_path = os.path.expanduser(os.path.expandvars(base_path))
177
+ path = os.path.join(base_path, path)
178
+
179
+ # first expand variables
180
+ _path = expand_vars(path, substitute)
181
+
182
+ # then expand wildcards
183
+ paths = glob.glob(os.path.expanduser(_path))
184
+
185
+ if base_path is not None and base_path != "":
186
+ paths = [os.path.relpath(p, base_path) for p in paths]
187
+
188
+ if not list:
189
+ if len(paths) == 0:
190
+ raise FileNotFoundError(f"could not find path matching {path}")
191
+ elif len(paths) > 1:
192
+ raise FileNotFoundError(f"found multiple paths matching {path}")
193
+ else:
194
+ return paths[0]
195
+ else:
196
+ return paths