python-gdb 0.3.0__tar.gz → 0.4.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.
- {python_gdb-0.3.0 → python_gdb-0.4.0}/PKG-INFO +1 -1
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pygdb/__init__.py +24 -1
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pygdb/gdb.py +88 -2
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pygdb/gdb_reader.py +168 -20
- python_gdb-0.4.0/pygdb/registry.py +1395 -0
- python_gdb-0.4.0/pygdb/report.py +49 -0
- python_gdb-0.4.0/pygdb/unseen.py +488 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/rust/Cargo.lock +1 -1
- {python_gdb-0.3.0 → python_gdb-0.4.0}/rust/Cargo.toml +1 -1
- python_gdb-0.3.0/pygdb/registry.py +0 -272
- {python_gdb-0.3.0 → python_gdb-0.4.0}/LICENSE +0 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/README.md +0 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pygdb/grd_reader.py +0 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pygdb/lzrw1.py +0 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/pyproject.toml +0 -0
- {python_gdb-0.3.0 → python_gdb-0.4.0}/rust/src/lib.rs +0 -0
|
@@ -29,38 +29,61 @@ from .gdb_reader import (
|
|
|
29
29
|
header_fields,
|
|
30
30
|
iter_blobs,
|
|
31
31
|
read_blob_directory,
|
|
32
|
+
read_blob_symbols,
|
|
32
33
|
read_blob_values,
|
|
33
34
|
read_channels,
|
|
34
35
|
read_lines,
|
|
35
36
|
)
|
|
36
37
|
from .grd_reader import GrdHeader, GRDParseWarning, read_grd
|
|
37
38
|
from .lzrw1 import LZRW1DecodeError
|
|
38
|
-
from .registry import
|
|
39
|
+
from .registry import (
|
|
40
|
+
ChannelMaker,
|
|
41
|
+
DisplayListEntry,
|
|
42
|
+
ProjectionParameters,
|
|
43
|
+
find_channel_makers,
|
|
44
|
+
find_channel_roles,
|
|
45
|
+
find_channel_settings,
|
|
46
|
+
find_coordinate_systems,
|
|
47
|
+
find_display_lists,
|
|
48
|
+
find_projection_parameters,
|
|
49
|
+
)
|
|
50
|
+
from .unseen import GDBUnseenFeatureWarning, unseen_feature_report
|
|
39
51
|
|
|
40
52
|
__all__ = [
|
|
41
53
|
"GDB",
|
|
42
54
|
"BlobDirectory",
|
|
43
55
|
"BlobHeader",
|
|
56
|
+
"ChannelMaker",
|
|
44
57
|
"ChannelRecord",
|
|
45
58
|
"CompressionInfo",
|
|
59
|
+
"DisplayListEntry",
|
|
46
60
|
"GDBParseWarning",
|
|
61
|
+
"GDBUnseenFeatureWarning",
|
|
47
62
|
"GRDParseWarning",
|
|
48
63
|
"GrdHeader",
|
|
49
64
|
"LZRW1DecodeError",
|
|
50
65
|
"LineRecord",
|
|
66
|
+
"ProjectionParameters",
|
|
51
67
|
"check_magic",
|
|
52
68
|
"exact_line_table_start",
|
|
53
69
|
"find_blob",
|
|
70
|
+
"find_channel_makers",
|
|
71
|
+
"find_channel_roles",
|
|
72
|
+
"find_channel_settings",
|
|
54
73
|
"find_channel_table",
|
|
55
74
|
"find_coordinate_systems",
|
|
75
|
+
"find_display_lists",
|
|
56
76
|
"find_line_table",
|
|
77
|
+
"find_projection_parameters",
|
|
57
78
|
"header_fields",
|
|
58
79
|
"iter_blobs",
|
|
59
80
|
"read_blob_directory",
|
|
81
|
+
"read_blob_symbols",
|
|
60
82
|
"read_blob_values",
|
|
61
83
|
"read_channels",
|
|
62
84
|
"read_grd",
|
|
63
85
|
"read_lines",
|
|
86
|
+
"unseen_feature_report",
|
|
64
87
|
]
|
|
65
88
|
|
|
66
89
|
from importlib.metadata import PackageNotFoundError, version
|
|
@@ -27,6 +27,7 @@ from .gdb_reader import (
|
|
|
27
27
|
GS_TYPE_DUMMY_VALUE,
|
|
28
28
|
GS_TYPE_NUMPY_DTYPE,
|
|
29
29
|
LineRecord,
|
|
30
|
+
blob_region_start,
|
|
30
31
|
check_magic,
|
|
31
32
|
header_fields,
|
|
32
33
|
iter_blobs,
|
|
@@ -38,7 +39,18 @@ from .gdb_reader import (
|
|
|
38
39
|
DIRECTORY_INVALID,
|
|
39
40
|
DIRECTORY_LIVE,
|
|
40
41
|
)
|
|
41
|
-
from .registry import
|
|
42
|
+
from .registry import (
|
|
43
|
+
ChannelMaker,
|
|
44
|
+
DisplayListEntry,
|
|
45
|
+
ProjectionParameters,
|
|
46
|
+
find_channel_makers,
|
|
47
|
+
find_channel_roles,
|
|
48
|
+
find_channel_settings,
|
|
49
|
+
find_coordinate_systems,
|
|
50
|
+
find_display_lists,
|
|
51
|
+
find_projection_parameters,
|
|
52
|
+
)
|
|
53
|
+
from . import unseen
|
|
42
54
|
|
|
43
55
|
# docs/spec.md section 7
|
|
44
56
|
_DB_COMP_NAMES = {
|
|
@@ -176,6 +188,8 @@ class GDB:
|
|
|
176
188
|
f"not a recognized .gdb file"
|
|
177
189
|
)
|
|
178
190
|
self._fields = header_fields(header)
|
|
191
|
+
unseen.check_header(path, header)
|
|
192
|
+
unseen.check_admin_objects(path)
|
|
179
193
|
self._channels: Optional[List[ChannelRecord]] = None
|
|
180
194
|
self._lines: Optional[List[LineRecord]] = None
|
|
181
195
|
self._lines_exact = False
|
|
@@ -184,6 +198,10 @@ class GDB:
|
|
|
184
198
|
self._blob_index: Optional[Dict[Tuple[int, int], BlobHeader]] = None
|
|
185
199
|
self._coordinate_systems: Optional[List[str]] = None
|
|
186
200
|
self._coordinate_channels: Optional[Dict[str, Optional[str]]] = None
|
|
201
|
+
self._channel_settings: Optional[Dict[str, Dict[str, str]]] = None
|
|
202
|
+
self._projection_parameters: Optional[Dict[str, ProjectionParameters]] = None
|
|
203
|
+
self._channel_makers: Optional[Dict[str, ChannelMaker]] = None
|
|
204
|
+
self._display_lists: Optional[List[List[DisplayListEntry]]] = None
|
|
187
205
|
|
|
188
206
|
def __repr__(self) -> str:
|
|
189
207
|
return f"GDB({self.path!r})"
|
|
@@ -273,6 +291,68 @@ class GDB:
|
|
|
273
291
|
)
|
|
274
292
|
return self._coordinate_channels
|
|
275
293
|
|
|
294
|
+
@property
|
|
295
|
+
def channel_settings(self) -> Dict[str, Dict[str, str]]:
|
|
296
|
+
"""
|
|
297
|
+
dict of {str : dict of {str : str}}: Real per-channel settings
|
|
298
|
+
recorded in this file's own internal registry
|
|
299
|
+
(docs/provenance/notes.md section 6.8c), e.g. `{"raw_mag":
|
|
300
|
+
{"UNITS": "nT"}, ...}`. Only a channel with at least one
|
|
301
|
+
populated registry key is present; most real files have none for
|
|
302
|
+
most channels -- see `pygdb.registry.find_channel_settings` for
|
|
303
|
+
what is and isn't decoded (the registry's key/value entries, not
|
|
304
|
+
its nested `MAKER` records, which are `channel_makers`).
|
|
305
|
+
"""
|
|
306
|
+
if self._channel_settings is None:
|
|
307
|
+
self._channel_settings = find_channel_settings(self.path, channels=self.channels)
|
|
308
|
+
return self._channel_settings
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def channel_makers(self) -> Dict[str, ChannelMaker]:
|
|
312
|
+
"""
|
|
313
|
+
dict of {str : ChannelMaker}: How each channel was made, from the
|
|
314
|
+
`MAKER` records in this file's registry (docs/spec.md section 9) --
|
|
315
|
+
the tool (`"newchan.gx"`, a math expression, `"newxy.gx"`, ...),
|
|
316
|
+
its label, and the parameters it ran with, e.g. a derived
|
|
317
|
+
channel's formula. Only channels with a record are present; see
|
|
318
|
+
`pygdb.registry.find_channel_makers`.
|
|
319
|
+
"""
|
|
320
|
+
if self._channel_makers is None:
|
|
321
|
+
self._channel_makers = find_channel_makers(self.path, channels=self.channels)
|
|
322
|
+
return self._channel_makers
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def display_lists(self) -> List[List[DisplayListEntry]]:
|
|
326
|
+
"""
|
|
327
|
+
list of list of DisplayListEntry: The file's `Display List` objects
|
|
328
|
+
(docs/spec.md section 9) -- **[LIKELY]** the channels shown in the
|
|
329
|
+
database's spreadsheet view -- each entry resolved by handle to the
|
|
330
|
+
channel's current name. See `pygdb.registry.find_display_lists`.
|
|
331
|
+
"""
|
|
332
|
+
if self._display_lists is None:
|
|
333
|
+
self._display_lists = find_display_lists(self.path, channels=self.channels)
|
|
334
|
+
return self._display_lists
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def projection_parameters(self) -> Dict[str, ProjectionParameters]:
|
|
338
|
+
"""
|
|
339
|
+
dict of {str : ProjectionParameters}: Real geodetic parameters for
|
|
340
|
+
each coordinate system named in `coordinate_systems`, decoded from
|
|
341
|
+
this file's own internal IPJ registry (docs/spec.md section 8,
|
|
342
|
+
docs/provenance/notes.md section 6.7b) -- datum, ellipsoid, and
|
|
343
|
+
(when this coordinate system is a projected one) central
|
|
344
|
+
meridian, scale factor, and false easting/northing. An empty
|
|
345
|
+
dict just means no `IPJ` content was found, same as
|
|
346
|
+
`coordinate_systems`; see `pygdb.registry.find_projection_parameters`
|
|
347
|
+
for exactly what is and isn't decoded.
|
|
348
|
+
"""
|
|
349
|
+
if self._projection_parameters is None:
|
|
350
|
+
max_real_line_slot = max((line.index for line in self.lines), default=-1)
|
|
351
|
+
self._projection_parameters = find_projection_parameters(
|
|
352
|
+
self.path, max_real_line_slot=max_real_line_slot,
|
|
353
|
+
)
|
|
354
|
+
return self._projection_parameters
|
|
355
|
+
|
|
276
356
|
# -- channels / lines ----------------------------------------------------
|
|
277
357
|
|
|
278
358
|
@property
|
|
@@ -532,7 +612,13 @@ class GDB:
|
|
|
532
612
|
f"against the line's other channels, this is the likely cause (see issue #2)"
|
|
533
613
|
)
|
|
534
614
|
return
|
|
535
|
-
|
|
615
|
+
# Directory start pages count from the blob region's start (header
|
|
616
|
+
# word 108), not from the first blob walked: a real file can hold a
|
|
617
|
+
# page there that is not a blob (docs/spec.md section 6.2).
|
|
618
|
+
with open(self.path, "rb") as f:
|
|
619
|
+
first_offset = blob_region_start(f.read(128))
|
|
620
|
+
if first_offset is None:
|
|
621
|
+
first_offset = min(by_offset)
|
|
536
622
|
skipped: List[Tuple[int, int]] = []
|
|
537
623
|
invalid: List[Tuple[int, int]] = []
|
|
538
624
|
for key in copies:
|
|
@@ -50,11 +50,12 @@ import warnings
|
|
|
50
50
|
import zlib
|
|
51
51
|
from dataclasses import dataclass
|
|
52
52
|
|
|
53
|
-
from typing import BinaryIO, List, Optional, Tuple
|
|
53
|
+
from typing import BinaryIO, Dict, List, Optional, Tuple
|
|
54
54
|
|
|
55
55
|
import numpy as np
|
|
56
56
|
|
|
57
57
|
from . import lzrw1 as _lzrw1
|
|
58
|
+
from . import unseen
|
|
58
59
|
|
|
59
60
|
try:
|
|
60
61
|
from . import _native as _native_ext
|
|
@@ -330,12 +331,24 @@ class ChannelRecord:
|
|
|
330
331
|
printable name (observed for real in DB_Mag_833.gdb -- see
|
|
331
332
|
docs/provenance/notes.md). Real records seen so far always
|
|
332
333
|
have dtype either a known GS_* code (0-13) or a small
|
|
333
|
-
negative string width,
|
|
334
|
-
DB_CHAN_FORMAT_* range (0-6)
|
|
334
|
+
negative string width, a format code in the known
|
|
335
|
+
DB_CHAN_FORMAT_* range (0-6), and an array width of at least
|
|
336
|
+
1 (elements per fiducial).
|
|
337
|
+
|
|
338
|
+
Notes
|
|
339
|
+
-----
|
|
340
|
+
The array-width test catches leftover records that pass the
|
|
341
|
+
other two: 16 records in three real GSQ files (`DB_AGG_1213`,
|
|
342
|
+
`DB_Mag_1213`, `DB_Mag_1212`) hold clean names -- fill-pattern
|
|
343
|
+
names, projection-catalog names, second copies of real channel
|
|
344
|
+
names -- with dtype 0 and array width 0 (or garbage), and own no
|
|
345
|
+
data blob at all. Every genuine channel in the corpus has width
|
|
346
|
+
1 or more (docs/provenance/notes.md section 6.2d).
|
|
335
347
|
"""
|
|
336
348
|
dtype_ok = self.dtype_code in GS_TYPE_NAMES or -256 <= self.dtype_code < 0
|
|
337
349
|
format_ok = self.format_code in DB_CHAN_FORMAT_NAMES
|
|
338
|
-
|
|
350
|
+
width_ok = self.array_width >= 1
|
|
351
|
+
return dtype_ok and format_ok and width_ok
|
|
339
352
|
|
|
340
353
|
|
|
341
354
|
def _read_name(raw: bytes, offset: int, max_len: int = 64):
|
|
@@ -400,7 +413,7 @@ def check_magic(data: bytes) -> bool:
|
|
|
400
413
|
The full 16-byte `HEADER_SIGNATURE` is only **[LIKELY]** -- it
|
|
401
414
|
matched exactly in 8/9 real files, but one real file
|
|
402
415
|
(DB_Mag_Elaine_1003.gdb, from GSQ's Mount Gordon delivery) has
|
|
403
|
-
`f0 f0 f0 f0` at bytes
|
|
416
|
+
`f0 f0 f0 f0` at bytes 4-7 instead of the usual `00 00 00 00`.
|
|
404
417
|
That file is otherwise structurally normal
|
|
405
418
|
(chans_max/users_max/page_size all decode sanely), so this looks
|
|
406
419
|
like a real, if rare, variation in that sub-block rather than a
|
|
@@ -679,6 +692,10 @@ def read_channels(path: str) -> List[ChannelRecord]:
|
|
|
679
692
|
# DB_CHAN_FORMAT_* range -- garbage doesn't. See docs/provenance/notes.md.
|
|
680
693
|
continue
|
|
681
694
|
channels.append(rec)
|
|
695
|
+
unseen.check_channels(path, channels)
|
|
696
|
+
if fields["users_max"] is not None:
|
|
697
|
+
# The user table directly follows the channel table (docs/spec.md section 2.1).
|
|
698
|
+
unseen.check_users(path, data, table_start + chans_max * SYMBOL_RECORD_SIZE, fields["users_max"])
|
|
682
699
|
return channels
|
|
683
700
|
|
|
684
701
|
|
|
@@ -998,6 +1015,7 @@ def _read_lines(path: str) -> Tuple[List[LineRecord], bool]:
|
|
|
998
1015
|
rec = _parse_line_record(data, exact_start + i * SYMBOL_RECORD_SIZE, i)
|
|
999
1016
|
if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
|
|
1000
1017
|
lines.append(rec)
|
|
1018
|
+
unseen.check_lines(path, data, lines)
|
|
1001
1019
|
return lines, True
|
|
1002
1020
|
|
|
1003
1021
|
try:
|
|
@@ -1189,12 +1207,32 @@ def blob_region_start(data: bytes) -> Optional[int]:
|
|
|
1189
1207
|
return start_page * page_size
|
|
1190
1208
|
|
|
1191
1209
|
|
|
1210
|
+
def _next_blob_page(f: BinaryIO, start: int, page_size: int, size: int) -> Optional[int]:
|
|
1211
|
+
"""Offset of the first page boundary at or after `start` whose first
|
|
1212
|
+
bytes are the blob magic, or `None` if there is none before `size`
|
|
1213
|
+
minus one header. Reads in large chunks rather than seeking per page."""
|
|
1214
|
+
chunk_pages = max(1, (1 << 20) // page_size)
|
|
1215
|
+
pos = start
|
|
1216
|
+
while pos + BLOB_HEADER_SIZE <= size:
|
|
1217
|
+
f.seek(pos)
|
|
1218
|
+
buf = f.read(chunk_pages * page_size)
|
|
1219
|
+
if not buf:
|
|
1220
|
+
return None
|
|
1221
|
+
for i in range(0, len(buf), page_size):
|
|
1222
|
+
if buf[i:i + 4] == BLOB_MAGIC and pos + i + BLOB_HEADER_SIZE <= size:
|
|
1223
|
+
return pos + i
|
|
1224
|
+
pos += len(buf)
|
|
1225
|
+
return None
|
|
1226
|
+
|
|
1227
|
+
|
|
1192
1228
|
def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
1193
1229
|
"""
|
|
1194
1230
|
Walk the self-describing blob chain.
|
|
1195
1231
|
|
|
1196
1232
|
Walks from the start of the blob region to end of file (or
|
|
1197
|
-
`max_blobs`, or
|
|
1233
|
+
`max_blobs`, or a framing anomaly it cannot step past). A page that
|
|
1234
|
+
does not start a blob is skipped: the walk resumes at the next page
|
|
1235
|
+
that does.
|
|
1198
1236
|
|
|
1199
1237
|
Parameters
|
|
1200
1238
|
----------
|
|
@@ -1212,7 +1250,9 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
|
1212
1250
|
-----
|
|
1213
1251
|
GDBParseWarning
|
|
1214
1252
|
Reaching the file's true end cleanly is silent (the expected,
|
|
1215
|
-
common case)
|
|
1253
|
+
common case). Pages skipped because they do not start a blob get
|
|
1254
|
+
one summary warning (count and offsets). A magic mismatch with no
|
|
1255
|
+
later blob page, a non-positive `n_pages`,
|
|
1216
1256
|
a file that ends mid-header, or landing short of true EOF by
|
|
1217
1257
|
less than one full header (i.e. real leftover bytes, not
|
|
1218
1258
|
enough to be read at all) are all real anomalies and each gets
|
|
@@ -1230,7 +1270,12 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
|
1230
1270
|
on the true file size) on 20 real files spanning all 3 agencies
|
|
1231
1271
|
this project has files from and all three `DB_COMP_*` compression
|
|
1232
1272
|
modes, 2MB to 1.93GB -- see docs/provenance/notes.md section
|
|
1233
|
-
6.6b/6.6d/6.9.
|
|
1273
|
+
6.6b/6.6d/6.9. One later real file (OpenEI BRIDGE
|
|
1274
|
+
`GP_Master_Gravity_11082023.gdb`, 2023) has pages between blobs
|
|
1275
|
+
that are not blobs: one page of leftover data at the region start
|
|
1276
|
+
and a run of 96 all-zero pages. Its blobs are still contiguous
|
|
1277
|
+
around those gaps, and the resynchronizing walk lands exactly on
|
|
1278
|
+
the file's end.
|
|
1234
1279
|
|
|
1235
1280
|
As a generator, this already "returns partial results" in the most
|
|
1236
1281
|
natural way possible: whatever's been yielded before a problem is
|
|
@@ -1261,6 +1306,7 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
|
1261
1306
|
return
|
|
1262
1307
|
f.seek(off)
|
|
1263
1308
|
n = 0
|
|
1309
|
+
skipped_runs = [] # [(first offset, page count)] of pages that are not blobs
|
|
1264
1310
|
while off + BLOB_HEADER_SIZE <= size:
|
|
1265
1311
|
if max_blobs is not None and n >= max_blobs:
|
|
1266
1312
|
return
|
|
@@ -1275,17 +1321,25 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
|
1275
1321
|
f"walked -- file is likely truncated; returning the "
|
|
1276
1322
|
f"{n} blob(s) already yielded"
|
|
1277
1323
|
)
|
|
1278
|
-
|
|
1324
|
+
return
|
|
1325
|
+
# A page that is not a blob header. Real files can hold
|
|
1326
|
+
# such pages between blobs -- leftover data or never-written
|
|
1327
|
+
# zero pages (docs/spec.md section 6.2) -- so resynchronize on
|
|
1328
|
+
# the next page that starts with the blob magic.
|
|
1329
|
+
resume = _next_blob_page(f, off + page_size, page_size, size)
|
|
1330
|
+
if resume is None:
|
|
1279
1331
|
_warn(
|
|
1280
1332
|
f"{path}: blob magic mismatch at offset {off} "
|
|
1281
1333
|
f"(got {raw[:4].hex()}, expected {BLOB_MAGIC.hex()}) "
|
|
1282
|
-
f"after {n} blob(s) successfully walked
|
|
1283
|
-
f"
|
|
1284
|
-
f"
|
|
1285
|
-
f"anomaly or an administrative-blob variant not yet "
|
|
1286
|
-
f"understood (docs/provenance/notes.md section 6.4/6.9)"
|
|
1334
|
+
f"after {n} blob(s) successfully walked, and no later "
|
|
1335
|
+
f"page starts a blob -- stopping the chain walk here "
|
|
1336
|
+
f"and returning the {n} blob(s) already yielded"
|
|
1287
1337
|
)
|
|
1288
|
-
|
|
1338
|
+
return
|
|
1339
|
+
skipped_runs.append((off, (resume - off) // page_size))
|
|
1340
|
+
off = resume
|
|
1341
|
+
f.seek(off)
|
|
1342
|
+
continue
|
|
1289
1343
|
if blob.n_pages <= 0:
|
|
1290
1344
|
_warn(
|
|
1291
1345
|
f"{path}: blob at offset {off} (blob_index={blob.blob_index}) "
|
|
@@ -1312,6 +1366,15 @@ def iter_blobs(path: str, max_blobs: Optional[int] = None):
|
|
|
1312
1366
|
f.seek(skip, 1)
|
|
1313
1367
|
off += blob.n_pages * page_size
|
|
1314
1368
|
n += 1
|
|
1369
|
+
if skipped_runs:
|
|
1370
|
+
pages = sum(count for _start, count in skipped_runs)
|
|
1371
|
+
runs = ", ".join(f"{count} at offset {start}" for start, count in skipped_runs[:5])
|
|
1372
|
+
more = f" and {len(skipped_runs) - 5} more run(s)" if len(skipped_runs) > 5 else ""
|
|
1373
|
+
_warn(
|
|
1374
|
+
f"{path}: skipped {pages} page(s) of the blob region that do not "
|
|
1375
|
+
f"start a blob ({runs}{more}) and resumed the chain walk after "
|
|
1376
|
+
f"each -- leftover or never-written pages between blobs"
|
|
1377
|
+
)
|
|
1315
1378
|
if n > 0 and off > size:
|
|
1316
1379
|
# The last blob successfully parsed claimed a page count that
|
|
1317
1380
|
# implies more data than the file actually contains -- off
|
|
@@ -1403,7 +1466,11 @@ def find_blob(path: str, line_slot: int, channel_slot: int, chans_max: Optional[
|
|
|
1403
1466
|
|
|
1404
1467
|
DIRECTORY_OFFSET = 280 # [CONFIRMED] first blob-directory slot -- docs/spec.md section 2.2
|
|
1405
1468
|
_DIRECTORY_SLOT_DTYPE = np.dtype([("word", "<u4"), ("n_pages", "<u2")]) # 6 bytes, unaligned
|
|
1406
|
-
|
|
1469
|
+
# A live entry's 32-bit word has the 0x80000000 bit set; the 0x40000000
|
|
1470
|
+
# bit flips each time the blob is rewritten (docs/spec.md section 2.2), so
|
|
1471
|
+
# the top nibble is 0x8 or 0xC. The remaining 30 bits are the start page.
|
|
1472
|
+
_DIRECTORY_LIVE_BIT = 0x80000000
|
|
1473
|
+
_DIRECTORY_PAGE_MASK = 0x3FFFFFFF
|
|
1407
1474
|
|
|
1408
1475
|
DIRECTORY_LIVE = "live"
|
|
1409
1476
|
DIRECTORY_ABSENT = "absent"
|
|
@@ -1433,7 +1500,11 @@ class BlobDirectory:
|
|
|
1433
1500
|
The directory is an array of 6-byte slots starting at file offset
|
|
1434
1501
|
280. A live entry is `(0x80000000 | start page, n_pages)` where
|
|
1435
1502
|
the start page is relative to the first blob (`(offset - blob
|
|
1436
|
-
region start) / page_size`)
|
|
1503
|
+
region start) / page_size`), optionally with the `0x40000000` bit,
|
|
1504
|
+
which flips each time the blob is rewritten. (Every data entry in
|
|
1505
|
+
the first 22 corpus files read `0x8`; the first data entry with
|
|
1506
|
+
`0xC`, in USGS OFR 2011-1270 `Kalay_nk.gdb`, is the live copy -- the
|
|
1507
|
+
other copy is on the free list.) Across the real corpus it addressed
|
|
1437
1508
|
100% of the real (line, channel) blobs of 20 of 22 files, and for
|
|
1438
1509
|
every duplicated pair with an independent oracle (13 of 13) it
|
|
1439
1510
|
pointed at the correct copy. A slot that is all-zero belongs to a
|
|
@@ -1477,9 +1548,9 @@ class BlobDirectory:
|
|
|
1477
1548
|
if entry is None:
|
|
1478
1549
|
return DIRECTORY_ABSENT, None
|
|
1479
1550
|
word, n_pages = entry
|
|
1480
|
-
if word
|
|
1551
|
+
if not word & _DIRECTORY_LIVE_BIT:
|
|
1481
1552
|
return DIRECTORY_INVALID, None
|
|
1482
|
-
blob = blobs_by_offset.get(first_offset + (word &
|
|
1553
|
+
blob = blobs_by_offset.get(first_offset + (word & _DIRECTORY_PAGE_MASK) * page_size)
|
|
1483
1554
|
if blob is None or blob.blob_index != blob_index or blob.n_pages != n_pages:
|
|
1484
1555
|
return DIRECTORY_INVALID, None
|
|
1485
1556
|
return DIRECTORY_LIVE, blob
|
|
@@ -1540,6 +1611,77 @@ def read_blob_directory(path: str) -> Optional[BlobDirectory]:
|
|
|
1540
1611
|
return BlobDirectory(data_slots=data_slots, entries=entries)
|
|
1541
1612
|
|
|
1542
1613
|
|
|
1614
|
+
# Blob-symbol record fields, measured from the record's first byte (its
|
|
1615
|
+
# name) -- docs/spec.md section 2.1, docs/provenance/notes.md section 6.2d.
|
|
1616
|
+
_BLOB_SYMBOL_CATEGORY_OFFSET = 76
|
|
1617
|
+
_DB_CATEGORY_BLOB_NORMAL = 0 # vendor constant; the live-symbol category
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def read_blob_symbols(path: str) -> Optional[Dict[int, str]]:
|
|
1621
|
+
"""
|
|
1622
|
+
Read the names of the file's live administrative objects.
|
|
1623
|
+
|
|
1624
|
+
Parameters
|
|
1625
|
+
----------
|
|
1626
|
+
path : str
|
|
1627
|
+
Path to the `.gdb` file.
|
|
1628
|
+
|
|
1629
|
+
Returns
|
|
1630
|
+
-------
|
|
1631
|
+
dict of {int : str} or None
|
|
1632
|
+
`{symbol_slot: name}` for every live blob symbol. The object
|
|
1633
|
+
named by slot `k` is the administrative blob whose `blob_index`
|
|
1634
|
+
is `data_slots + k` (`header_fields()["data_slots"]`). `None`
|
|
1635
|
+
when the file has no table to trust: a bad magic or truncated
|
|
1636
|
+
header, or header words that would put the table outside the
|
|
1637
|
+
region before the first blob. Absence is not an anomaly, so
|
|
1638
|
+
nothing is warned.
|
|
1639
|
+
|
|
1640
|
+
Notes
|
|
1641
|
+
-----
|
|
1642
|
+
**[CONFIRMED]** layout (docs/spec.md section 2.1,
|
|
1643
|
+
docs/provenance/notes.md section 6.2d): `blobs_max` 128-byte records
|
|
1644
|
+
starting right after the blob directory, at `280 + 6 * index_slots`,
|
|
1645
|
+
each beginning with its NUL-terminated name. A record is live when
|
|
1646
|
+
its category (record `+76`) is `DB_CATEGORY_BLOB_NORMAL` (0), which
|
|
1647
|
+
held on exactly the 1,267 corpus records that own an administrative
|
|
1648
|
+
blob. A freed slot has bit `0x10000` set and may still hold its old
|
|
1649
|
+
name, so the category, not the name, decides.
|
|
1650
|
+
|
|
1651
|
+
Names seen on real files: four fixed objects (`"__dbreg"`,
|
|
1652
|
+
`"Display List"`, `"Line Selection"`, `"Database Extension
|
|
1653
|
+
Objects"`), `"?|IPJ_<X>:<Y>"` projection objects, and `"__<n>"` REG
|
|
1654
|
+
objects where `n` is a global symbol handle (`blobs_max + lines_max
|
|
1655
|
+
+ channel_slot` for a channel).
|
|
1656
|
+
"""
|
|
1657
|
+
with open(path, "rb") as f:
|
|
1658
|
+
header = f.read(4096)
|
|
1659
|
+
if not check_magic(header):
|
|
1660
|
+
return None
|
|
1661
|
+
fields = header_fields(header)
|
|
1662
|
+
blobs_max, index_slots = fields["blobs_max"], fields["index_slots"]
|
|
1663
|
+
if blobs_max is None or index_slots is None or blobs_max <= 0 or index_slots <= 0:
|
|
1664
|
+
return None
|
|
1665
|
+
blob_start = blob_region_start(header)
|
|
1666
|
+
start = DIRECTORY_OFFSET + index_slots * _DIRECTORY_SLOT_DTYPE.itemsize
|
|
1667
|
+
size = blobs_max * SYMBOL_RECORD_SIZE
|
|
1668
|
+
if blob_start is None or start + size > blob_start:
|
|
1669
|
+
return None
|
|
1670
|
+
f.seek(start)
|
|
1671
|
+
raw = f.read(size)
|
|
1672
|
+
if len(raw) != size:
|
|
1673
|
+
return None
|
|
1674
|
+
symbols = {}
|
|
1675
|
+
for slot in range(blobs_max):
|
|
1676
|
+
rec = raw[slot * SYMBOL_RECORD_SIZE:(slot + 1) * SYMBOL_RECORD_SIZE]
|
|
1677
|
+
if struct.unpack_from("<i", rec, _BLOB_SYMBOL_CATEGORY_OFFSET)[0] != _DB_CATEGORY_BLOB_NORMAL:
|
|
1678
|
+
continue
|
|
1679
|
+
name, is_clean = _read_name(rec, 0)
|
|
1680
|
+
if name and is_clean:
|
|
1681
|
+
symbols[slot] = name
|
|
1682
|
+
return symbols
|
|
1683
|
+
|
|
1684
|
+
|
|
1543
1685
|
def _element_width(channel: ChannelRecord) -> Optional[int]:
|
|
1544
1686
|
"""
|
|
1545
1687
|
Byte width of one element of `channel`'s data.
|
|
@@ -1737,7 +1879,13 @@ def _decode_numeric_or_string(raw: bytes, channel: ChannelRecord, row_count: Opt
|
|
|
1737
1879
|
if channel.is_string:
|
|
1738
1880
|
if _native_ext is not None:
|
|
1739
1881
|
max_len, ucs4 = _native_ext.decode_fixed_width_strings_ucs4(raw, width, n)
|
|
1740
|
-
|
|
1882
|
+
if max_len == 0:
|
|
1883
|
+
# Every record is empty: there is no zero-width numpy string
|
|
1884
|
+
# dtype to view the (empty) buffer as. `np.array(..., "<U0")`
|
|
1885
|
+
# in the fallback below becomes `<U1` the same way.
|
|
1886
|
+
arr = np.zeros(n, dtype="<U1")
|
|
1887
|
+
else:
|
|
1888
|
+
arr = np.frombuffer(ucs4, dtype=f"<U{max_len}")
|
|
1741
1889
|
else:
|
|
1742
1890
|
values = [
|
|
1743
1891
|
raw[i * width : (i + 1) * width].split(b"\x00")[0].decode("ascii", errors="replace")
|