constrata 1.0.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017-2024 Scott Mooney (aka Grimrukh)
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.
@@ -0,0 +1,330 @@
1
+ Metadata-Version: 2.1
2
+ Name: constrata
3
+ Version: 1.0.0
4
+ Summary: Binary struct reading/writing Python library based on dataclass schemas
5
+ Home-page: https://github.com/Grimrukh/constrata
6
+ License: MIT
7
+ Author: Scott Mooney
8
+ Author-email: grimrukh@gmail.com
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Project-URL: Repository, https://github.com/Grimrukh/constrata
16
+ Description-Content-Type: text/markdown
17
+
18
+ # constrata
19
+
20
+ An efficient Python library for parsing and building binary data structures based on the built-in `dataclasses` module,
21
+ with support for reserving/filling fields, pure field type support (without `= field(...)`), asserted values, and more.
22
+
23
+ Pure Python, no dependencies, MIT license. **Requires Python 3.11 or later.**
24
+
25
+ ## Installation
26
+
27
+ ```shell
28
+ pip install constrata
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Use `dataclasses.dataclass` and `dataclasses.field` as you normally would to define a subclass of
34
+ `constrata.BinaryStruct`, and specify the binary format, size, asserted values, and unpacking/packing functions of
35
+ fields using `constrata` 'metadata' constructors. Many of these fields can be determined automatically by `constrata`
36
+ based on the field type hint, and custom metadata factories can be added to expand the range of such automatic support.
37
+
38
+ Usage of the genuine `dataclasses.field` with double-asterisk metadata arguments like `**Binary()` is recommended if you
39
+ want your IDE to continue detecting field types and default values for `__init__` (e.g. in PyCharm) -- but you can
40
+ equivalently use full `field` wrappers such as `binary()` for cleaner code and automatic `init=False` arguments for
41
+ asserted fields (but IDE dataclass support may break).
42
+
43
+ `BinaryStruct` subclasses **must use the `@dataclass` wrapper**. They do not require `slots=True`, but as these classes
44
+ are intended to represent binary data structures and should never have undefined fields anyway, there is NO reason not
45
+ to take the performance gain here.
46
+
47
+ ## Basic Example
48
+
49
+ ```python
50
+ from dataclasses import dataclass, field
51
+ from constrata import BinaryStruct, Binary, BinaryString, BinaryArray, BinaryPad
52
+ from constrata.field_types import *
53
+
54
+ @dataclass(slots=True)
55
+ class MyStruct(BinaryStruct):
56
+ my_int32: int
57
+ my_uint64: uint64
58
+ my_single: float32
59
+ my_double: float64 = field(**Binary(asserted=(1.0, 2.0, 3.0))) # only three permitted values
60
+ _padding: bytes = field(init=False, **BinaryPad(8))
61
+ my_ascii_string: str = field(**BinaryString(12, encoding="ascii"))
62
+ my_eight_bools: list[bool] = field(default_factory=lambda: [False] * 8, **BinaryArray(8))
63
+ my_bitflag1: bool = field(default=False, **Binary(bit_count=1))
64
+ my_bitflag2: bool = field(default=True, **Binary(bit_count=1))
65
+ # Six unused bits in byte skipped here (and must be 0).
66
+
67
+ # Read from a file.
68
+ bin_path = "my_struct.bin"
69
+ with open(bin_path, "rb") as f:
70
+ my_struct = MyStruct.from_bytes(f)
71
+
72
+ # Modify fields.
73
+ my_struct.my_int32 = 123
74
+ my_struct.my_bitflag2 = True
75
+
76
+ # Write to a file.
77
+ my_struct_bytes = my_struct.to_bytes()
78
+ new_bin_path = "my_struct_new.bin"
79
+ with open(new_bin_path, "wb") as f:
80
+ f.write(my_struct_bytes)
81
+
82
+ # Create a new instance from scratch as a standard dataclass.
83
+ new_struct = MyStruct(0, 0, 0.0, 1.0, my_ascii_string="helloworld")
84
+ ```
85
+
86
+ An identical `MyStruct` can be defined using direct `dataclasses.field` wrappers:
87
+
88
+ ```python
89
+ from dataclasses import dataclass
90
+ from constrata import BinaryStruct, binary, binary_string, binary_array, binary_pad
91
+ from constrata.field_types import *
92
+
93
+ @dataclass(slots=True)
94
+ class MyStruct(BinaryStruct):
95
+ my_int32: int
96
+ my_uint64: uint64
97
+ my_single: float32
98
+ my_double: float64 = binary(asserted=(1.0, 2.0, 3.0)) # only three permitted values
99
+ _padding: bytes = binary_pad(8) # `init=False` is automatic
100
+ my_ascii_string: str = binary_string(12, encoding="ascii")
101
+ my_eight_bools: list[bool] = binary_array(8, default_factory=lambda: [False] * 8)
102
+ my_bitflag1: bool = binary(default=False, bit_count=1)
103
+ my_bitflag2: bool = binary(default=True, bit_count=1)
104
+ # Six unused bits in byte skipped here (and must be 0).
105
+ ```
106
+
107
+ ## Reserving and Filling Fields
108
+
109
+ The flexible `BinaryReader` and `BinaryWriter` classes can also serve as useful tools for managing binary IO streams.
110
+ They manage byte order, variable-sized ints, and (in the case of `BinaryWriter`) handle field *reserving and filling*.
111
+
112
+ When converting a `BinaryStruct` instance to a `BinaryWriter`, fields can also be left as `None` (or explicitly set to
113
+ the unique value `RESERVED`) and filled in later using the `binary_writer.fill()` method. This is useful for fields that
114
+ store offsets or otherwise depend on future state. (The `binary_writer.fill_with_position()` method can be used in this
115
+ case, too.)
116
+
117
+ Field positions in the writer are reserved with reference to the `id()` of the `BinaryStruct` instance, so numerous
118
+ instances can all reserve the same field name in a single writer without conflict.
119
+
120
+ If any reserved fields are not filled before the final conversion of the writer to `bytes`, an error will be raised.
121
+
122
+ Example:
123
+
124
+ ```python
125
+ from __future__ import annotations
126
+
127
+ from dataclasses import dataclass, field
128
+ from typing import NamedTuple
129
+ from constrata import BinaryStruct, BinaryString, BinaryReader, BinaryWriter, RESERVED
130
+ from constrata.field_types import float32
131
+
132
+ class Vector(NamedTuple):
133
+ name: str
134
+ x: float
135
+ y: float
136
+ z: float
137
+
138
+ @dataclass(slots=True)
139
+ class VectorListHeader(BinaryStruct):
140
+ magic: bytes = field(init=False, **BinaryString(4, asserted=b"VEC\0"))
141
+ vector_count: int
142
+ names_offset: int # offset to packed null-terminated vector name data
143
+ data_offset: int # offset to packed (x, y, z) vector float data
144
+ file_size: int # total file size in bytes
145
+
146
+ @dataclass(slots=True)
147
+ class VectorData(BinaryStruct):
148
+ x: float32
149
+ y: float32
150
+ z: float32
151
+ name_offset: int
152
+ next_vector_offset: int
153
+
154
+ # Unpacking a `Vector` list from a binary file is straightforward.
155
+ bin_path = "my_vector_list.vec"
156
+ # We create our own `BinaryReader` instance to manage the file, since we will be using multiple `BinaryStruct` classes.
157
+ reader = BinaryReader(bin_path)
158
+ vector_list_header = VectorListHeader.from_bytes(reader)
159
+ # We use `reader.temp_offset()` to step in and out of file regions.
160
+ names = []
161
+ with reader.temp_offset(vector_list_header.names_offset):
162
+ for _ in range(vector_list_header.vector_count):
163
+ name = reader.unpack_string() # null-terminated with default UTF-8 encoding
164
+ names.append(name)
165
+ vectors = []
166
+ with reader.temp_offset(vector_list_header.data_offset):
167
+ for i in range(vector_list_header.vector_count):
168
+ data = VectorData.from_bytes(reader)
169
+ # Attach indexed name from above.
170
+ vector = Vector(names[i], data.x, data.y, data.z)
171
+ vectors.append(vector)
172
+ # We don't need to do anything with the header `file_size` or the `name_offset` or `next_vector_offset` fields of each
173
+ # `VectorData` struct, since we know the data is tightly packed.
174
+
175
+ # Add a new Vector.
176
+ vectors.append(Vector("new_vector", 1.0, 2.0, 3.0))
177
+
178
+ # To pack our `Vector` list, we can use the `BinaryWriter` class and `RESERVED` value.
179
+ writer = BinaryWriter()
180
+ new_header = VectorListHeader(
181
+ vector_count=len(vectors),
182
+ names_offset=RESERVED,
183
+ data_offset=RESERVED,
184
+ file_size=RESERVED,
185
+ )
186
+ # We call `to_writer()` rather than `to_bytes()`, which would raise an error due to the reserved fields.
187
+ new_header.to_writer(writer)
188
+ # Names will be packed first, which means we can fill `names_offset` immediately.
189
+ new_header.fill(writer, "names_offset", writer.position)
190
+ # We need to record the offset of each vector's name to write to that vector's `name_offset` field. Since that field
191
+ # comes AFTER the name data, reserving isn't a good approach here.
192
+ vector_name_offsets = []
193
+ for vector in vectors:
194
+ vector_name_offsets.append(writer.position) # record offset before writing
195
+ writer.pack_z_string(vector.name) # default UTF-8 encoding
196
+ # Let's say we know that our spec of the `.vec` file format requires alignment to 16 bytes here, as the name strings
197
+ # could end up being any total length.
198
+ writer.pad_align(0x10)
199
+ # Vector data will be packed next, so we can fill `data_offset` now.
200
+ new_header.fill(writer, "data_offset", writer.position)
201
+ # We will keep the `VectorData` struct instances we create, as they are each responsible for their own reserved
202
+ # `next_vector_offset` field in the writer.
203
+ vector_data_structs = []
204
+ for i, vector in enumerate(vectors):
205
+ if i > 0:
206
+ # We need to fill the `next_vector_offset` field of the previous vector.
207
+ vector_data_structs[i - 1].fill(writer, "next_vector_offset", writer.position)
208
+ if i == len(vectors) - 1:
209
+ # This is the last vector, so its `next_vector_offset` field should be 0.
210
+ next_vector_offset = 0
211
+ else:
212
+ # Reserve this vector's `next_vector_offset` field.
213
+ next_vector_offset = RESERVED
214
+ name_offset = vector_name_offsets[i]
215
+ # We index into the name offsets recorded above and reserve the next vector offset.
216
+ vector_data = VectorData(vector.x, vector.y, vector.z, name_offset, next_vector_offset=next_vector_offset)
217
+ vector_data.to_writer(writer)
218
+ vector_data_structs.append(vector_data)
219
+
220
+ # We can now fill the `file_size` field of the header.
221
+ new_header.fill(writer, "file_size", writer.position)
222
+
223
+ # Finally, we can write the packed data to a new file.
224
+ new_bin_path = "my_vector_list_new.vec"
225
+ with open(new_bin_path, "wb") as f:
226
+ f.write(bytes(writer))
227
+ ```
228
+
229
+ ## Custom Metadata Factories
230
+
231
+ By default, you can only omit `field(**Binary(...))` metadata when the field type hint is a built-in type with a known
232
+ size. You can extend this support by adding custom metadata factories to `BinaryStruct.METADATA_FACTORIES`, most easily
233
+ done with a subclass.
234
+
235
+ The example below defines a `Vector` metadata factory for use with the example above, which allows `Vector` fields to be
236
+ explicitly used in the `VectorData` struct without needing to read or write the three `float` components manually. (In
237
+ this case, since the `name` field of each `Vector` must be changed after object initialization, we use a `dataclass` for
238
+ it instead of an immutable `NamedTuple`.)
239
+
240
+ ```python
241
+ from dataclasses import dataclass, field
242
+ from constrata import BinaryStruct, Binary
243
+ from constrata.metadata import BinaryMetadata
244
+
245
+ @dataclass(slots=True)
246
+ class Vector:
247
+ name: str
248
+ x: float
249
+ y: float
250
+ z: float
251
+
252
+
253
+ def unpack_vector(values: list[float]) -> Vector:
254
+ """Name will be set later. The field metadata will have already read three floats (see below)."""
255
+ return Vector("", *values)
256
+
257
+ def pack_vector(value: Vector) -> list[float]:
258
+ """This function must convert the custom type to a list of values that can be packed with `struct.pack()`.
259
+
260
+ Name is ignored and handled separately by our script.
261
+
262
+ Note that if `Vector` defined `__iter__`, we could omit this, as the asterisk operator would unpack the values for
263
+ us as we call `struct.pack(*value)`.
264
+ """
265
+ return [value.x, value.y, value.z]
266
+
267
+ # As binary metadata is powerful, we could support `Vector` as a field type by specifying its format and unpack/pack
268
+ # functions, but we would have to do this every time it appeared:
269
+
270
+ @dataclass(slots=True)
271
+ class VectorData(BinaryStruct):
272
+ vector: Vector = field(**Binary("3f", unpack_func=unpack_vector, pack_func=pack_vector))
273
+ name_offset: int
274
+ next_vector_offset: int
275
+
276
+ # Since `Vector` may appear in many structs, we can define a custom metadata factory for it in a new `BinaryStruct`
277
+ # subclass, and use that subclass in place of `BinaryStruct` in our code:
278
+
279
+ @dataclass(slots=True)
280
+ class EnhancedBinaryStruct(BinaryStruct):
281
+
282
+ METADATA_FACTORIES = {
283
+ "Vector": lambda: BinaryMetadata("3f", unpack_func=unpack_vector, pack_func=pack_vector),
284
+ }
285
+
286
+ # Now we can use `Vector` fields in our `EnhancedBinaryStruct` subclasses across all of our code, including:
287
+
288
+ @dataclass(slots=True)
289
+ class VectorData(EnhancedBinaryStruct):
290
+ vector: Vector # replaces `x`, `y`, `z` separate fields; no `field()` call needed here!
291
+ name_offset: int
292
+ next_vector_offset: int
293
+
294
+ # We could add support for any common custom types to `EnhancedBinaryStruct.METADATA_FACTORIES` to make them available
295
+ # across all of our structs. This is especially useful for complex types that are used in many places.
296
+ ```
297
+
298
+ You can also natively use other `BinaryStruct` subclasses as field types in your `BinaryStruct` subclasses, as long as
299
+ they do not create a circular reference. This can be useful for defining complex binary structures with nested fields.
300
+
301
+ ## More to come...
302
+
303
+ More documentation and examples to come. For now, please refer to the source code and docstrings.
304
+
305
+ ## License
306
+
307
+ ```
308
+ MIT License
309
+
310
+ Copyright (c) 2017-2024 Scott Mooney (aka Grimrukh)
311
+
312
+ Permission is hereby granted, free of charge, to any person obtaining a copy
313
+ of this software and associated documentation files (the "Software"), to deal
314
+ in the Software without restriction, including without limitation the rights
315
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
316
+ copies of the Software, and to permit persons to whom the Software is
317
+ furnished to do so, subject to the following conditions:
318
+
319
+ The above copyright notice and this permission notice shall be included in all
320
+ copies or substantial portions of the Software.
321
+
322
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
323
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
324
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
325
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
326
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
327
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
328
+ SOFTWARE.
329
+ ```
330
+
@@ -0,0 +1,312 @@
1
+ # constrata
2
+
3
+ An efficient Python library for parsing and building binary data structures based on the built-in `dataclasses` module,
4
+ with support for reserving/filling fields, pure field type support (without `= field(...)`), asserted values, and more.
5
+
6
+ Pure Python, no dependencies, MIT license. **Requires Python 3.11 or later.**
7
+
8
+ ## Installation
9
+
10
+ ```shell
11
+ pip install constrata
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Use `dataclasses.dataclass` and `dataclasses.field` as you normally would to define a subclass of
17
+ `constrata.BinaryStruct`, and specify the binary format, size, asserted values, and unpacking/packing functions of
18
+ fields using `constrata` 'metadata' constructors. Many of these fields can be determined automatically by `constrata`
19
+ based on the field type hint, and custom metadata factories can be added to expand the range of such automatic support.
20
+
21
+ Usage of the genuine `dataclasses.field` with double-asterisk metadata arguments like `**Binary()` is recommended if you
22
+ want your IDE to continue detecting field types and default values for `__init__` (e.g. in PyCharm) -- but you can
23
+ equivalently use full `field` wrappers such as `binary()` for cleaner code and automatic `init=False` arguments for
24
+ asserted fields (but IDE dataclass support may break).
25
+
26
+ `BinaryStruct` subclasses **must use the `@dataclass` wrapper**. They do not require `slots=True`, but as these classes
27
+ are intended to represent binary data structures and should never have undefined fields anyway, there is NO reason not
28
+ to take the performance gain here.
29
+
30
+ ## Basic Example
31
+
32
+ ```python
33
+ from dataclasses import dataclass, field
34
+ from constrata import BinaryStruct, Binary, BinaryString, BinaryArray, BinaryPad
35
+ from constrata.field_types import *
36
+
37
+ @dataclass(slots=True)
38
+ class MyStruct(BinaryStruct):
39
+ my_int32: int
40
+ my_uint64: uint64
41
+ my_single: float32
42
+ my_double: float64 = field(**Binary(asserted=(1.0, 2.0, 3.0))) # only three permitted values
43
+ _padding: bytes = field(init=False, **BinaryPad(8))
44
+ my_ascii_string: str = field(**BinaryString(12, encoding="ascii"))
45
+ my_eight_bools: list[bool] = field(default_factory=lambda: [False] * 8, **BinaryArray(8))
46
+ my_bitflag1: bool = field(default=False, **Binary(bit_count=1))
47
+ my_bitflag2: bool = field(default=True, **Binary(bit_count=1))
48
+ # Six unused bits in byte skipped here (and must be 0).
49
+
50
+ # Read from a file.
51
+ bin_path = "my_struct.bin"
52
+ with open(bin_path, "rb") as f:
53
+ my_struct = MyStruct.from_bytes(f)
54
+
55
+ # Modify fields.
56
+ my_struct.my_int32 = 123
57
+ my_struct.my_bitflag2 = True
58
+
59
+ # Write to a file.
60
+ my_struct_bytes = my_struct.to_bytes()
61
+ new_bin_path = "my_struct_new.bin"
62
+ with open(new_bin_path, "wb") as f:
63
+ f.write(my_struct_bytes)
64
+
65
+ # Create a new instance from scratch as a standard dataclass.
66
+ new_struct = MyStruct(0, 0, 0.0, 1.0, my_ascii_string="helloworld")
67
+ ```
68
+
69
+ An identical `MyStruct` can be defined using direct `dataclasses.field` wrappers:
70
+
71
+ ```python
72
+ from dataclasses import dataclass
73
+ from constrata import BinaryStruct, binary, binary_string, binary_array, binary_pad
74
+ from constrata.field_types import *
75
+
76
+ @dataclass(slots=True)
77
+ class MyStruct(BinaryStruct):
78
+ my_int32: int
79
+ my_uint64: uint64
80
+ my_single: float32
81
+ my_double: float64 = binary(asserted=(1.0, 2.0, 3.0)) # only three permitted values
82
+ _padding: bytes = binary_pad(8) # `init=False` is automatic
83
+ my_ascii_string: str = binary_string(12, encoding="ascii")
84
+ my_eight_bools: list[bool] = binary_array(8, default_factory=lambda: [False] * 8)
85
+ my_bitflag1: bool = binary(default=False, bit_count=1)
86
+ my_bitflag2: bool = binary(default=True, bit_count=1)
87
+ # Six unused bits in byte skipped here (and must be 0).
88
+ ```
89
+
90
+ ## Reserving and Filling Fields
91
+
92
+ The flexible `BinaryReader` and `BinaryWriter` classes can also serve as useful tools for managing binary IO streams.
93
+ They manage byte order, variable-sized ints, and (in the case of `BinaryWriter`) handle field *reserving and filling*.
94
+
95
+ When converting a `BinaryStruct` instance to a `BinaryWriter`, fields can also be left as `None` (or explicitly set to
96
+ the unique value `RESERVED`) and filled in later using the `binary_writer.fill()` method. This is useful for fields that
97
+ store offsets or otherwise depend on future state. (The `binary_writer.fill_with_position()` method can be used in this
98
+ case, too.)
99
+
100
+ Field positions in the writer are reserved with reference to the `id()` of the `BinaryStruct` instance, so numerous
101
+ instances can all reserve the same field name in a single writer without conflict.
102
+
103
+ If any reserved fields are not filled before the final conversion of the writer to `bytes`, an error will be raised.
104
+
105
+ Example:
106
+
107
+ ```python
108
+ from __future__ import annotations
109
+
110
+ from dataclasses import dataclass, field
111
+ from typing import NamedTuple
112
+ from constrata import BinaryStruct, BinaryString, BinaryReader, BinaryWriter, RESERVED
113
+ from constrata.field_types import float32
114
+
115
+ class Vector(NamedTuple):
116
+ name: str
117
+ x: float
118
+ y: float
119
+ z: float
120
+
121
+ @dataclass(slots=True)
122
+ class VectorListHeader(BinaryStruct):
123
+ magic: bytes = field(init=False, **BinaryString(4, asserted=b"VEC\0"))
124
+ vector_count: int
125
+ names_offset: int # offset to packed null-terminated vector name data
126
+ data_offset: int # offset to packed (x, y, z) vector float data
127
+ file_size: int # total file size in bytes
128
+
129
+ @dataclass(slots=True)
130
+ class VectorData(BinaryStruct):
131
+ x: float32
132
+ y: float32
133
+ z: float32
134
+ name_offset: int
135
+ next_vector_offset: int
136
+
137
+ # Unpacking a `Vector` list from a binary file is straightforward.
138
+ bin_path = "my_vector_list.vec"
139
+ # We create our own `BinaryReader` instance to manage the file, since we will be using multiple `BinaryStruct` classes.
140
+ reader = BinaryReader(bin_path)
141
+ vector_list_header = VectorListHeader.from_bytes(reader)
142
+ # We use `reader.temp_offset()` to step in and out of file regions.
143
+ names = []
144
+ with reader.temp_offset(vector_list_header.names_offset):
145
+ for _ in range(vector_list_header.vector_count):
146
+ name = reader.unpack_string() # null-terminated with default UTF-8 encoding
147
+ names.append(name)
148
+ vectors = []
149
+ with reader.temp_offset(vector_list_header.data_offset):
150
+ for i in range(vector_list_header.vector_count):
151
+ data = VectorData.from_bytes(reader)
152
+ # Attach indexed name from above.
153
+ vector = Vector(names[i], data.x, data.y, data.z)
154
+ vectors.append(vector)
155
+ # We don't need to do anything with the header `file_size` or the `name_offset` or `next_vector_offset` fields of each
156
+ # `VectorData` struct, since we know the data is tightly packed.
157
+
158
+ # Add a new Vector.
159
+ vectors.append(Vector("new_vector", 1.0, 2.0, 3.0))
160
+
161
+ # To pack our `Vector` list, we can use the `BinaryWriter` class and `RESERVED` value.
162
+ writer = BinaryWriter()
163
+ new_header = VectorListHeader(
164
+ vector_count=len(vectors),
165
+ names_offset=RESERVED,
166
+ data_offset=RESERVED,
167
+ file_size=RESERVED,
168
+ )
169
+ # We call `to_writer()` rather than `to_bytes()`, which would raise an error due to the reserved fields.
170
+ new_header.to_writer(writer)
171
+ # Names will be packed first, which means we can fill `names_offset` immediately.
172
+ new_header.fill(writer, "names_offset", writer.position)
173
+ # We need to record the offset of each vector's name to write to that vector's `name_offset` field. Since that field
174
+ # comes AFTER the name data, reserving isn't a good approach here.
175
+ vector_name_offsets = []
176
+ for vector in vectors:
177
+ vector_name_offsets.append(writer.position) # record offset before writing
178
+ writer.pack_z_string(vector.name) # default UTF-8 encoding
179
+ # Let's say we know that our spec of the `.vec` file format requires alignment to 16 bytes here, as the name strings
180
+ # could end up being any total length.
181
+ writer.pad_align(0x10)
182
+ # Vector data will be packed next, so we can fill `data_offset` now.
183
+ new_header.fill(writer, "data_offset", writer.position)
184
+ # We will keep the `VectorData` struct instances we create, as they are each responsible for their own reserved
185
+ # `next_vector_offset` field in the writer.
186
+ vector_data_structs = []
187
+ for i, vector in enumerate(vectors):
188
+ if i > 0:
189
+ # We need to fill the `next_vector_offset` field of the previous vector.
190
+ vector_data_structs[i - 1].fill(writer, "next_vector_offset", writer.position)
191
+ if i == len(vectors) - 1:
192
+ # This is the last vector, so its `next_vector_offset` field should be 0.
193
+ next_vector_offset = 0
194
+ else:
195
+ # Reserve this vector's `next_vector_offset` field.
196
+ next_vector_offset = RESERVED
197
+ name_offset = vector_name_offsets[i]
198
+ # We index into the name offsets recorded above and reserve the next vector offset.
199
+ vector_data = VectorData(vector.x, vector.y, vector.z, name_offset, next_vector_offset=next_vector_offset)
200
+ vector_data.to_writer(writer)
201
+ vector_data_structs.append(vector_data)
202
+
203
+ # We can now fill the `file_size` field of the header.
204
+ new_header.fill(writer, "file_size", writer.position)
205
+
206
+ # Finally, we can write the packed data to a new file.
207
+ new_bin_path = "my_vector_list_new.vec"
208
+ with open(new_bin_path, "wb") as f:
209
+ f.write(bytes(writer))
210
+ ```
211
+
212
+ ## Custom Metadata Factories
213
+
214
+ By default, you can only omit `field(**Binary(...))` metadata when the field type hint is a built-in type with a known
215
+ size. You can extend this support by adding custom metadata factories to `BinaryStruct.METADATA_FACTORIES`, most easily
216
+ done with a subclass.
217
+
218
+ The example below defines a `Vector` metadata factory for use with the example above, which allows `Vector` fields to be
219
+ explicitly used in the `VectorData` struct without needing to read or write the three `float` components manually. (In
220
+ this case, since the `name` field of each `Vector` must be changed after object initialization, we use a `dataclass` for
221
+ it instead of an immutable `NamedTuple`.)
222
+
223
+ ```python
224
+ from dataclasses import dataclass, field
225
+ from constrata import BinaryStruct, Binary
226
+ from constrata.metadata import BinaryMetadata
227
+
228
+ @dataclass(slots=True)
229
+ class Vector:
230
+ name: str
231
+ x: float
232
+ y: float
233
+ z: float
234
+
235
+
236
+ def unpack_vector(values: list[float]) -> Vector:
237
+ """Name will be set later. The field metadata will have already read three floats (see below)."""
238
+ return Vector("", *values)
239
+
240
+ def pack_vector(value: Vector) -> list[float]:
241
+ """This function must convert the custom type to a list of values that can be packed with `struct.pack()`.
242
+
243
+ Name is ignored and handled separately by our script.
244
+
245
+ Note that if `Vector` defined `__iter__`, we could omit this, as the asterisk operator would unpack the values for
246
+ us as we call `struct.pack(*value)`.
247
+ """
248
+ return [value.x, value.y, value.z]
249
+
250
+ # As binary metadata is powerful, we could support `Vector` as a field type by specifying its format and unpack/pack
251
+ # functions, but we would have to do this every time it appeared:
252
+
253
+ @dataclass(slots=True)
254
+ class VectorData(BinaryStruct):
255
+ vector: Vector = field(**Binary("3f", unpack_func=unpack_vector, pack_func=pack_vector))
256
+ name_offset: int
257
+ next_vector_offset: int
258
+
259
+ # Since `Vector` may appear in many structs, we can define a custom metadata factory for it in a new `BinaryStruct`
260
+ # subclass, and use that subclass in place of `BinaryStruct` in our code:
261
+
262
+ @dataclass(slots=True)
263
+ class EnhancedBinaryStruct(BinaryStruct):
264
+
265
+ METADATA_FACTORIES = {
266
+ "Vector": lambda: BinaryMetadata("3f", unpack_func=unpack_vector, pack_func=pack_vector),
267
+ }
268
+
269
+ # Now we can use `Vector` fields in our `EnhancedBinaryStruct` subclasses across all of our code, including:
270
+
271
+ @dataclass(slots=True)
272
+ class VectorData(EnhancedBinaryStruct):
273
+ vector: Vector # replaces `x`, `y`, `z` separate fields; no `field()` call needed here!
274
+ name_offset: int
275
+ next_vector_offset: int
276
+
277
+ # We could add support for any common custom types to `EnhancedBinaryStruct.METADATA_FACTORIES` to make them available
278
+ # across all of our structs. This is especially useful for complex types that are used in many places.
279
+ ```
280
+
281
+ You can also natively use other `BinaryStruct` subclasses as field types in your `BinaryStruct` subclasses, as long as
282
+ they do not create a circular reference. This can be useful for defining complex binary structures with nested fields.
283
+
284
+ ## More to come...
285
+
286
+ More documentation and examples to come. For now, please refer to the source code and docstrings.
287
+
288
+ ## License
289
+
290
+ ```
291
+ MIT License
292
+
293
+ Copyright (c) 2017-2024 Scott Mooney (aka Grimrukh)
294
+
295
+ Permission is hereby granted, free of charge, to any person obtaining a copy
296
+ of this software and associated documentation files (the "Software"), to deal
297
+ in the Software without restriction, including without limitation the rights
298
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
299
+ copies of the Software, and to permit persons to whom the Software is
300
+ furnished to do so, subject to the following conditions:
301
+
302
+ The above copyright notice and this permission notice shall be included in all
303
+ copies or substantial portions of the Software.
304
+
305
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
306
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
307
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
308
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
309
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
310
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
311
+ SOFTWARE.
312
+ ```