tdmspy 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.
tdmspy/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """tdmspy: a pure Python reader for National Instruments TDMS files (MIT licensed).
2
+
3
+ import tdmspy
4
+
5
+ file = tdmspy.read("measurement.tdms")
6
+ channel = file["rawdata"]["Channel1"]
7
+ values = channel.read()
8
+
9
+ The contract of the reader (what it reads, what it refuses, and which special cases of the format it
10
+ handles) is in `doc/design.md` and `doc/special-cases.md`; the tests in `tests/` check every case.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from tdmspy.errors import TdmsError, TdmsFormatError, UnsupportedFeatureError, UnsupportedScalingError
16
+ from tdmspy.file import Channel, Group, TdmsFile, read
17
+ from tdmspy.timestamp import Timestamp
18
+
19
+ __all__ = [
20
+ "Channel",
21
+ "Group",
22
+ "TdmsError",
23
+ "TdmsFile",
24
+ "TdmsFormatError",
25
+ "Timestamp",
26
+ "UnsupportedFeatureError",
27
+ "UnsupportedScalingError",
28
+ "read",
29
+ ]
30
+
31
+ __implemented__ = True
tdmspy/_format.py ADDED
@@ -0,0 +1,61 @@
1
+ """Constants and the data type table of the TDMS format (see doc/design.md for what is supported)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ LEAD_IN_SIZE = 28
8
+ SIGNATURE = b"TDSm"
9
+ INDEX_SIGNATURE = b"TDSh"
10
+ VERSIONS = (4712, 4713)
11
+ UNKNOWN_NEXT_OFFSET = 0xFFFF_FFFF_FFFF_FFFF
12
+
13
+ # table of contents flags
14
+ TOC_METADATA = 1 << 1
15
+ TOC_NEW_OBJECT_LIST = 1 << 2
16
+ TOC_RAW_DATA = 1 << 3
17
+ TOC_INTERLEAVED = 1 << 5
18
+ TOC_BIG_ENDIAN = 1 << 6
19
+ TOC_DAQMX = 1 << 7
20
+
21
+ # raw data index values
22
+ INDEX_NONE = 0xFFFF_FFFF
23
+ INDEX_SAME = 0
24
+ INDEX_DAQMX = frozenset({0x1269, 0x126A, 0x1369, 0x136A})
25
+
26
+ TYPE_STRING = 0x20
27
+ TYPE_BOOL = 0x21
28
+ TYPE_TIMESTAMP = 0x44
29
+ TYPE_EXTENDED = 0x0B
30
+ TYPE_COMPLEX_SINGLE = 0x0800_000C
31
+ TYPE_COMPLEX_DOUBLE = 0x1000_000D
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class DataType:
36
+ """A type a channel can have: the numpy type code of one element (without byte order) and its size in the file."""
37
+
38
+ code: int
39
+ name: str
40
+ numpy: str
41
+ size: int
42
+
43
+
44
+ _NUMERIC = {
45
+ 1: ("int8", "i1", 1), 2: ("int16", "i2", 2), 3: ("int32", "i4", 4), 4: ("int64", "i8", 8),
46
+ 5: ("uint8", "u1", 1), 6: ("uint16", "u2", 2), 7: ("uint32", "u4", 4), 8: ("uint64", "u8", 8),
47
+ 9: ("float32", "f4", 4), 10: ("float64", "f8", 8), 0x19: ("float32", "f4", 4), 0x1A: ("float64", "f8", 8),
48
+ TYPE_BOOL: ("bool", "u1", 1), TYPE_TIMESTAMP: ("timestamp", "", 16),
49
+ TYPE_COMPLEX_SINGLE: ("complex64", "c8", 8), TYPE_COMPLEX_DOUBLE: ("complex128", "c16", 16),
50
+ } # fmt: skip
51
+
52
+ DATA_TYPES: dict[int, DataType] = {
53
+ code: DataType(code, name, np_code, size) for code, (name, np_code, size) in _NUMERIC.items()
54
+ }
55
+ DATA_TYPES[TYPE_STRING] = DataType(TYPE_STRING, "string", "", 0)
56
+
57
+ # property value types: code -> (struct code, size); strings, timestamps and complex numbers are handled apart
58
+ PROPERTY_STRUCT = {
59
+ 1: ("b", 1), 2: ("h", 2), 3: ("i", 4), 4: ("q", 8), 5: ("B", 1), 6: ("H", 2), 7: ("I", 4), 8: ("Q", 8),
60
+ 9: ("f", 4), 10: ("d", 8), 0x19: ("f", 4), 0x1A: ("d", 8), TYPE_BOOL: ("B", 1),
61
+ } # fmt: skip
tdmspy/_metadata.py ADDED
@@ -0,0 +1,205 @@
1
+ """Parsing the metadata section of a segment into objects, with every count and length checked against the bytes
2
+ that are there before anything is read or allocated."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import struct
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from tdmspy._format import (
11
+ DATA_TYPES,
12
+ INDEX_DAQMX,
13
+ INDEX_NONE,
14
+ INDEX_SAME,
15
+ PROPERTY_STRUCT,
16
+ TYPE_COMPLEX_DOUBLE,
17
+ TYPE_COMPLEX_SINGLE,
18
+ TYPE_EXTENDED,
19
+ TYPE_STRING,
20
+ TYPE_TIMESTAMP,
21
+ )
22
+ from tdmspy.errors import TdmsFormatError, UnsupportedFeatureError
23
+ from tdmspy.timestamp import Timestamp
24
+
25
+ _MIN_OBJECT = 12 # path length, raw data index, property count
26
+ _MIN_PROPERTY = 8 # name length, type
27
+
28
+ NONE = "none"
29
+ SAME = "same"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Index:
34
+ """What an object says about its raw data in a segment."""
35
+
36
+ kind: str # NONE, SAME or "explicit"
37
+ type_code: int = 0
38
+ count: int = 0
39
+ total_size: int = 0 # strings: bytes of the offsets and the text of one chunk
40
+
41
+
42
+ @dataclass
43
+ class ObjectMeta:
44
+ path: tuple[str, ...]
45
+ index: Index
46
+ properties: list[tuple[str, Any]]
47
+
48
+
49
+ def parse_path(path: str) -> tuple[str, ...]:
50
+ """`/` -> (), `/'g'` -> ('g',), `/'g'/'c'` -> ('g', 'c'); `''` inside a name is one apostrophe."""
51
+ bad = TdmsFormatError(f"invalid object path {path!r}")
52
+ if not path.startswith("/"):
53
+ raise bad
54
+ names: list[str] = []
55
+ i, n = 1, len(path)
56
+ while i < n:
57
+ if path[i] != "'":
58
+ raise bad
59
+ i += 1
60
+ name = []
61
+ while True:
62
+ if i >= n:
63
+ raise bad
64
+ if path[i] == "'":
65
+ if i + 1 < n and path[i + 1] == "'":
66
+ name.append("'")
67
+ i += 2
68
+ continue
69
+ i += 1
70
+ break
71
+ name.append(path[i])
72
+ i += 1
73
+ names.append("".join(name))
74
+ if i < n:
75
+ if path[i] != "/" or i + 1 >= n:
76
+ raise bad
77
+ i += 1
78
+ if len(names) > 2:
79
+ raise bad
80
+ return tuple(names)
81
+
82
+
83
+ class _Reader:
84
+ def __init__(self, buffer: bytes | memoryview, big_endian: bool) -> None:
85
+ self.buffer = buffer
86
+ self.pos = 0
87
+ self.end = len(buffer)
88
+ self.e = ">" if big_endian else "<"
89
+
90
+ def remaining(self) -> int:
91
+ return self.end - self.pos
92
+
93
+ def need(self, count: int, what: str) -> None:
94
+ if count > self.end - self.pos:
95
+ raise TdmsFormatError(f"metadata ends inside {what}")
96
+
97
+ def u32(self, what: str) -> int:
98
+ self.need(4, what)
99
+ (value,) = struct.unpack_from(self.e + "I", self.buffer, self.pos)
100
+ self.pos += 4
101
+ return value
102
+
103
+ def u64(self, what: str) -> int:
104
+ self.need(8, what)
105
+ (value,) = struct.unpack_from(self.e + "Q", self.buffer, self.pos)
106
+ self.pos += 8
107
+ return value
108
+
109
+ def string(self, what: str) -> str:
110
+ length = self.u32(what)
111
+ self.need(length, what)
112
+ raw = bytes(self.buffer[self.pos : self.pos + length])
113
+ self.pos += length
114
+ try:
115
+ return raw.decode("utf-8")
116
+ except UnicodeDecodeError:
117
+ raise TdmsFormatError(f"{what} is not valid UTF-8") from None
118
+
119
+
120
+ def _property_value(reader: _Reader, type_code: int, name: str) -> Any:
121
+ if type_code in PROPERTY_STRUCT:
122
+ code, size = PROPERTY_STRUCT[type_code]
123
+ reader.need(size, f"property {name!r}")
124
+ (value,) = struct.unpack_from(reader.e + code, reader.buffer, reader.pos)
125
+ reader.pos += size
126
+ return bool(value) if type_code == 0x21 else value
127
+ if type_code == TYPE_STRING:
128
+ return reader.string(f"property {name!r}")
129
+ if type_code == TYPE_TIMESTAMP:
130
+ reader.need(16, f"property {name!r}")
131
+ if reader.e == ">":
132
+ seconds, fraction = struct.unpack_from(">qQ", reader.buffer, reader.pos)
133
+ else:
134
+ fraction, seconds = struct.unpack_from("<Qq", reader.buffer, reader.pos)
135
+ reader.pos += 16
136
+ return Timestamp(seconds, fraction)
137
+ if type_code in (TYPE_COMPLEX_SINGLE, TYPE_COMPLEX_DOUBLE):
138
+ code, size = ("f", 8) if type_code == TYPE_COMPLEX_SINGLE else ("d", 16)
139
+ reader.need(size, f"property {name!r}")
140
+ real, imag = struct.unpack_from(reader.e + code * 2, reader.buffer, reader.pos)
141
+ reader.pos += size
142
+ return complex(real, imag)
143
+ if type_code == 0:
144
+ return None
145
+ if type_code in (TYPE_EXTENDED, 0x1B):
146
+ raise UnsupportedFeatureError(f"property {name!r} has the extended float type, which is not supported")
147
+ raise TdmsFormatError(f"property {name!r} has the unknown type {type_code:#x}")
148
+
149
+
150
+ def _index(reader: _Reader, path: str) -> Index:
151
+ value = reader.u32(f"the raw data index of {path!r}")
152
+ if value == INDEX_NONE:
153
+ return Index(NONE)
154
+ if value == INDEX_SAME:
155
+ return Index(SAME)
156
+ if value in INDEX_DAQMX:
157
+ raise UnsupportedFeatureError(f"{path!r} holds DAQmx raw data, which is not supported")
158
+ if value < 20 or value > reader.remaining() + 4:
159
+ raise TdmsFormatError(f"the raw data index of {path!r} has the length {value}, which does not fit the metadata")
160
+ following = value - 4
161
+ start = reader.pos
162
+ type_code = reader.u32("a raw data index")
163
+ dimension = reader.u32("a raw data index")
164
+ count = reader.u64("a raw data index")
165
+ total = 0
166
+ if type_code == TYPE_STRING:
167
+ total = reader.u64("a raw data index")
168
+ if reader.pos - start > following:
169
+ raise TdmsFormatError(f"the raw data index of {path!r} is longer than its length says")
170
+ reader.pos = start + following
171
+ if type_code == TYPE_STRING and total < 4 * count:
172
+ raise TdmsFormatError(
173
+ f"{path!r}: {count} strings need {4 * count} bytes of offsets, the index says {total} bytes in all"
174
+ )
175
+ if dimension != 1:
176
+ raise TdmsFormatError(f"{path!r} has an array dimension of {dimension}; only 1 is valid")
177
+ if type_code not in DATA_TYPES:
178
+ if type_code == TYPE_EXTENDED or type_code == 0x1B:
179
+ raise UnsupportedFeatureError(f"{path!r} has the extended float data type, which is not supported")
180
+ raise TdmsFormatError(f"{path!r} has the unknown data type {type_code:#x}")
181
+ return Index("explicit", type_code, count, total)
182
+
183
+
184
+ def parse_metadata(buffer: bytes | memoryview, big_endian: bool) -> list[ObjectMeta]:
185
+ reader = _Reader(buffer, big_endian)
186
+ count = reader.u32("the object count")
187
+ if count * _MIN_OBJECT > reader.remaining():
188
+ raise TdmsFormatError(f"the metadata announces {count} objects but is only {reader.remaining()} bytes long")
189
+ objects: list[ObjectMeta] = []
190
+ for _ in range(count):
191
+ text = reader.string("an object path")
192
+ path = parse_path(text)
193
+ index = _index(reader, text)
194
+ if len(path) < 2 and index.kind == "explicit":
195
+ raise TdmsFormatError(f"{text!r} is not a channel but has raw data")
196
+ n_props = reader.u32(f"the properties of {text!r}")
197
+ if n_props * _MIN_PROPERTY > reader.remaining():
198
+ raise TdmsFormatError(f"{text!r} announces {n_props} properties but the metadata ends sooner")
199
+ properties = []
200
+ for _ in range(n_props):
201
+ name = reader.string("a property name")
202
+ type_code = reader.u32(f"property {name!r}")
203
+ properties.append((name, _property_value(reader, type_code, name)))
204
+ objects.append(ObjectMeta(path, index, properties))
205
+ return objects
tdmspy/_scaling.py ADDED
@@ -0,0 +1,69 @@
1
+ """NI scaling: the scales declared in the properties of a channel, its group or the file (see doc/design.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from tdmspy.errors import UnsupportedScalingError
11
+
12
+ RAW_DATA_INPUT_SOURCE = 0xFFFF_FFFF
13
+
14
+
15
+ def scale_count(properties: Mapping[str, Any]) -> int | None:
16
+ """How many scales these properties declare, `None` if none or the data is already scaled."""
17
+ count = properties.get("NI_Number_Of_Scales")
18
+ if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
19
+ return None
20
+ if properties.get("NI_Scaling_Status", "unscaled") == "scaled":
21
+ return None
22
+ return count
23
+
24
+
25
+ def _number(properties: Mapping[str, Any], key: str) -> float:
26
+ value = properties.get(key)
27
+ if isinstance(value, bool) or not isinstance(value, int | float):
28
+ raise UnsupportedScalingError(f"the scaling property {key!r} is missing or not a number")
29
+ return float(value)
30
+
31
+
32
+ def apply_scaling(raw: np.ndarray, *levels: Mapping[str, Any]) -> np.ndarray:
33
+ """`raw` scaled by the first of `levels` (channel, group, file properties) that declares scales, else `raw` itself."""
34
+ for properties in levels:
35
+ count = scale_count(properties)
36
+ if count is not None:
37
+ return _scale(raw, properties, count)
38
+ return raw
39
+
40
+
41
+ def _scale(raw: np.ndarray, properties: Mapping[str, Any], count: int) -> np.ndarray:
42
+ def compute(index: int, depth: int) -> np.ndarray:
43
+ if index == RAW_DATA_INPUT_SOURCE:
44
+ return raw.astype(np.float64)
45
+ if not 0 <= index < count or depth > count:
46
+ raise UnsupportedScalingError(
47
+ f"NI scale {index} does not exist or the scales refer to each other in a circle"
48
+ )
49
+ prefix = f"NI_Scale[{index}]_"
50
+ kind = properties.get(prefix + "Scale_Type")
51
+ if kind not in ("Linear", "Polynomial"):
52
+ what = "a DAQmx scaler" if kind is None else f"the scale type {kind!r}"
53
+ raise UnsupportedScalingError(f"{what} is not supported (only Linear and Polynomial scales are)")
54
+ source = properties.get(f"{prefix}{kind}_Input_Source", RAW_DATA_INPUT_SOURCE)
55
+ values = compute(int(source), depth + 1)
56
+ if kind == "Linear":
57
+ return values * _number(properties, prefix + "Linear_Slope") + _number(
58
+ properties, prefix + "Linear_Y_Intercept"
59
+ )
60
+ size = properties.get(prefix + "Polynomial_Coefficients_Size", 4)
61
+ if isinstance(size, bool) or not isinstance(size, int) or not 0 <= size <= 1024:
62
+ raise UnsupportedScalingError(f"the polynomial of scale {index} has an invalid number of coefficients")
63
+ coefficients = [_number(properties, f"{prefix}Polynomial_Coefficients[{k}]") for k in range(size)]
64
+ result = np.zeros(len(values))
65
+ for coefficient in reversed(coefficients): # Horner's rule
66
+ result = result * values + coefficient
67
+ return result
68
+
69
+ return compute(count - 1, 0)
tdmspy/_scan.py ADDED
@@ -0,0 +1,269 @@
1
+ """Scanning a file: every segment's lead-in and metadata become, per channel, a list of data blocks.
2
+
3
+ The result of a scan holds no channel data. A *block* says where a run of a channel's values lies in the file:
4
+ `[offset, per, repeat, stride, big_endian]` is `repeat` units of `per` consecutive values, the units `stride`
5
+ bytes apart, the first starting at `offset`. A segment of one chunk is one unit; several chunks, or interleaved
6
+ data, are many units; the identical segments of an acquisition are merged into one block.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import mmap
12
+ import struct
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from tdmspy._format import (
17
+ DATA_TYPES,
18
+ INDEX_SIGNATURE,
19
+ LEAD_IN_SIZE,
20
+ SIGNATURE,
21
+ TOC_BIG_ENDIAN,
22
+ TOC_DAQMX,
23
+ TOC_INTERLEAVED,
24
+ TOC_METADATA,
25
+ TOC_NEW_OBJECT_LIST,
26
+ TOC_RAW_DATA,
27
+ TYPE_STRING,
28
+ UNKNOWN_NEXT_OFFSET,
29
+ VERSIONS,
30
+ )
31
+ from tdmspy._metadata import NONE, SAME, Index, ObjectMeta, parse_metadata
32
+ from tdmspy.errors import TdmsFormatError, UnsupportedFeatureError
33
+
34
+
35
+ @dataclass(eq=False)
36
+ class ChannelIndex:
37
+ """Everything the scan learned about one channel."""
38
+
39
+ name: str
40
+ properties: dict[str, Any] = field(default_factory=dict)
41
+ type_code: int | None = None
42
+ last: Index | None = None # the last explicit raw data index
43
+ current: Index | None = None # the index in force now (None: no data in the current segments)
44
+ blocks: list[list[int]] = field(default_factory=list)
45
+ string_chunks: list[tuple[int, int, int, bool]] = field(default_factory=list) # offset, count, bytes, big endian
46
+ length: int = 0
47
+
48
+
49
+ @dataclass(eq=False)
50
+ class GroupIndex:
51
+ name: str
52
+ properties: dict[str, Any] = field(default_factory=dict)
53
+ channels: dict[str, ChannelIndex] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass
57
+ class Scan:
58
+ properties: dict[str, Any] = field(default_factory=dict)
59
+ groups: dict[str, GroupIndex] = field(default_factory=dict)
60
+ truncated: bool = False
61
+
62
+
63
+ def _item_size(channel: ChannelIndex) -> int:
64
+ assert channel.type_code is not None
65
+ return DATA_TYPES[channel.type_code].size
66
+
67
+
68
+ class _State:
69
+ def __init__(self) -> None:
70
+ self.scan = Scan()
71
+ self.active: list[ChannelIndex] = []
72
+ self.layout: tuple[list[ChannelIndex], list[int], int] | None = None # members, sizes, chunk size
73
+
74
+ def group(self, name: str) -> GroupIndex:
75
+ group = self.scan.groups.get(name)
76
+ if group is None:
77
+ group = self.scan.groups[name] = GroupIndex(name)
78
+ return group
79
+
80
+ def apply(self, objects: list[ObjectMeta], new_list: bool) -> None:
81
+ listed: list[ChannelIndex] = []
82
+ for obj in objects:
83
+ if len(obj.path) == 0:
84
+ self.scan.properties.update(obj.properties)
85
+ elif len(obj.path) == 1:
86
+ self.group(obj.path[0]).properties.update(obj.properties)
87
+ else:
88
+ group = self.group(obj.path[0])
89
+ channel = group.channels.get(obj.path[1])
90
+ if channel is None:
91
+ channel = group.channels[obj.path[1]] = ChannelIndex(obj.path[1])
92
+ channel.properties.update(obj.properties)
93
+ self._index(channel, obj.index, obj.path)
94
+ if channel not in listed:
95
+ listed.append(channel)
96
+ if new_list:
97
+ self.active = listed
98
+ else:
99
+ known = {id(c) for c in self.active}
100
+ self.active += [c for c in listed if id(c) not in known]
101
+ self.layout = None
102
+
103
+ @staticmethod
104
+ def _index(channel: ChannelIndex, index: Index, path: tuple[str, ...]) -> None:
105
+ name = "/".join(path)
106
+ if index.kind == NONE:
107
+ channel.current = None
108
+ elif index.kind == SAME:
109
+ if channel.last is None:
110
+ raise TdmsFormatError(f"{name!r} has the raw data index 'same as before' but never had one")
111
+ channel.current = channel.last
112
+ else:
113
+ if channel.type_code is not None and channel.type_code != index.type_code:
114
+ raise TdmsFormatError(
115
+ f"the data type of {name!r} changes from {channel.type_code:#x} to {index.type_code:#x}"
116
+ )
117
+ channel.type_code = index.type_code
118
+ channel.last = channel.current = index
119
+
120
+ def members(self) -> tuple[list[ChannelIndex], list[int], int]:
121
+ if self.layout is None:
122
+ members = [c for c in self.active if c.current is not None]
123
+ sizes = []
124
+ for channel in members:
125
+ index = channel.current
126
+ assert index is not None
127
+ sizes.append(index.total_size if index.type_code == TYPE_STRING else index.count * _item_size(channel))
128
+ self.layout = (members, sizes, sum(sizes))
129
+ return self.layout
130
+
131
+
132
+ def _add_block(channel: ChannelIndex, offset: int, per: int, repeat: int, stride: int, big: bool) -> None:
133
+ """Append a block, merging it into the previous one when it continues it at a regular distance."""
134
+ blocks = channel.blocks
135
+ if blocks:
136
+ last = blocks[-1]
137
+ if repeat == 1 and last[1] == per and last[4] == int(big):
138
+ if last[2] == 1:
139
+ last[2], last[3] = 2, offset - last[0]
140
+ channel.length += per
141
+ return
142
+ if offset - (last[0] + (last[2] - 1) * last[3]) == last[3]:
143
+ last[2] += 1
144
+ channel.length += per
145
+ return
146
+ blocks.append([offset, per, repeat, stride, int(big)])
147
+ channel.length += per * repeat
148
+
149
+
150
+ def _segment_layout(
151
+ state: _State, raw_start: int, raw_len: int, interleaved: bool, big: bool, partial_allowed: bool
152
+ ) -> bool:
153
+ """Add the blocks of one segment's raw data; returns whether the data was cut inside a chunk."""
154
+ members, sizes, chunk = state.members()
155
+ if chunk == 0:
156
+ if raw_len > 0:
157
+ raise TdmsFormatError("a segment has raw data but no channel accounts for it")
158
+ return False
159
+ whole, remainder = divmod(raw_len, chunk)
160
+ if remainder and not partial_allowed or whole == 0 and not partial_allowed:
161
+ raise TdmsFormatError(
162
+ f"the raw data of a segment is {raw_len} bytes, which is not a whole number of chunks of {chunk} bytes"
163
+ )
164
+ if interleaved:
165
+ counts = {c.current.count for c in members if c.current is not None}
166
+ if len(counts) > 1 or any(c.type_code == TYPE_STRING for c in members):
167
+ raise TdmsFormatError("interleaved channels must all have the same count and cannot be strings")
168
+ count = counts.pop() if counts else 0
169
+ row = sum(_item_size(c) for c in members)
170
+ rows = whole * count + remainder // row
171
+ offset = raw_start
172
+ for channel in members:
173
+ if rows:
174
+ _add_block(channel, offset, 1, rows, row, big)
175
+ offset += _item_size(channel)
176
+ return remainder > 0
177
+ offset = 0
178
+ left = remainder
179
+ for channel, size in zip(members, sizes, strict=True):
180
+ index = channel.current
181
+ assert index is not None
182
+ if channel.type_code == TYPE_STRING:
183
+ for c in range(whole):
184
+ channel.string_chunks.append((raw_start + c * chunk + offset, index.count, size, big))
185
+ channel.length += index.count
186
+ else:
187
+ if whole and index.count:
188
+ _add_block(channel, raw_start + offset, index.count, whole, chunk, big)
189
+ if left > 0:
190
+ complete = min(left, size) // _item_size(channel)
191
+ if complete:
192
+ _add_block(channel, raw_start + whole * chunk + offset, complete, 1, 0, big)
193
+ left -= size
194
+ offset += size
195
+ return remainder > 0
196
+
197
+
198
+ def scan_file(path: str, *, strict: bool) -> Scan:
199
+ with open(path, "rb") as handle:
200
+ handle.seek(0, 2)
201
+ size = handle.tell()
202
+ if size < LEAD_IN_SIZE:
203
+ raise TdmsFormatError(f"{path}: {size} bytes are too few for a TDMS file (a segment starts with 28 bytes)")
204
+ with mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as data:
205
+ return _scan(data, size, strict)
206
+
207
+
208
+ def _cut(strict: bool, message: str) -> None:
209
+ if strict:
210
+ raise TdmsFormatError(message)
211
+
212
+
213
+ def _scan(data: mmap.mmap, size: int, strict: bool) -> Scan:
214
+ state = _State()
215
+ scan = state.scan
216
+ pos = 0
217
+ segments = 0
218
+ cache_key: tuple | None = None
219
+ while pos < size:
220
+ if size - pos < LEAD_IN_SIZE:
221
+ _cut(strict, f"the file ends inside the lead-in of a segment (byte {pos})")
222
+ scan.truncated = True
223
+ break
224
+ tag = data[pos : pos + 4]
225
+ if tag != SIGNATURE:
226
+ if tag == INDEX_SIGNATURE:
227
+ raise TdmsFormatError("this is a TDMS index file (.tdms_index): it holds no data, open the .tdms file")
228
+ raise TdmsFormatError(f"no TDMS segment at byte {pos} (found {bytes(tag)!r})")
229
+ (toc,) = struct.unpack_from("<I", data, pos + 4)
230
+ big = bool(toc & TOC_BIG_ENDIAN)
231
+ version, next_offset, raw_offset = struct.unpack_from((">" if big else "<") + "IQQ", data, pos + 8)
232
+ if version not in VERSIONS:
233
+ raise TdmsFormatError(f"unknown TDMS version {version} in the segment at byte {pos} (known: {VERSIONS})")
234
+ unknown_end = next_offset == UNKNOWN_NEXT_OFFSET
235
+ end = size if unknown_end else pos + LEAD_IN_SIZE + next_offset
236
+ cut = end > size
237
+ if cut:
238
+ end = size
239
+ raw_start = pos + LEAD_IN_SIZE + raw_offset
240
+ if raw_start > end:
241
+ if cut:
242
+ _cut(strict, f"the file ends inside the metadata of the segment at byte {pos}")
243
+ scan.truncated = True
244
+ break
245
+ raise TdmsFormatError(f"the raw data offset of the segment at byte {pos} points beyond the segment")
246
+
247
+ if toc & TOC_METADATA:
248
+ metadata = bytes(data[pos + LEAD_IN_SIZE : raw_start])
249
+ key = (metadata, toc & (TOC_NEW_OBJECT_LIST | TOC_BIG_ENDIAN))
250
+ if key != cache_key: # identical metadata twice in a row changes nothing the second time
251
+ objects = parse_metadata(metadata, big)
252
+ state.apply(objects, bool(toc & TOC_NEW_OBJECT_LIST))
253
+ cache_key = key
254
+ raw_len = end - raw_start
255
+ if toc & TOC_DAQMX and raw_len:
256
+ raise UnsupportedFeatureError("DAQmx raw data is not supported")
257
+ partial = False
258
+ if raw_len or toc & TOC_RAW_DATA:
259
+ partial = _segment_layout(
260
+ state, raw_start, raw_len, bool(toc & TOC_INTERLEAVED), big, partial_allowed=cut or unknown_end
261
+ )
262
+ if cut or partial:
263
+ _cut(strict, f"the file ends inside the segment at byte {pos}")
264
+ scan.truncated = True
265
+ segments += 1
266
+ pos = end
267
+ if segments == 0:
268
+ raise TdmsFormatError("the file has no complete segment")
269
+ return scan
tdmspy/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """The exceptions of tdmspy. Everything the package raises on purpose is a `TdmsError`."""
2
+
3
+
4
+ class TdmsError(Exception):
5
+ """Base class of every error raised for a file's content."""
6
+
7
+
8
+ class TdmsFormatError(TdmsError, ValueError):
9
+ """The file is not valid TDMS (wrong signature, corrupt or inconsistent segments, ...)."""
10
+
11
+
12
+ class UnsupportedFeatureError(TdmsError, NotImplementedError):
13
+ """The file is valid TDMS but uses something this package does not read (DAQmx raw data, ...)."""
14
+
15
+
16
+ class UnsupportedScalingError(UnsupportedFeatureError):
17
+ """A channel's NI scaling uses a scale type that is not implemented (`read(scaled=False)` still works)."""
tdmspy/file.py ADDED
@@ -0,0 +1,236 @@
1
+ """The objects `tdmspy.read` returns: `TdmsFile`, `Group` and `Channel` (see doc/design.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mmap
6
+ import os
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+
13
+ from tdmspy._format import DATA_TYPES, TYPE_BOOL, TYPE_STRING, TYPE_TIMESTAMP
14
+ from tdmspy._scaling import apply_scaling
15
+ from tdmspy._scan import ChannelIndex, GroupIndex, Scan, scan_file
16
+ from tdmspy.errors import TdmsFormatError
17
+ from tdmspy.timestamp import UNIX_EPOCH_IN_TDMS_SECONDS
18
+
19
+ _LE_STAMP = np.dtype([("fraction", "<u8"), ("seconds", "<i8")])
20
+ _BE_STAMP = np.dtype([("seconds", ">i8"), ("fraction", ">u8")])
21
+
22
+
23
+ def _microseconds(seconds: np.ndarray, fraction: np.ndarray) -> np.ndarray:
24
+ """`datetime64[us]` from TDMS seconds and 2**-64 fractions: the fraction is truncated, exactly (no floats).
25
+
26
+ floor(fraction * 10**6 / 2**64) with the fraction split in two 32 bit halves, so nothing overflows 64 bits.
27
+ """
28
+ thirty_two = np.uint64(32)
29
+ high, low = fraction >> thirty_two, fraction & np.uint64(0xFFFF_FFFF)
30
+ a, b = high * np.uint64(1_000_000), low * np.uint64(1_000_000)
31
+ micro = ((a + (b >> thirty_two)) >> thirty_two).astype(np.int64)
32
+ ticks = (seconds.astype(np.int64) - UNIX_EPOCH_IN_TDMS_SECONDS) * 1_000_000 + micro
33
+ return ticks.astype("datetime64[us]")
34
+
35
+
36
+ def _decode(raw: bytes) -> str:
37
+ try:
38
+ return raw.decode("utf-8")
39
+ except UnicodeDecodeError:
40
+ raise TdmsFormatError("a string value is not valid UTF-8") from None
41
+
42
+
43
+ class Channel:
44
+ """A channel: its properties, data type, length, and `read` for its values."""
45
+
46
+ def __init__(self, index: ChannelIndex, group: Group, file: TdmsFile) -> None:
47
+ self.name = index.name
48
+ self.properties: dict[str, Any] = index.properties
49
+ self._group = group
50
+ self._file = file
51
+ self._type_code = index.type_code
52
+ self._length = index.length
53
+ self._strings = index.string_chunks
54
+ self._blocks = np.array(index.blocks, dtype=np.int64).reshape(-1, 5)
55
+ self._starts = np.concatenate(([0], np.cumsum(self._blocks[:, 1] * self._blocks[:, 2])))
56
+ self._dtype = None if index.type_code is None else self._native_dtype(index.type_code)
57
+
58
+ @staticmethod
59
+ def _native_dtype(type_code: int) -> np.dtype:
60
+ if type_code == TYPE_STRING:
61
+ return np.dtype(object)
62
+ if type_code == TYPE_BOOL:
63
+ return np.dtype(bool)
64
+ if type_code == TYPE_TIMESTAMP:
65
+ return np.dtype("datetime64[us]")
66
+ return np.dtype("=" + DATA_TYPES[type_code].numpy)
67
+
68
+ @property
69
+ def dtype(self) -> np.dtype | None:
70
+ """The numpy dtype of the values (native byte order); `None` for a channel that never has a data type."""
71
+ return self._dtype
72
+
73
+ def __len__(self) -> int:
74
+ return self._length
75
+
76
+ def __repr__(self) -> str:
77
+ return f"<Channel {self.name!r}: {self._dtype}, {self._length} values>"
78
+
79
+ def read(self, start: int = 0, stop: int | None = None, *, scaled: bool = True) -> np.ndarray:
80
+ """The values `start` up to `stop` (all by default) as a new array; NI scaling is applied unless `scaled=False`."""
81
+ if start < 0 or (stop is not None and stop < 0):
82
+ raise ValueError("start and stop must not be negative")
83
+ stop = self._length if stop is None else min(stop, self._length)
84
+ start = min(start, self._length)
85
+ stop = max(stop, start)
86
+ if self._dtype is None:
87
+ return np.empty(0)
88
+ raw = self._read_strings(start, stop) if self._type_code == TYPE_STRING else self._read_numbers(start, stop)
89
+ if scaled and raw.dtype.kind in "iuf":
90
+ return apply_scaling(raw, self.properties, self._group.properties, self._file.properties)
91
+ return raw
92
+
93
+ # -- numbers ---------------------------------------------------------------------------------
94
+
95
+ def _read_numbers(self, start: int, stop: int) -> np.ndarray:
96
+ assert self._dtype is not None
97
+ out = np.empty(stop - start, dtype=self._dtype)
98
+ if stop == start:
99
+ return out
100
+ first = int(np.searchsorted(self._starts, start, side="right")) - 1
101
+ last = int(np.searchsorted(self._starts, stop, side="left"))
102
+ with open(self._file.path, "rb") as handle, mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as data:
103
+ for i in range(first, last):
104
+ base = int(self._starts[i])
105
+ lo, hi = max(start, base), min(stop, int(self._starts[i + 1]))
106
+ if lo < hi:
107
+ offset, per, repeat, stride, big = (int(v) for v in self._blocks[i])
108
+ self._read_block(
109
+ data,
110
+ (offset, per, repeat, stride, bool(big)),
111
+ lo - base,
112
+ hi - base,
113
+ out[lo - start : hi - start],
114
+ )
115
+ return out
116
+
117
+ def _read_block(
118
+ self, data: mmap.mmap, block: tuple[int, int, int, int, bool], v0: int, v1: int, out: np.ndarray
119
+ ) -> None:
120
+ """Values `v0` to `v1` of one block into `out`."""
121
+ assert self._type_code is not None
122
+ offset, per, repeat, stride, big = block
123
+ info = DATA_TYPES[self._type_code]
124
+ if self._type_code == TYPE_TIMESTAMP:
125
+ dtype, size = (_BE_STAMP if big else _LE_STAMP), 16
126
+ else:
127
+ dtype, size = np.dtype(("<>"[big]) + info.numpy), info.size
128
+ if offset + (repeat - 1) * stride + per * size > len(data):
129
+ raise TdmsFormatError("the file is shorter than when it was opened")
130
+
131
+ def copy(unit: int, count: int, within: int, into: np.ndarray, units: int = 1) -> None:
132
+ view = np.ndarray(
133
+ (units, count),
134
+ dtype=dtype,
135
+ buffer=data,
136
+ offset=offset + unit * stride + within * size,
137
+ strides=(stride, size),
138
+ )
139
+ self._convert(view, into.reshape(units, count))
140
+
141
+ wanted = v1 - v0
142
+ position = 0
143
+ unit, within = divmod(v0, per)
144
+ if within: # the tail of a first unit that is only partly wanted
145
+ count = min(per - within, wanted)
146
+ copy(unit, count, within, out[:count])
147
+ position, unit = count, unit + 1
148
+ full = (v1 // per) - unit
149
+ if full > 0 and position < wanted:
150
+ copy(unit, per, 0, out[position : position + full * per], units=full)
151
+ position, unit = position + full * per, unit + full
152
+ if position < wanted: # the head of a last unit that is only partly wanted
153
+ copy(unit, wanted - position, 0, out[position:])
154
+
155
+ def _convert(self, view: np.ndarray, into: np.ndarray) -> None:
156
+ if self._type_code == TYPE_TIMESTAMP:
157
+ into[...] = _microseconds(view["seconds"], view["fraction"].astype(np.uint64))
158
+ elif self._type_code == TYPE_BOOL:
159
+ into[...] = view != 0
160
+ else:
161
+ into[...] = view
162
+
163
+ # -- strings ---------------------------------------------------------------------------------
164
+
165
+ def _read_strings(self, start: int, stop: int) -> np.ndarray:
166
+ out = np.empty(stop - start, dtype=object)
167
+ position = 0
168
+ with open(self._file.path, "rb") as handle:
169
+ for offset, count, total, big in self._strings:
170
+ lo, hi = max(start, position), min(stop, position + count)
171
+ if lo < hi:
172
+ handle.seek(offset)
173
+ chunk = handle.read(total)
174
+ if len(chunk) < total or total < 4 * count:
175
+ raise TdmsFormatError("a string channel's data is shorter than its index says")
176
+ ends = np.frombuffer(chunk, dtype=("<>"[big]) + "u4", count=count)
177
+ text = chunk[4 * count :]
178
+ for i in range(lo - position, hi - position):
179
+ begin, end = (int(ends[i - 1]) if i else 0), int(ends[i])
180
+ if not begin <= end <= len(text):
181
+ raise TdmsFormatError("the offsets of a string channel are inconsistent")
182
+ out[lo - start + i - (lo - position)] = _decode(text[begin:end])
183
+ position += count
184
+ if position >= stop:
185
+ break
186
+ return out
187
+
188
+
189
+ class Group:
190
+ """A group: its properties and its channels, in file order."""
191
+
192
+ def __init__(self, index: GroupIndex, file: TdmsFile) -> None:
193
+ self.name = index.name
194
+ self.properties: dict[str, Any] = index.properties
195
+ self.channels: list[Channel] = [Channel(c, self, file) for c in index.channels.values()]
196
+ self._by_name = {c.name: c for c in self.channels}
197
+
198
+ def __getitem__(self, name: str) -> Channel:
199
+ return self._by_name[name]
200
+
201
+ def __contains__(self, name: object) -> bool:
202
+ return name in self._by_name
203
+
204
+ def __iter__(self) -> Iterator[Channel]:
205
+ return iter(self.channels)
206
+
207
+ def __repr__(self) -> str:
208
+ return f"<Group {self.name!r}: {len(self.channels)} channels>"
209
+
210
+
211
+ class TdmsFile:
212
+ """A TDMS file: its properties and groups. Channel data is read when asked for."""
213
+
214
+ def __init__(self, path: str | os.PathLike[str], scan: Scan) -> None:
215
+ self.path = Path(path)
216
+ self.properties: dict[str, Any] = scan.properties
217
+ self.truncated = scan.truncated
218
+ self.groups: list[Group] = [Group(g, self) for g in scan.groups.values()]
219
+ self._by_name = {g.name: g for g in self.groups}
220
+
221
+ def __getitem__(self, name: str) -> Group:
222
+ return self._by_name[name]
223
+
224
+ def __contains__(self, name: object) -> bool:
225
+ return name in self._by_name
226
+
227
+ def __iter__(self) -> Iterator[Group]:
228
+ return iter(self.groups)
229
+
230
+ def __repr__(self) -> str:
231
+ return f"<TdmsFile {self.path.name!r}: {len(self.groups)} groups>"
232
+
233
+
234
+ def read(path: str | os.PathLike[str], *, strict: bool = False) -> TdmsFile:
235
+ """Open the TDMS file at `path`: its structure is read, the channel data is not (see doc/design.md)."""
236
+ return TdmsFile(path, scan_file(os.fspath(path), strict=strict))
tdmspy/py.typed ADDED
File without changes
tdmspy/timestamp.py ADDED
@@ -0,0 +1,57 @@
1
+ """The TDMS timestamp: seconds since 1904-01-01 00:00:00 UTC plus a fraction of a second.
2
+
3
+ A property or channel value of type "timestamp" keeps both parts exactly, as the file stores them.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+ FRACTIONS_PER_SECOND = 1 << 64
13
+ """The fraction counts units of 2**-64 seconds."""
14
+
15
+ UNIX_EPOCH_IN_TDMS_SECONDS = 2_082_844_800
16
+ """Seconds between 1904-01-01 and 1970-01-01."""
17
+
18
+ _UNITS_PER_SECOND = {"s": 1, "ms": 1_000, "us": 1_000_000, "ns": 1_000_000_000}
19
+
20
+
21
+ @dataclass(frozen=True, order=True)
22
+ class Timestamp:
23
+ """`seconds` since 1904-01-01 UTC (may be negative) and `fraction` of a second in units of 2**-64 s."""
24
+
25
+ seconds: int
26
+ fraction: int = 0
27
+
28
+ def __post_init__(self) -> None:
29
+ if not 0 <= self.fraction < FRACTIONS_PER_SECOND:
30
+ raise ValueError(f"fraction must be in [0, 2**64), got {self.fraction}")
31
+
32
+ def to_datetime64(self, unit: str = "us") -> np.datetime64:
33
+ """UTC time as a `numpy.datetime64` of `unit` (`s`, `ms`, `us` or `ns`), truncated (never rounded up).
34
+
35
+ The fraction is converted with integer arithmetic, so the result is exact.
36
+ """
37
+ try:
38
+ per_second = _UNITS_PER_SECOND[unit]
39
+ except KeyError:
40
+ raise ValueError(f"unit must be one of {sorted(_UNITS_PER_SECOND)}, got {unit!r}") from None
41
+ ticks = (
42
+ self.seconds - UNIX_EPOCH_IN_TDMS_SECONDS
43
+ ) * per_second + self.fraction * per_second // FRACTIONS_PER_SECOND
44
+ return np.datetime64(ticks, unit)
45
+
46
+ @classmethod
47
+ def from_datetime64(cls, value: np.datetime64) -> Timestamp:
48
+ """The timestamp of a `numpy.datetime64` (any unit up to `ns`), exact for the value given."""
49
+ unit = np.datetime_data(value.dtype)[0]
50
+ if unit not in _UNITS_PER_SECOND:
51
+ raise ValueError(f"unsupported datetime64 unit {unit!r}")
52
+ per_second = _UNITS_PER_SECOND[unit]
53
+ ticks = int(value.astype(f"datetime64[{unit}]").astype(np.int64))
54
+ seconds, remainder = divmod(ticks, per_second)
55
+ # round up so that to_datetime64 of the result gives the same value back
56
+ fraction = -(-remainder * FRACTIONS_PER_SECOND // per_second)
57
+ return cls(seconds + UNIX_EPOCH_IN_TDMS_SECONDS, fraction)
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: tdmspy
3
+ Version: 0.1.0
4
+ Summary: Pure Python reader for National Instruments TDMS files, MIT licensed
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: numpy>=2.5.1
9
+ Description-Content-Type: text/markdown
10
+
11
+ # tdmspy
12
+
13
+ A pure Python reader for National Instruments **TDMS** files, MIT licensed. Its only runtime dependency is numpy.
14
+
15
+ It is written from the format specification and tested against real files and
16
+ synthetic test cases, one for every special case of the format.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ uv venv --allow-existing
22
+ uv sync --all-extras
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ import tdmspy
29
+
30
+ file = tdmspy.read("measurement.tdms") # indexes the file; no channel data is read yet
31
+ for group in file.groups: # in file order, not sorted
32
+ for channel in group.channels:
33
+ print(group.name, channel.name, channel.dtype, len(channel), channel.properties)
34
+
35
+ values = file["rawdata"]["Channel1"].read() # numpy array, native byte order, NI scaling applied
36
+ raw = file["rawdata"]["Channel1"].read(scaled=False)
37
+ part = file["rawdata"]["Channel1"].read(start=1000, stop=2000)
38
+ ```
39
+
40
+ The details (what is returned for each data type, what is refused, how truncated files are treated) are in
41
+ [`doc/design.md`](doc/design.md); every special case of the format the reader has to handle is listed with the files
42
+ that exercise it in [`doc/special-cases.md`](doc/special-cases.md).
43
+
44
+ ## Tests
45
+
46
+ ```sh
47
+ just test # lint, then everything except the slow tests
48
+ just test-all # including large files and long fuzz runs
49
+ just testcases # rewrite the stored synthetic files in tests/data/synthetic
50
+ ```
51
+
52
+ The tests are the specification of the reader:
53
+
54
+ - `tests/catalogue.py` lists every special case of the format. Add a case there first.
55
+ - `tests/builder.py` is a byte-level TDMS writer for the tests (interleaved, big-endian, several chunks, truncated,
56
+ corrupt, every property type, ...). It does not use tdmspy.
57
+ - `tests/testcases.py` has one small file per special case, each with what it must read as (`Truth`). The files are
58
+ stored in `tests/data/synthetic`; the long ones are built when a test needs them.
59
+ - `tests/data/real` are real files, with their identifying strings replaced by
60
+ placeholders; `tests/test_real_files.py` checks them and `tests/test_privacy.py` keeps identifiers out of the test data.
61
+ - `tests/test_reader_*.py` are the tests of `tdmspy.read`: all test cases, the API, strict mode and scaling.
62
+ - `tests/test_fuzz.py` and `tests/test_robust.py` damage files and read large ones.
63
+
64
+ `just catalogue` regenerates `doc/special-cases.md`.
65
+
66
+ ## Inspired by
67
+
68
+ [npTDMS](https://github.com/adamreeve/npTDMS), [pyTDMS](https://github.com/petebachant/pyTDMS) and
69
+ [ICOschema](https://github.com/ift-tuwien/ICOschema), which tdmspy was created to aid: ICOschema reads TDMS files
70
+ through it. No code of any of these projects is used here.
71
+
72
+ TDMS, LabVIEW and NI are trademarks of National Instruments. tdmspy is not affiliated with or endorsed by NI.
73
+
74
+ ## Licence
75
+
76
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ tdmspy/__init__.py,sha256=XUuRR04aC2K5xjy0uVt7kqu3DOpDSMDILT3t1E4CY7Y,870
2
+ tdmspy/_format.py,sha256=5Kfb6xi1Z-dKuv5LGtC9z1ur0fQ61eo-l3LIlQOuvlc,2010
3
+ tdmspy/_metadata.py,sha256=oTAJ7qekHPC7i2M-EKLaaq71d1KboiRcUUgRs8Q0hWI,7543
4
+ tdmspy/_scaling.py,sha256=KjAkhAGkzyR8y1iTo2IQ_BgS2hwfbEgxvHcQHCyvgZQ,3077
5
+ tdmspy/_scan.py,sha256=zaDr5epjrjsHw5srrHpmOsVq8Yp7gq5bZzeVWvRZZ7I,11024
6
+ tdmspy/errors.py,sha256=520q0a5VfloULbgqUG0-nFdwzhcZTPmrXXEIeXv_kco,665
7
+ tdmspy/file.py,sha256=IvcTQk1iqWGXlZziyINByQ6IQUPjipLD7WbSCF6-ia8,10220
8
+ tdmspy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ tdmspy/timestamp.py,sha256=VSYZykzYy173MKDttY6XkZeRPJFeXj5-G7O53wgYQwQ,2317
10
+ tdmspy-0.1.0.dist-info/METADATA,sha256=1dLjfO5LymjFAOo4tyPmaHLJP-GbIg1vAWtQT1dNPZY,3048
11
+ tdmspy-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ tdmspy-0.1.0.dist-info/licenses/LICENSE,sha256=pGTOdcwdvrXSTL8WKTZQxYlYVikuc6srBYve_adrc0s,1147
13
+ tdmspy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moritz Breurather, Institute of Production Engineering and Photonic Technologies TU Wien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.