bethkit 1.0.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,694 @@
1
+ """
2
+ Copyright (c) Modding Forge
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .. import _ffi
12
+ from .._error import BethkitClosedError, BethkitNotFoundError
13
+ from ..enums import Ba2Version, BsaVersion
14
+
15
+
16
+ def _buf_from_bytes(data: bytes) -> ctypes.Array[ctypes.c_uint8]:
17
+ """
18
+ Wrap *data* in a ctypes ``c_uint8`` array for FFI calls.
19
+
20
+ Args:
21
+ data (bytes): Byte sequence to wrap.
22
+
23
+ Returns:
24
+ ctypes.Array: A ``c_uint8`` array backed by a copy of *data*.
25
+ """
26
+
27
+ return (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
28
+
29
+
30
+ class ArchiveEntry:
31
+ """
32
+ A single file entry inside an open archive.
33
+
34
+ Instances borrow their data from the parent :class:`Archive` and
35
+ become invalid once the archive is closed or freed.
36
+ """
37
+
38
+ _ptr: int
39
+ _parent: Archive
40
+
41
+ def __init__(self, ptr: int, parent: Archive) -> None:
42
+ """
43
+ Args:
44
+ ptr (int): Native pointer to the underlying entry object.
45
+ parent (Archive): Owning archive that keeps native memory alive.
46
+ """
47
+
48
+ self._ptr = ptr
49
+ self._parent = parent
50
+
51
+ @property
52
+ def path(self) -> str:
53
+ """
54
+ Virtual path of the entry as stored in the archive.
55
+
56
+ Returns:
57
+ str: Path string, or an empty string when unavailable.
58
+ """
59
+
60
+ lib = _ffi.load_lib()
61
+ ptr = lib.bethkit_archive_entry_path(self._ptr)
62
+ if not ptr:
63
+ return ""
64
+ return _ffi.copy_and_free_str(
65
+ ptr, lib.bethkit_archive_entry_path_free, lib
66
+ )
67
+
68
+ @property
69
+ def uncompressed_size(self) -> int:
70
+ """
71
+ Uncompressed size of the entry data in bytes.
72
+
73
+ Returns:
74
+ int: Byte count of the decompressed content.
75
+ """
76
+
77
+ return _ffi.load_lib().bethkit_archive_entry_uncompressed_size(
78
+ self._ptr
79
+ )
80
+
81
+ def __repr__(self) -> str:
82
+ """
83
+ Returns:
84
+ str: Developer-friendly representation showing the entry path.
85
+ """
86
+
87
+ return f"<ArchiveEntry {self.path!r}>"
88
+
89
+
90
+ class Archive:
91
+ """
92
+ An open Bethesda archive (BSA or BA2) in read-only mode.
93
+
94
+ Use as a context manager to guarantee that the native handle is
95
+ freed even on error::
96
+
97
+ with Archive.open(path) as arc:
98
+ data = arc.extract("meshes/foo.nif")
99
+ """
100
+
101
+ __ptr: int
102
+
103
+ def __init__(self, ptr: int) -> None:
104
+ """
105
+ Args:
106
+ ptr (int): Native handle returned by the FFI open call.
107
+ """
108
+
109
+ self.__ptr = ptr
110
+
111
+ def __check_open(self) -> int:
112
+ """
113
+ Return the native pointer, raising if the handle has been closed.
114
+
115
+ Returns:
116
+ int: Valid native pointer.
117
+
118
+ Raises:
119
+ BethkitClosedError: If the archive has been closed.
120
+ """
121
+
122
+ if not self.__ptr:
123
+ raise BethkitClosedError("Archive is closed")
124
+ return self.__ptr
125
+
126
+ @classmethod
127
+ def open(cls, path: Path) -> Archive:
128
+ """
129
+ Open an archive file from disk.
130
+
131
+ Args:
132
+ path (Path): Filesystem path to the ``.bsa`` or ``.ba2`` file.
133
+
134
+ Returns:
135
+ Archive: A new ``Archive`` wrapping the open file.
136
+
137
+ Raises:
138
+ BethkitNativeError: If the file cannot be opened or parsed.
139
+ """
140
+
141
+ lib = _ffi.load_lib()
142
+ ptr = lib.bethkit_archive_open(_ffi.enc(path))
143
+ if not ptr:
144
+ _ffi.raise_last_error(lib)
145
+ return cls(ptr)
146
+
147
+ def close(self) -> None:
148
+ """
149
+ Release the native archive handle.
150
+
151
+ Safe to call multiple times; subsequent calls are no-ops.
152
+ """
153
+
154
+ if self.__ptr:
155
+ _ffi.load_lib().bethkit_archive_free(self.__ptr)
156
+ self.__ptr = 0
157
+
158
+ def __enter__(self) -> Archive:
159
+ """Return *self* for use as a context manager."""
160
+
161
+ return self
162
+
163
+ def __exit__(self, *_: object) -> None:
164
+ """Close the archive when exiting the context."""
165
+
166
+ self.close()
167
+
168
+ def __del__(self) -> None:
169
+ """Free the native handle on garbage collection."""
170
+
171
+ try:
172
+ self.close()
173
+ except Exception:
174
+ pass
175
+
176
+ @property
177
+ def format_name(self) -> str:
178
+ """
179
+ Human-readable name of the archive format (e.g. ``"BSA"`` or
180
+ ``"BA2"``).
181
+
182
+ Returns:
183
+ str: Format identifier string.
184
+
185
+ Raises:
186
+ BethkitClosedError: If the archive has been closed.
187
+ """
188
+
189
+ lib = _ffi.load_lib()
190
+ raw: Optional[bytes] = lib.bethkit_archive_format_name(
191
+ self.__check_open()
192
+ )
193
+ return raw.decode("utf-8") if raw else ""
194
+
195
+ @property
196
+ def file_count(self) -> int:
197
+ """
198
+ Total number of file entries in the archive.
199
+
200
+ Returns:
201
+ int: Entry count.
202
+
203
+ Raises:
204
+ BethkitClosedError: If the archive has been closed.
205
+ """
206
+
207
+ return _ffi.load_lib().bethkit_archive_file_count(
208
+ self.__check_open()
209
+ )
210
+
211
+ def entry_at(self, index: int) -> ArchiveEntry:
212
+ """
213
+ Return the entry at the given index.
214
+
215
+ Args:
216
+ index (int): Zero-based entry index.
217
+
218
+ Returns:
219
+ ArchiveEntry: Borrowed entry for the given index.
220
+
221
+ Raises:
222
+ BethkitClosedError: If the archive has been closed.
223
+ BethkitNativeError: If *index* is out of range.
224
+ """
225
+
226
+ lib = _ffi.load_lib()
227
+ ptr = lib.bethkit_archive_entry_get(self.__check_open(), index)
228
+ if not ptr:
229
+ _ffi.raise_last_error(lib)
230
+ return ArchiveEntry(ptr, self)
231
+
232
+ def entries(self) -> Iterator[ArchiveEntry]:
233
+ """
234
+ Iterate over all entries in the archive.
235
+
236
+ Yields:
237
+ ArchiveEntry: Each entry in insertion order.
238
+
239
+ Raises:
240
+ BethkitClosedError: If the archive has been closed.
241
+ """
242
+
243
+ for i in range(self.file_count):
244
+ yield self.entry_at(i)
245
+
246
+ def extract(self, path: str) -> Optional[bytes]:
247
+ """
248
+ Extract a single file from the archive by its virtual path.
249
+
250
+ Returns ``None`` when the path is not found.
251
+
252
+ Args:
253
+ path (str): Virtual path of the entry to extract.
254
+
255
+ Returns:
256
+ Optional[bytes]: Decompressed file data, or ``None`` if the
257
+ path does not exist in the archive.
258
+
259
+ Raises:
260
+ BethkitClosedError: If the archive has been closed.
261
+ BethkitNativeError: If extraction fails for a reason other than
262
+ a missing path.
263
+ """
264
+
265
+ lib = _ffi.load_lib()
266
+ out_len = ctypes.c_size_t(0)
267
+ ptr = lib.bethkit_archive_extract(
268
+ self.__check_open(), _ffi.senc(path), ctypes.byref(out_len)
269
+ )
270
+ if not ptr:
271
+ return None
272
+ try:
273
+ return bytes(ctypes.string_at(ptr, out_len.value))
274
+ finally:
275
+ lib.bethkit_bytes_free(ptr, out_len.value)
276
+
277
+ def extract_required(self, path: str) -> bytes:
278
+ """
279
+ Extract a single file, raising when the path is not found.
280
+
281
+ Args:
282
+ path (str): Virtual path of the entry to extract.
283
+
284
+ Returns:
285
+ bytes: Decompressed file data.
286
+
287
+ Raises:
288
+ BethkitClosedError: If the archive has been closed.
289
+ BethkitNotFoundError: If the path is not in the archive.
290
+ BethkitNativeError: If extraction fails.
291
+ """
292
+
293
+ data = self.extract(path)
294
+ if data is None:
295
+ raise BethkitNotFoundError(f"Entry not found in archive: {path!r}")
296
+ return data
297
+
298
+ def extract_to_file(self, path: str, dest: Path) -> None:
299
+ """
300
+ Extract a single entry and write it to *dest* on disk.
301
+
302
+ Args:
303
+ path (str): Virtual path of the entry to extract.
304
+ dest (Path): Destination file path.
305
+
306
+ Raises:
307
+ BethkitClosedError: If the archive has been closed.
308
+ BethkitNativeError: If the path is not found or the write fails.
309
+ """
310
+
311
+ lib = _ffi.load_lib()
312
+ rc = lib.bethkit_archive_extract_to_file(
313
+ self.__check_open(), _ffi.senc(path), _ffi.enc(dest)
314
+ )
315
+ if rc != 0:
316
+ _ffi.raise_last_error(lib)
317
+
318
+ def __repr__(self) -> str:
319
+ """
320
+ Returns:
321
+ str: Developer-friendly representation showing format and count.
322
+ """
323
+
324
+ if not self.__ptr:
325
+ return "<Archive closed>"
326
+ return (
327
+ f"<Archive format={self.format_name!r} files={self.file_count}>"
328
+ )
329
+
330
+
331
+ class BsaWriter:
332
+ """
333
+ Builder for Bethesda Softworks Archive (BSA) files.
334
+
335
+ Create a writer, add files, then call :meth:`write_to` to produce
336
+ the BSA on disk. Use as a context manager to ensure the native
337
+ handle is released::
338
+
339
+ with BsaWriter(BsaVersion.SSE) as w:
340
+ w.add("meshes/foo.nif", data)
341
+ w.write_to(output_path)
342
+ """
343
+
344
+ __ptr: int
345
+
346
+ def __init__(self, version: BsaVersion) -> None:
347
+ """
348
+ Args:
349
+ version (BsaVersion): BSA format version to write.
350
+
351
+ Raises:
352
+ BethkitNativeError: If the native writer cannot be created.
353
+ """
354
+
355
+ lib = _ffi.load_lib()
356
+ ptr = lib.bethkit_bsa_writer_new(int(version))
357
+ if not ptr:
358
+ _ffi.raise_last_error(lib)
359
+ self.__ptr = ptr
360
+
361
+ def __check_open(self) -> int:
362
+ """
363
+ Return the native pointer, raising if the handle has been closed.
364
+
365
+ Returns:
366
+ int: Valid native pointer.
367
+
368
+ Raises:
369
+ BethkitClosedError: If the writer has been closed.
370
+ """
371
+
372
+ if not self.__ptr:
373
+ raise BethkitClosedError("BsaWriter is closed")
374
+ return self.__ptr
375
+
376
+ def close(self) -> None:
377
+ """
378
+ Release the native writer handle.
379
+
380
+ Safe to call multiple times; subsequent calls are no-ops.
381
+ """
382
+
383
+ if self.__ptr:
384
+ _ffi.load_lib().bethkit_bsa_writer_free(self.__ptr)
385
+ self.__ptr = 0
386
+
387
+ def __enter__(self) -> BsaWriter:
388
+ """Return *self* for use as a context manager."""
389
+
390
+ return self
391
+
392
+ def __exit__(self, *_: object) -> None:
393
+ """Free the writer when exiting the context."""
394
+
395
+ self.close()
396
+
397
+ def __del__(self) -> None:
398
+ """Free the native handle on garbage collection."""
399
+
400
+ try:
401
+ self.close()
402
+ except Exception:
403
+ pass
404
+
405
+ def set_compress(self, compress: bool) -> None:
406
+ """
407
+ Enable or disable default compression for entries.
408
+
409
+ Args:
410
+ compress (bool): ``True`` to enable compression by default.
411
+
412
+ Raises:
413
+ BethkitClosedError: If the writer has been closed.
414
+ BethkitNativeError: If the native call fails.
415
+ """
416
+
417
+ lib = _ffi.load_lib()
418
+ if lib.bethkit_bsa_writer_set_compress(
419
+ self.__check_open(), compress
420
+ ) != 0:
421
+ _ffi.raise_last_error(lib)
422
+
423
+ def set_embed_names(self, embed: bool) -> None:
424
+ """
425
+ Enable or disable embedded file-name strings in the archive.
426
+
427
+ Args:
428
+ embed (bool): ``True`` to embed file names.
429
+
430
+ Raises:
431
+ BethkitClosedError: If the writer has been closed.
432
+ BethkitNativeError: If the native call fails.
433
+ """
434
+
435
+ lib = _ffi.load_lib()
436
+ if lib.bethkit_bsa_writer_set_embed_names(
437
+ self.__check_open(), embed
438
+ ) != 0:
439
+ _ffi.raise_last_error(lib)
440
+
441
+ def add(self, path: str, data: bytes) -> None:
442
+ """
443
+ Add a file to the archive.
444
+
445
+ Args:
446
+ path (str): Virtual path used to store the file inside the
447
+ archive.
448
+ data (bytes): File contents.
449
+
450
+ Raises:
451
+ BethkitClosedError: If the writer has been closed.
452
+ BethkitNativeError: If the entry cannot be added.
453
+ """
454
+
455
+ lib = _ffi.load_lib()
456
+ buf = _buf_from_bytes(data)
457
+ if lib.bethkit_bsa_writer_add(
458
+ self.__check_open(), _ffi.senc(path), buf, len(data)
459
+ ) != 0:
460
+ _ffi.raise_last_error(lib)
461
+
462
+ def write_to(self, dest: Path) -> None:
463
+ """
464
+ Finalise and write the archive to disk.
465
+
466
+ Args:
467
+ dest (Path): Destination file path.
468
+
469
+ Raises:
470
+ BethkitClosedError: If the writer has been closed.
471
+ BethkitNativeError: If serialisation or the write fails.
472
+ """
473
+
474
+ lib = _ffi.load_lib()
475
+ if lib.bethkit_bsa_writer_write_to(
476
+ self.__check_open(), _ffi.enc(dest)
477
+ ) != 0:
478
+ _ffi.raise_last_error(lib)
479
+
480
+
481
+ class Ba2GnrlWriter:
482
+ """
483
+ Builder for Fallout 4 BA2 general (non-texture) archives.
484
+
485
+ Use this for non-texture assets packed in the general BA2 format.
486
+ The interface mirrors :class:`BsaWriter`.
487
+ """
488
+
489
+ __ptr: int
490
+
491
+ def __init__(self, version: Ba2Version) -> None:
492
+ """
493
+ Args:
494
+ version (Ba2Version): BA2 format version to write.
495
+
496
+ Raises:
497
+ BethkitNativeError: If the native writer cannot be created.
498
+ """
499
+
500
+ lib = _ffi.load_lib()
501
+ ptr = lib.bethkit_ba2_gnrl_writer_new(int(version))
502
+ if not ptr:
503
+ _ffi.raise_last_error(lib)
504
+ self.__ptr = ptr
505
+
506
+ def __check_open(self) -> int:
507
+ """
508
+ Return the native pointer, raising if the handle has been closed.
509
+
510
+ Returns:
511
+ int: Valid native pointer.
512
+
513
+ Raises:
514
+ BethkitClosedError: If the writer has been closed.
515
+ """
516
+
517
+ if not self.__ptr:
518
+ raise BethkitClosedError("Ba2GnrlWriter is closed")
519
+ return self.__ptr
520
+
521
+ def close(self) -> None:
522
+ """
523
+ Release the native writer handle.
524
+
525
+ Safe to call multiple times; subsequent calls are no-ops.
526
+ """
527
+
528
+ if self.__ptr:
529
+ _ffi.load_lib().bethkit_ba2_gnrl_writer_free(self.__ptr)
530
+ self.__ptr = 0
531
+
532
+ def __enter__(self) -> Ba2GnrlWriter:
533
+ """Return *self* for use as a context manager."""
534
+
535
+ return self
536
+
537
+ def __exit__(self, *_: object) -> None:
538
+ """Free the writer when exiting the context."""
539
+
540
+ self.close()
541
+
542
+ def __del__(self) -> None:
543
+ """Free the native handle on garbage collection."""
544
+
545
+ try:
546
+ self.close()
547
+ except Exception:
548
+ pass
549
+
550
+ def add(self, path: str, data: bytes) -> None:
551
+ """
552
+ Add a file to the archive.
553
+
554
+ Args:
555
+ path (str): Virtual path inside the archive.
556
+ data (bytes): File contents.
557
+
558
+ Raises:
559
+ BethkitClosedError: If the writer has been closed.
560
+ BethkitNativeError: If the entry cannot be added.
561
+ """
562
+
563
+ lib = _ffi.load_lib()
564
+ buf = _buf_from_bytes(data)
565
+ if lib.bethkit_ba2_gnrl_writer_add(
566
+ self.__check_open(), _ffi.senc(path), buf, len(data)
567
+ ) != 0:
568
+ _ffi.raise_last_error(lib)
569
+
570
+ def write_to(self, dest: Path) -> None:
571
+ """
572
+ Finalise and write the archive to disk.
573
+
574
+ Args:
575
+ dest (Path): Destination file path.
576
+
577
+ Raises:
578
+ BethkitClosedError: If the writer has been closed.
579
+ BethkitNativeError: If serialisation or the write fails.
580
+ """
581
+
582
+ lib = _ffi.load_lib()
583
+ if lib.bethkit_ba2_gnrl_writer_write_to(
584
+ self.__check_open(), _ffi.enc(dest)
585
+ ) != 0:
586
+ _ffi.raise_last_error(lib)
587
+
588
+
589
+ class Ba2Dx10Writer:
590
+ """
591
+ Builder for Fallout 4 BA2 DX10 (texture) archives.
592
+
593
+ Use this for texture assets packed in the DX10 BA2 format used by
594
+ Fallout 4.
595
+ """
596
+
597
+ __ptr: int
598
+
599
+ def __init__(self, version: Ba2Version) -> None:
600
+ """
601
+ Args:
602
+ version (Ba2Version): BA2 format version to write.
603
+
604
+ Raises:
605
+ BethkitNativeError: If the native writer cannot be created.
606
+ """
607
+
608
+ lib = _ffi.load_lib()
609
+ ptr = lib.bethkit_ba2_dx10_writer_new(int(version))
610
+ if not ptr:
611
+ _ffi.raise_last_error(lib)
612
+ self.__ptr = ptr
613
+
614
+ def __check_open(self) -> int:
615
+ """
616
+ Return the native pointer, raising if the handle has been closed.
617
+
618
+ Returns:
619
+ int: Valid native pointer.
620
+
621
+ Raises:
622
+ BethkitClosedError: If the writer has been closed.
623
+ """
624
+
625
+ if not self.__ptr:
626
+ raise BethkitClosedError("Ba2Dx10Writer is closed")
627
+ return self.__ptr
628
+
629
+ def close(self) -> None:
630
+ """
631
+ Release the native writer handle.
632
+
633
+ Safe to call multiple times; subsequent calls are no-ops.
634
+ """
635
+
636
+ if self.__ptr:
637
+ _ffi.load_lib().bethkit_ba2_dx10_writer_free(self.__ptr)
638
+ self.__ptr = 0
639
+
640
+ def __enter__(self) -> Ba2Dx10Writer:
641
+ """Return *self* for use as a context manager."""
642
+
643
+ return self
644
+
645
+ def __exit__(self, *_: object) -> None:
646
+ """Free the writer when exiting the context."""
647
+
648
+ self.close()
649
+
650
+ def __del__(self) -> None:
651
+ """Free the native handle on garbage collection."""
652
+
653
+ try:
654
+ self.close()
655
+ except Exception:
656
+ pass
657
+
658
+ def add(self, path: str, data: bytes) -> None:
659
+ """
660
+ Add a texture file to the archive.
661
+
662
+ Args:
663
+ path (str): Virtual path inside the archive.
664
+ data (bytes): Raw DDS texture data.
665
+
666
+ Raises:
667
+ BethkitClosedError: If the writer has been closed.
668
+ BethkitNativeError: If the entry cannot be added.
669
+ """
670
+
671
+ lib = _ffi.load_lib()
672
+ buf = _buf_from_bytes(data)
673
+ if lib.bethkit_ba2_dx10_writer_add(
674
+ self.__check_open(), _ffi.senc(path), buf, len(data)
675
+ ) != 0:
676
+ _ffi.raise_last_error(lib)
677
+
678
+ def write_to(self, dest: Path) -> None:
679
+ """
680
+ Finalise and write the archive to disk.
681
+
682
+ Args:
683
+ dest (Path): Destination file path.
684
+
685
+ Raises:
686
+ BethkitClosedError: If the writer has been closed.
687
+ BethkitNativeError: If serialisation or the write fails.
688
+ """
689
+
690
+ lib = _ffi.load_lib()
691
+ if lib.bethkit_ba2_dx10_writer_write_to(
692
+ self.__check_open(), _ffi.enc(dest)
693
+ ) != 0:
694
+ _ffi.raise_last_error(lib)