chromparser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,548 @@
1
+ r"""Minimal netCDF-3 reader and writer (no runtime dependency).
2
+
3
+ Implements Unidata's "netCDF classic and 64-bit offset format" specification:
4
+ read classic (``CDF\x01``) and 64-bit offset (``CDF\x02``) files with fixed
5
+ and record (unlimited) dimensions; write classic files. All values are
6
+ big-endian; arrays are returned in native byte order with the stored type
7
+ (``int8``, ``S1`` for chars, ``int16``, ``int32``, ``float32``, ``float64``).
8
+ CDF-5 (``CDF\x05``) and netCDF-4 (HDF5) files are not read.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, BinaryIO
17
+
18
+ import numpy as np
19
+
20
+ from .errors import CorruptFile, UnsupportedFormat, UnsupportedVariant
21
+
22
+ CLASSIC = b"CDF\x01"
23
+ OFFSET64 = b"CDF\x02"
24
+ CDF5 = b"CDF\x05"
25
+ STREAMING = 0xFFFFFFFF # numrecs of a file still being written
26
+
27
+ _DIMENSION, _VARIABLE, _ATTRIBUTE = 0x0A, 0x0B, 0x0C
28
+ #: nc_type -> big-endian numpy dtype
29
+ TYPES = {1: ">i1", 2: "S1", 3: ">i2", 4: ">i4", 5: ">f4", 6: ">f8"}
30
+ _TYPE_OF = {np.dtype(v).newbyteorder("=").str[1:]: k for k, v in TYPES.items()}
31
+ _TYPE_OF["S1"] = 2
32
+
33
+ #: Default fill values of the netCDF library (used for unwritten data).
34
+ FILL = {
35
+ 1: -127,
36
+ 2: 0,
37
+ 3: -32767,
38
+ 4: -2147483647,
39
+ 5: 9.969209968386869e36,
40
+ 6: 9.969209968386869e36,
41
+ }
42
+
43
+
44
+ def _pad(n: int) -> int:
45
+ return -n % 4
46
+
47
+
48
+ def is_netcdf3(head: bytes) -> bool:
49
+ """Whether `head` (the first bytes of a file) is a netCDF-3 magic."""
50
+ return head[:4] in (CLASSIC, OFFSET64)
51
+
52
+
53
+ # ------------------------------------------------------------------ reading
54
+ class _Short(Exception):
55
+ """The header continues past the bytes read so far."""
56
+
57
+
58
+ @dataclass
59
+ class Variable:
60
+ """A variable of a netCDF-3 file, without its data (see `File.read`).
61
+
62
+ Attributes
63
+ ----------
64
+ name : str
65
+ Variable name.
66
+ dimensions : tuple of str
67
+ Dimension names; a record variable's first dimension is unlimited.
68
+ shape : tuple of int
69
+ Shape; the record dimension counts the file's records.
70
+ nc_type : int
71
+ netCDF type code (1 byte, 2 char, 3 short, 4 int, 5 float, 6 double).
72
+ attributes : dict
73
+ Attributes: `str` for chars, numpy scalars or 1D arrays otherwise.
74
+ begin : int
75
+ File offset of the data (of the first record for record variables).
76
+ is_record : bool
77
+ Whether the first dimension is the unlimited one.
78
+ """
79
+
80
+ name: str
81
+ dimensions: tuple[str, ...]
82
+ shape: tuple[int, ...]
83
+ nc_type: int
84
+ attributes: dict[str, Any] = field(default_factory=dict)
85
+ begin: int = 0
86
+ is_record: bool = False
87
+
88
+ @property
89
+ def dtype(self) -> np.dtype:
90
+ """Stored dtype in native byte order."""
91
+ return np.dtype(TYPES[self.nc_type]).newbyteorder("=")
92
+
93
+
94
+ class _Parser:
95
+ def __init__(self, data: bytes, offset64: bool):
96
+ self.data = data
97
+ self.pos = 0
98
+ self.offset64 = offset64
99
+
100
+ def take(self, n: int) -> bytes:
101
+ if self.pos + n > len(self.data):
102
+ raise _Short
103
+ chunk = self.data[self.pos : self.pos + n]
104
+ self.pos += n
105
+ return chunk
106
+
107
+ def uint(self) -> int:
108
+ return struct.unpack(">I", self.take(4))[0]
109
+
110
+ def offset(self) -> int:
111
+ if self.offset64:
112
+ return struct.unpack(">Q", self.take(8))[0]
113
+ return self.uint()
114
+
115
+ def count(self) -> int:
116
+ n = self.uint()
117
+ if n >= 1 << 31:
118
+ raise CorruptFile(f"negative count {n - (1 << 32)} in netCDF header")
119
+ return n
120
+
121
+ def name(self) -> str:
122
+ n = self.count()
123
+ raw = self.take(n)
124
+ self.take(_pad(n))
125
+ return raw.decode("utf-8", "replace")
126
+
127
+ def nc_type(self) -> int:
128
+ t = self.uint()
129
+ if t not in TYPES:
130
+ raise UnsupportedVariant(f"netCDF type {t} (only netCDF-3 types are read)")
131
+ return t
132
+
133
+ def values(self, nc_type: int, n: int) -> Any:
134
+ dtype = np.dtype(TYPES[nc_type])
135
+ raw = self.take(n * dtype.itemsize)
136
+ self.take(_pad(n * dtype.itemsize))
137
+ if nc_type == 2:
138
+ return raw.split(b"\0", 1)[0].decode("utf-8", "replace")
139
+ values = np.frombuffer(raw, dtype).astype(dtype.newbyteorder("="))
140
+ return values[0] if n == 1 else values
141
+
142
+ def header_list(self, tag: int) -> int:
143
+ kind, n = self.uint(), self.count()
144
+ if kind == 0 and n == 0:
145
+ return 0
146
+ if kind != tag:
147
+ raise CorruptFile(f"netCDF header: expected list tag {tag}, found {kind}")
148
+ return n
149
+
150
+ def attributes(self) -> dict[str, Any]:
151
+ result = {}
152
+ for _ in range(self.header_list(_ATTRIBUTE)):
153
+ name = self.name()
154
+ nc_type = self.nc_type()
155
+ result[name] = self.values(nc_type, self.count())
156
+ return result
157
+
158
+
159
+ class File:
160
+ """An open netCDF-3 file: dimensions, attributes and lazily read variables.
161
+
162
+ Use as a context manager, or call `close`.
163
+
164
+ Attributes
165
+ ----------
166
+ version : int
167
+ 1 (classic) or 2 (64-bit offset).
168
+ numrecs : int
169
+ Number of records according to the header (computed from the file
170
+ size for a file in streaming state).
171
+ dimensions : dict of str to int or None
172
+ Dimension lengths; `None` for the unlimited dimension.
173
+ attributes : dict
174
+ Global attributes.
175
+ variables : dict of str to Variable
176
+ Variables in file order.
177
+ record_size : int
178
+ Bytes per record (all record variables).
179
+ """
180
+
181
+ def __init__(self, path: str | Path):
182
+ self.path = Path(path)
183
+ self._file: BinaryIO = self.path.open("rb")
184
+ try:
185
+ self._parse()
186
+ except BaseException:
187
+ self._file.close()
188
+ raise
189
+
190
+ def _parse(self) -> None:
191
+ name = self.path.name
192
+ self.size = self.path.stat().st_size
193
+ size = 1 << 16
194
+ while True:
195
+ self._file.seek(0)
196
+ data = self._file.read(size)
197
+ magic = data[:4]
198
+ if magic == CDF5:
199
+ raise UnsupportedVariant(
200
+ f"{name} is a CDF-5 (64-bit data) netCDF file."
201
+ )
202
+ if not is_netcdf3(magic):
203
+ raise UnsupportedFormat(f"{name} is not a netCDF-3 file.")
204
+ try:
205
+ self._parse_header(_Parser(data, magic == OFFSET64))
206
+ return
207
+ except _Short:
208
+ if len(data) < size:
209
+ raise CorruptFile(
210
+ f"{name}: the netCDF header is truncated."
211
+ ) from None
212
+ size *= 4
213
+
214
+ def _parse_header(self, p: _Parser) -> None:
215
+ p.take(4)
216
+ self.version = 2 if p.offset64 else 1
217
+ self.numrecs = p.uint()
218
+ dims: list[tuple[str, int]] = []
219
+ for _ in range(p.header_list(_DIMENSION)):
220
+ dims.append((p.name(), p.count()))
221
+ unlimited = [n for n, length in dims if length == 0]
222
+ if len(unlimited) > 1:
223
+ raise CorruptFile(f"{self.path.name}: several unlimited dimensions.")
224
+ self.dimensions = {n: (None if length == 0 else length) for n, length in dims}
225
+ self.attributes = p.attributes()
226
+ self.variables: dict[str, Variable] = {}
227
+ for _ in range(p.header_list(_VARIABLE)):
228
+ name = p.name()
229
+ ids = [p.count() for _ in range(p.count())]
230
+ if any(i >= len(dims) for i in ids):
231
+ raise CorruptFile(f"{self.path.name}: {name} has an unknown dimension.")
232
+ attributes = p.attributes()
233
+ nc_type = p.nc_type()
234
+ p.uint() # vsize: recomputed below, it overflows for large variables
235
+ begin = p.offset()
236
+ names = tuple(dims[i][0] for i in ids)
237
+ is_record = bool(names) and self.dimensions[names[0]] is None
238
+ if any(self.dimensions[n] is None for n in names[1:]):
239
+ raise CorruptFile(
240
+ f"{self.path.name}: {name} misuses the unlimited dimension."
241
+ )
242
+ shape = tuple(self.dimensions[n] or 0 for n in names)
243
+ self.variables[name] = Variable(
244
+ name, names, shape, nc_type, attributes, begin, is_record
245
+ )
246
+ self.header_size = p.pos
247
+ records = [v for v in self.variables.values() if v.is_record]
248
+ sizes = [self._slab(v) for v in records]
249
+ if len(records) > 1:
250
+ sizes = [s + _pad(s) for s in sizes]
251
+ self.record_size = sum(sizes)
252
+ self.record_begin = min((v.begin for v in records), default=self.size)
253
+ if self.numrecs == STREAMING:
254
+ self.numrecs = self.records_in_file()
255
+ for v in records:
256
+ v.shape = (self.numrecs, *v.shape[1:])
257
+
258
+ def _slab(self, variable: Variable) -> int:
259
+ """Bytes of one record (record variables) or of all data (others)."""
260
+ dims = variable.shape[1:] if variable.is_record else variable.shape
261
+ return int(np.prod(dims, dtype=np.int64)) * variable.dtype.itemsize
262
+
263
+ @property
264
+ def unlimited(self) -> str | None:
265
+ """Name of the unlimited dimension, or `None`."""
266
+ return next((n for n, v in self.dimensions.items() if v is None), None)
267
+
268
+ def records_in_file(self) -> int:
269
+ """Return the number of complete records the file size allows."""
270
+ if not self.record_size:
271
+ return 0
272
+ return max(self.size - self.record_begin, 0) // self.record_size
273
+
274
+ def read(self, name: str, numrecs: int | None = None) -> np.ndarray:
275
+ """Read a variable's data.
276
+
277
+ Parameters
278
+ ----------
279
+ name : str
280
+ Variable name.
281
+ numrecs : int, optional
282
+ Number of records to read instead of the header's (record
283
+ variables only); for files whose header is known to be stale.
284
+
285
+ Returns
286
+ -------
287
+ numpy.ndarray
288
+ Stored type in native byte order (chars as ``S1``).
289
+
290
+ Raises
291
+ ------
292
+ KeyError
293
+ If there is no such variable.
294
+ CorruptFile
295
+ If the file is too short for the data.
296
+ """
297
+ v = self.variables[name]
298
+ stored = np.dtype(TYPES[v.nc_type])
299
+ if not v.is_record:
300
+ count = int(np.prod(v.shape, dtype=np.int64))
301
+ end = v.begin + count * stored.itemsize
302
+ if end > self.size:
303
+ raise CorruptFile(f"{self.path.name}: {name} is truncated.")
304
+ self._file.seek(v.begin)
305
+ data = np.fromfile(self._file, stored, count).reshape(v.shape)
306
+ return _native(data)
307
+ records = self.numrecs if numrecs is None else numrecs
308
+ shape = (records, *v.shape[1:])
309
+ per_record = int(np.prod(v.shape[1:], dtype=np.int64))
310
+ last = v.begin + (records - 1) * self.record_size + per_record * stored.itemsize
311
+ if records and last > self.size:
312
+ raise CorruptFile(f"{self.path.name}: the records of {name} are truncated.")
313
+ self._file.seek(v.begin)
314
+ if self.record_size == per_record * stored.itemsize: # the only record variable
315
+ data = np.fromfile(self._file, stored, records * per_record)
316
+ return _native(data.reshape(shape))
317
+ span = (records - 1) * self.record_size + per_record * stored.itemsize
318
+ raw = np.fromfile(self._file, np.uint8, span if records else 0)
319
+ inner = np.empty(v.shape[1:], stored).strides
320
+ view = np.ndarray(shape, stored, raw, 0, (self.record_size, *inner))
321
+ return view.astype(stored.newbyteorder("="))
322
+
323
+ def close(self) -> None:
324
+ """Close the file."""
325
+ self._file.close()
326
+
327
+ def __enter__(self) -> File:
328
+ return self
329
+
330
+ def __exit__(self, *exc) -> None:
331
+ self.close()
332
+
333
+
334
+ def _native(data: np.ndarray) -> np.ndarray:
335
+ if data.dtype.byteorder == ">" and np.little_endian:
336
+ data = data.byteswap().view(data.dtype.newbyteorder("="))
337
+ return data
338
+
339
+
340
+ def strings(data: np.ndarray) -> list[str]:
341
+ """Join a char array (``S1``, last axis the string) into strings.
342
+
343
+ Strings end at the first NUL; trailing blanks are removed.
344
+ """
345
+ data = np.asarray(data)
346
+ if data.ndim == 0:
347
+ return [data.tobytes().split(b"\0", 1)[0].decode("utf-8", "replace").rstrip()]
348
+ rows = data.reshape(-1, data.shape[-1]) if data.ndim > 1 else data.reshape(1, -1)
349
+ return [
350
+ row.tobytes().split(b"\0", 1)[0].decode("utf-8", "replace").rstrip()
351
+ for row in rows
352
+ ]
353
+
354
+
355
+ # ------------------------------------------------------------------ writing
356
+ def char_array(texts: list[str], width: int) -> np.ndarray:
357
+ """Encode strings as a ``(len(texts), width)`` char array (NUL padded).
358
+
359
+ Strings longer than `width` bytes (UTF-8) are truncated.
360
+ """
361
+ out = np.zeros((len(texts), width), "S1")
362
+ for i, text in enumerate(texts):
363
+ raw = text.encode("utf-8")[:width]
364
+ out[i, : len(raw)] = np.frombuffer(raw, "S1")
365
+ return out
366
+
367
+
368
+ def nc_type_of(dtype: np.dtype) -> int:
369
+ """Return the netCDF type code of a numpy dtype; `ValueError` for other types."""
370
+ dtype = np.dtype(dtype)
371
+ key = (
372
+ "S1"
373
+ if dtype.kind == "S" and dtype.itemsize == 1
374
+ else dtype.newbyteorder("=").str[1:]
375
+ )
376
+ if key not in _TYPE_OF:
377
+ raise ValueError(f"netCDF-3 has no type for {dtype}.")
378
+ return _TYPE_OF[key]
379
+
380
+
381
+ def _attribute_value(value: Any) -> tuple[int, bytes, int]:
382
+ """(nc_type, big-endian bytes, number of elements) of an attribute value."""
383
+ if isinstance(value, str):
384
+ raw = value.encode("utf-8")
385
+ return 2, raw, len(raw)
386
+ if isinstance(value, (bool, np.bool_)):
387
+ raise ValueError("netCDF-3 has no boolean type.")
388
+ if isinstance(value, int):
389
+ value = np.int32(value) if -(2**31) <= value < 2**31 else np.float64(value)
390
+ elif isinstance(value, float):
391
+ value = np.float64(value)
392
+ array = np.atleast_1d(np.asarray(value))
393
+ nc_type = nc_type_of(array.dtype)
394
+ return nc_type, array.astype(TYPES[nc_type]).tobytes(), array.size
395
+
396
+
397
+ def _name(text: str) -> bytes:
398
+ raw = text.encode("utf-8")
399
+ return struct.pack(">I", len(raw)) + raw + bytes(_pad(len(raw)))
400
+
401
+
402
+ def _attributes(attributes: dict[str, Any]) -> bytes:
403
+ items = {k: v for k, v in attributes.items() if v is not None}
404
+ if not items:
405
+ return bytes(8)
406
+ out = [struct.pack(">II", _ATTRIBUTE, len(items))]
407
+ for key, value in items.items():
408
+ nc_type, raw, n = _attribute_value(value)
409
+ out += [_name(key), struct.pack(">II", nc_type, n), raw, bytes(_pad(len(raw)))]
410
+ return b"".join(out)
411
+
412
+
413
+ @dataclass
414
+ class NewVariable:
415
+ """A variable to write: dimension names, data and attributes."""
416
+
417
+ dimensions: tuple[str, ...]
418
+ data: np.ndarray
419
+ attributes: dict[str, Any] = field(default_factory=dict)
420
+
421
+
422
+ def write(
423
+ path: str | Path,
424
+ dimensions: dict[str, int | None],
425
+ attributes: dict[str, Any],
426
+ variables: dict[str, NewVariable],
427
+ ) -> Path:
428
+ r"""Write a netCDF classic (``CDF\x01``) file.
429
+
430
+ Parameters
431
+ ----------
432
+ path : str or Path
433
+ Output file (replaced if it exists).
434
+ dimensions : dict of str to int or None
435
+ Dimension lengths in order; `None` marks the (single) unlimited
436
+ dimension, whose length is the first axis of the record variables.
437
+ attributes : dict
438
+ Global attributes: `str` (char), `int` (int32), `float` (double), or
439
+ numpy scalars and 1D arrays of a netCDF-3 type. `None` is skipped.
440
+ variables : dict of str to NewVariable
441
+ Variables in file order; data must have a netCDF-3 dtype (int8,
442
+ ``S1``, int16, int32, float32, float64) and the dimensions' shape.
443
+
444
+ Returns
445
+ -------
446
+ Path
447
+ The written file.
448
+
449
+ Raises
450
+ ------
451
+ ValueError
452
+ For inconsistent shapes, unsupported types, or a file too large for
453
+ the classic format (offsets beyond 2 GiB).
454
+ """
455
+ path = Path(path)
456
+ names = list(dimensions)
457
+ unlimited = [n for n, v in dimensions.items() if v is None]
458
+ if len(unlimited) > 1:
459
+ raise ValueError("netCDF-3 allows one unlimited dimension.")
460
+ numrecs = None
461
+ prepared = []
462
+ for name, var in variables.items():
463
+ data = np.asarray(var.data)
464
+ nc_type = nc_type_of(data.dtype)
465
+ if data.ndim != len(var.dimensions):
466
+ raise ValueError(
467
+ f"{name}: data has {data.ndim} axes, dimensions {var.dimensions}."
468
+ )
469
+ is_record = bool(var.dimensions) and dimensions[var.dimensions[0]] is None
470
+ for axis, dim in enumerate(var.dimensions):
471
+ if dim not in dimensions:
472
+ raise ValueError(f"{name}: unknown dimension {dim!r}.")
473
+ length = dimensions[dim]
474
+ if length is None:
475
+ if axis:
476
+ raise ValueError(
477
+ f"{name}: the unlimited dimension must come first."
478
+ )
479
+ if numrecs not in (None, data.shape[0]):
480
+ raise ValueError(f"{name}: record variables differ in length.")
481
+ numrecs = data.shape[0]
482
+ elif data.shape[axis] != length:
483
+ raise ValueError(
484
+ f"{name}: axis {axis} has {data.shape[axis]} entries, {dim} is {length}."
485
+ )
486
+ stored = data.astype(TYPES[nc_type])
487
+ slab = stored[0].nbytes if is_record else stored.nbytes
488
+ if is_record and not len(stored):
489
+ slab = int(np.prod(data.shape[1:], dtype=np.int64)) * stored.itemsize
490
+ prepared.append((name, var, nc_type, stored, is_record, slab))
491
+ numrecs = numrecs or 0
492
+ n_record_vars = sum(1 for p in prepared if p[4])
493
+
494
+ dim_list = (
495
+ struct.pack(">II", _DIMENSION, len(names))
496
+ + b"".join(_name(n) + struct.pack(">I", dimensions[n] or 0) for n in names)
497
+ if names
498
+ else bytes(8)
499
+ )
500
+ head = CLASSIC + struct.pack(">I", numrecs) + dim_list + _attributes(attributes)
501
+ var_headers = []
502
+ for name, var, nc_type, _, _, _ in prepared:
503
+ ids = [names.index(d) for d in var.dimensions]
504
+ var_headers.append(
505
+ _name(name)
506
+ + struct.pack(f">I{len(ids)}I", len(ids), *ids)
507
+ + _attributes(var.attributes)
508
+ + struct.pack(">I", nc_type)
509
+ )
510
+ # var_list tag and count (or ABSENT), then per variable vsize and begin
511
+ header_size = len(head) + 8 + sum(len(h) + 8 for h in var_headers)
512
+
513
+ offset = header_size
514
+ begins = {}
515
+ for name, _, _, _, is_record, slab in prepared:
516
+ if not is_record:
517
+ begins[name] = offset
518
+ offset += slab + _pad(slab)
519
+ record_size = 0
520
+ for name, _, _, _, is_record, slab in prepared:
521
+ if is_record:
522
+ begins[name] = offset + record_size
523
+ record_size += slab if n_record_vars == 1 else slab + _pad(slab)
524
+ if max(begins.values(), default=0) >= 1 << 31:
525
+ raise ValueError("Data too large for the netCDF classic format.")
526
+
527
+ with path.open("wb") as f:
528
+ f.write(head)
529
+ f.write(struct.pack(">II", _VARIABLE, len(prepared)) if prepared else bytes(8))
530
+ for (name, _, _, _, is_record, slab), h in zip(
531
+ prepared, var_headers, strict=True
532
+ ):
533
+ vsize = slab if is_record and n_record_vars == 1 else slab + _pad(slab)
534
+ f.write(h + struct.pack(">II", min(vsize, 0xFFFFFFFF), begins[name]))
535
+ for _, _, _, stored, is_record, slab in prepared:
536
+ if not is_record:
537
+ stored.tofile(f)
538
+ f.write(bytes(_pad(slab)))
539
+ if record_size and numrecs:
540
+ block = np.zeros((numrecs, record_size), np.uint8)
541
+ start = min(begins[p[0]] for p in prepared if p[4])
542
+ for name, _, _, stored, is_record, slab in prepared:
543
+ if is_record:
544
+ at = begins[name] - start
545
+ rows = stored.reshape(numrecs, -1).view(np.uint8)
546
+ block[:, at : at + slab] = rows.reshape(numrecs, slab)
547
+ block.tofile(f)
548
+ return path
chromparser/_util.py ADDED
@@ -0,0 +1,98 @@
1
+ """Small helpers shared by the readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any, BinaryIO
9
+
10
+ Source = str | Path | bytes | bytearray | BinaryIO
11
+
12
+ _FILETIME_EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
13
+
14
+
15
+ def read_bytes(source: Source) -> bytes:
16
+ """Read all bytes from a path, a bytes object or a binary file object.
17
+
18
+ Parameters
19
+ ----------
20
+ source : str, Path, bytes or file object
21
+ What to read.
22
+
23
+ Returns
24
+ -------
25
+ bytes
26
+ The content.
27
+
28
+ Raises
29
+ ------
30
+ FileNotFoundError
31
+ If `source` is a path that does not exist.
32
+ """
33
+ if isinstance(source, (bytes, bytearray)):
34
+ return bytes(source)
35
+ if hasattr(source, "read"):
36
+ return source.read()
37
+ path = Path(source)
38
+ if not path.is_file():
39
+ raise FileNotFoundError(f"File not found: {path}")
40
+ return path.read_bytes()
41
+
42
+
43
+ def existing_file(path: str | Path) -> Path:
44
+ """Return `path` as a Path, or raise FileNotFoundError."""
45
+ path = Path(path)
46
+ if not path.is_file():
47
+ raise FileNotFoundError(f"File not found: {path}")
48
+ return path
49
+
50
+
51
+ def head(path: str | Path, size: int) -> bytes:
52
+ """Return the first `size` bytes of a file (fewer if it is shorter)."""
53
+ with Path(path).open("rb") as f:
54
+ return f.read(size)
55
+
56
+
57
+ def clean(metadata: dict[str, Any]) -> dict[str, Any]:
58
+ """Drop entries whose value is unknown (`None` or ``""``)."""
59
+ return {key: value for key, value in metadata.items() if value not in (None, "")}
60
+
61
+
62
+ def filetime(ticks: int) -> datetime:
63
+ """Convert a Windows FILETIME (100 ns since 1601, UTC) to a datetime."""
64
+ return _FILETIME_EPOCH + timedelta(microseconds=ticks // 10)
65
+
66
+
67
+ _ISO_8601 = re.compile(
68
+ r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?$"
69
+ )
70
+
71
+
72
+ def parse_iso_datetime(text: str) -> datetime | None:
73
+ """Parse ISO 8601 timestamps with up to 7 fractional digits (.NET style).
74
+
75
+ Parameters
76
+ ----------
77
+ text : str
78
+ E.g. ``"2023-09-07T15:26:11.0000000+02:00"``.
79
+
80
+ Returns
81
+ -------
82
+ datetime or None
83
+ Timezone-aware when `text` has an offset; `None` if it cannot be parsed.
84
+ """
85
+ match = _ISO_8601.match(text.strip())
86
+ if not match:
87
+ return None
88
+ base, fraction, offset = match.groups()
89
+ base = base.replace(" ", "T")
90
+ if fraction:
91
+ base += "." + (fraction + "000000")[:6]
92
+ if offset:
93
+ if offset == "Z":
94
+ offset = "+00:00"
95
+ elif ":" not in offset:
96
+ offset = offset[:3] + ":" + offset[3:]
97
+ base += offset
98
+ return datetime.fromisoformat(base)