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.
- chromparser/__init__.py +183 -0
- chromparser/__main__.py +7 -0
- chromparser/_netcdf3.py +548 -0
- chromparser/_util.py +98 -0
- chromparser/cli.py +192 -0
- chromparser/errors.py +49 -0
- chromparser/hdf5.py +428 -0
- chromparser/model.py +254 -0
- chromparser/py.typed +0 -0
- chromparser/sniff.py +80 -0
- chromparser/vendors/__init__.py +55 -0
- chromparser/vendors/agilent/__init__.py +17 -0
- chromparser/vendors/agilent/ch.py +344 -0
- chromparser/vendors/agilent/dot_d.py +69 -0
- chromparser/vendors/agilent/dx.py +147 -0
- chromparser/vendors/andi/__init__.py +15 -0
- chromparser/vendors/andi/chrom.py +181 -0
- chromparser/vendors/andi/common.py +232 -0
- chromparser/vendors/andi/ms.py +358 -0
- chromparser/vendors/andi/write.py +320 -0
- chromparser/vendors/chromeleon/__init__.py +19 -0
- chromparser/vendors/chromeleon/archive.py +310 -0
- chromparser/vendors/chromeleon/raw.py +407 -0
- chromparser/vendors/chromeleon/sequence.py +177 -0
- chromparser/vendors/chromeleon/txt.py +319 -0
- chromparser/vendors/shimadzu/__init__.py +8 -0
- chromparser/vendors/shimadzu/gcd.py +146 -0
- chromparser/vendors/shimadzu/ole.py +84 -0
- chromparser/vendors/shimadzu/qgd.py +196 -0
- chromparser/vendors/thermo/__init__.py +7 -0
- chromparser/vendors/thermo/raw.py +377 -0
- chromparser-0.1.0.dist-info/METADATA +162 -0
- chromparser-0.1.0.dist-info/RECORD +36 -0
- chromparser-0.1.0.dist-info/WHEEL +4 -0
- chromparser-0.1.0.dist-info/entry_points.txt +3 -0
- chromparser-0.1.0.dist-info/licenses/LICENSE +51 -0
chromparser/_netcdf3.py
ADDED
|
@@ -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)
|