python-gdb 0.2.1__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-gdb
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Topic :: Scientific/Engineering :: GIS
@@ -16,16 +16,19 @@ Reading only: writing/mutating `.gdb` or `.grd` files is out of scope.
16
16
 
17
17
  from .gdb import GDB, CompressionInfo
18
18
  from .gdb_reader import (
19
+ BlobDirectory,
19
20
  BlobHeader,
20
21
  ChannelRecord,
21
22
  GDBParseWarning,
22
23
  LineRecord,
23
24
  check_magic,
25
+ exact_line_table_start,
24
26
  find_blob,
25
27
  find_channel_table,
26
28
  find_line_table,
27
29
  header_fields,
28
30
  iter_blobs,
31
+ read_blob_directory,
29
32
  read_blob_values,
30
33
  read_channels,
31
34
  read_lines,
@@ -36,6 +39,7 @@ from .registry import find_coordinate_systems
36
39
 
37
40
  __all__ = [
38
41
  "GDB",
42
+ "BlobDirectory",
39
43
  "BlobHeader",
40
44
  "ChannelRecord",
41
45
  "CompressionInfo",
@@ -45,12 +49,14 @@ __all__ = [
45
49
  "LZRW1DecodeError",
46
50
  "LineRecord",
47
51
  "check_magic",
52
+ "exact_line_table_start",
48
53
  "find_blob",
49
54
  "find_channel_table",
50
55
  "find_coordinate_systems",
51
56
  "find_line_table",
52
57
  "header_fields",
53
58
  "iter_blobs",
59
+ "read_blob_directory",
54
60
  "read_blob_values",
55
61
  "read_channels",
56
62
  "read_grd",
@@ -30,9 +30,13 @@ from .gdb_reader import (
30
30
  check_magic,
31
31
  header_fields,
32
32
  iter_blobs,
33
+ read_blob_directory,
33
34
  read_blob_values,
34
35
  read_channels,
35
- read_lines,
36
+ _read_lines,
37
+ DIRECTORY_ABSENT,
38
+ DIRECTORY_INVALID,
39
+ DIRECTORY_LIVE,
36
40
  )
37
41
  from .registry import find_channel_roles, find_coordinate_systems
38
42
 
@@ -60,87 +64,6 @@ LineRef = Union[str, Tuple[str, int], "LineRecord"]
60
64
  ChannelRef = Union[str, Tuple[str, int], "ChannelRecord"]
61
65
 
62
66
 
63
- def _finite_values(values: np.ndarray) -> np.ndarray:
64
- v = np.asarray(values, dtype=float).ravel()
65
- return v[np.isfinite(v) & (np.abs(v) < 1e30)]
66
-
67
-
68
- def _roughness(values: np.ndarray) -> Optional[float]:
69
- """
70
- Median row-to-row step divided by the 5-95% spread of the values.
71
-
72
- Returns
73
- -------
74
- float or None
75
- Small for data that varies smoothly along its rows, large for
76
- the same values in a scrambled order. None if there are too few
77
- finite values or they are constant.
78
- """
79
- v = _finite_values(values)
80
- if len(v) < 50:
81
- return None
82
- spread = float(np.percentile(v, 95) - np.percentile(v, 5))
83
- if spread == 0.0:
84
- return None
85
- return float(np.median(np.abs(np.diff(v))) / spread)
86
-
87
-
88
- def _row_order_pick(first: np.ndarray, last: np.ndarray, factor: float = 3.0) -> Optional[int]:
89
- """
90
- Decide which of two copies of one channel is in acquisition order.
91
-
92
- Parameters
93
- ----------
94
- first, last : numpy.ndarray
95
- The earlier and later copy in the blob chain.
96
- factor : float, optional
97
- How many times rougher one copy must be than the other.
98
-
99
- Returns
100
- -------
101
- int or None
102
- 0 if the *first* copy is the smooth one and the last is a
103
- markedly rougher reordering of it; 1 if the last is the smooth
104
- one; None if the copies are not a pure reordering of each other
105
- (different values or length) or neither is clearly smoother.
106
-
107
- Notes
108
- -----
109
- A copy that is *itself* perfectly monotone is never judged: a sorted
110
- ramp is smoother than any real signal, and a re-sort by a channel's
111
- own value (a coordinate re-sorted by itself) leaves exactly that. It
112
- could equally be a genuine ID or time channel, and nothing here can
113
- tell the two apart, so such a pair is undecided.
114
- """
115
- if len(first) != len(last):
116
- return None
117
- a, b = np.asarray(first).ravel(), np.asarray(last).ravel()
118
- try:
119
- if not np.array_equal(np.sort(a), np.sort(b), equal_nan=True):
120
- return None
121
- except TypeError: # dtype without a NaN notion (integers)
122
- if not np.array_equal(np.sort(a), np.sort(b)):
123
- return None
124
- if _is_monotone_reference(a) or _is_monotone_reference(b):
125
- return None
126
- ra, rb = _roughness(a), _roughness(b)
127
- if ra is None or rb is None:
128
- return None
129
- if rb > 0 and rb >= factor * ra:
130
- return 0
131
- if ra > 0 and ra >= factor * rb:
132
- return 1
133
- return None
134
-
135
-
136
- def _is_monotone_reference(values: np.ndarray) -> bool:
137
- """A non-constant channel stored in non-decreasing order (an ID, date or time)."""
138
- v = _finite_values(values)
139
- if len(v) < 50 or v[0] == v[-1]:
140
- return False
141
- return bool(np.mean(np.diff(v) >= 0) >= 0.999)
142
-
143
-
144
67
  @dataclass
145
68
  class CompressionInfo:
146
69
  """
@@ -181,20 +104,15 @@ class GDB:
181
104
  ----------
182
105
  path : str
183
106
  Path to the `.gdb` file.
184
- duplicate_blobs : {"last", "row_order"}, optional
185
- What to do when a (line, channel) has more than one blob in the
186
- blob chain (issue #2). `"last"` (the default) uses the last one
187
- in chain order. `"row_order"` is an opt-in **heuristic**, not a
188
- decoded field: for a duplicated numeric channel whose two
189
- copies hold exactly the same values in a different row order
190
- (a stale re-sorted copy), on a line that has an order-defining
191
- channel (see Notes), it prefers the copy whose values vary
192
- smoothly along the rows -- acquisition order, the order the
193
- line's ID/time channels are stored in -- and keeps the last
194
- copy in every other case. Either way one `GDBParseWarning`
195
- names the affected pairs and, for `"row_order"`, which ones it
196
- overrode. Choosing needs both copies decoded, so it is done
197
- once, when the blob index is first built.
107
+ include_unlisted_blobs : bool, optional
108
+ A file that carries a blob directory (see Notes) lists exactly
109
+ one live blob per (line, channel). By default a blob the
110
+ directory does not list -- an all-zero slot -- is **skipped**, as
111
+ if it were absent, and one `GDBParseWarning` says how many
112
+ (line, channel) blobs in which channels were skipped. Pass
113
+ `True` to read them anyway (the last copy in chain order). A
114
+ file with no directory, or an all-zero one, is unaffected: every
115
+ blob in the chain is served.
198
116
 
199
117
  Raises
200
118
  ------
@@ -203,8 +121,7 @@ class GDB:
203
121
  expected `.gdb` magic -- unlike the module-level functions in
204
122
  `gdb_reader`/`registry` (which warn and return empty results),
205
123
  since a `GDB` object that isn't backed by a real `.gdb` file
206
- can't usefully do anything at all. Also if `duplicate_blobs` is
207
- not one of the values above.
124
+ can't usefully do anything at all.
208
125
 
209
126
  Examples
210
127
  --------
@@ -234,31 +151,22 @@ class GDB:
234
151
  context manager) when done with it, or just let it get
235
152
  garbage-collected -- `__del__` closes it too, as a safety net.
236
153
 
237
- `duplicate_blobs="row_order"` only ever chooses between two copies
238
- of one channel that are a pure reordering of each other, and only on
239
- a line with an *order-defining channel*: a single-copy numeric
240
- channel of the same length stored in monotone order (typically an
241
- ID, date or time). A revised copy (different values) is never
242
- second-guessed, and neither is a pair with a copy that is itself
243
- perfectly monotone (a re-sort by a channel's own value leaves a
244
- smooth ramp that looks like the best copy but is the stale one).
245
- Among a qualifying pair, the copy at least 3x
246
- rougher along the rows -- median row-to-row step over the 5-95%
247
- spread of the values -- is treated as the stale one, since data in
248
- acquisition order varies smoothly and a re-sort scrambles that. This
249
- was validated against independent spreadsheet exports of the one
250
- real file known to have such copies (it never contradicted them),
251
- but it cannot decide a channel that is smooth in both orders, and no
252
- on-disk marker has been found that would make it unnecessary.
154
+ **Which copy of a blob is read.** The blob chain is append-only, so
155
+ a (line, channel) can have several blobs, an older stale one and
156
+ the current one, in either order (issue #2). The file's persisted
157
+ *blob directory* (docs/spec.md section 2.2) says which is current,
158
+ and it is the only thing consulted. A directory entry is used only
159
+ if its start page lands on a walked blob header whose `blob_index`
160
+ equals the slot and whose page count matches; if a non-zero entry
161
+ fails that check, the last copy in chain order is used and a
162
+ `GDBParseWarning` says so. Without a usable directory the last copy
163
+ in chain order is used, with a warning if a pair is duplicated: that
164
+ is a guess, and not always the current copy.
253
165
  """
254
166
 
255
- def __init__(self, path: str, duplicate_blobs: str = "last"):
256
- if duplicate_blobs not in ("last", "row_order"):
257
- raise ValueError(
258
- f"duplicate_blobs must be 'last' or 'row_order', got {duplicate_blobs!r}"
259
- )
167
+ def __init__(self, path: str, include_unlisted_blobs: bool = False):
260
168
  self.path = path
261
- self.duplicate_blobs = duplicate_blobs
169
+ self.include_unlisted_blobs = include_unlisted_blobs
262
170
  self._file = open(path, "rb")
263
171
  header = self._file.read(4096)
264
172
  if not check_magic(header):
@@ -270,6 +178,7 @@ class GDB:
270
178
  self._fields = header_fields(header)
271
179
  self._channels: Optional[List[ChannelRecord]] = None
272
180
  self._lines: Optional[List[LineRecord]] = None
181
+ self._lines_exact = False
273
182
  self._channels_by_name: Optional[Dict[str, List[ChannelRecord]]] = None
274
183
  self._lines_by_name: Optional[Dict[str, List[LineRecord]]] = None
275
184
  self._blob_index: Optional[Dict[Tuple[int, int], BlobHeader]] = None
@@ -385,7 +294,7 @@ class GDB:
385
294
  def lines(self) -> List[LineRecord]:
386
295
  """list of LineRecord: This file's line table, read once and cached."""
387
296
  if self._lines is None:
388
- self._lines = read_lines(self.path)
297
+ self._lines, self._lines_exact = _read_lines(self.path)
389
298
  self._lines_by_name = {}
390
299
  for l in self._lines:
391
300
  self._lines_by_name.setdefault(l.name, []).append(l)
@@ -547,219 +456,144 @@ class GDB:
547
456
  Returns
548
457
  -------
549
458
  dict of {(int, int) : BlobHeader}
550
- Maps `(line_slot, channel_slot)` to `BlobHeader`, built
551
- with one blob-chain walk and cached from then on.
552
- `iter_blobs`/`find_blob` themselves recommend this for
553
- anything beyond an occasional one-off lookup -- this class
554
- always wants line/channel listings and random-access
459
+ Maps `(line_slot, channel_slot)` to the `BlobHeader` of its
460
+ live blob, built with one blob-chain walk and cached from
461
+ then on. `iter_blobs`/`find_blob` themselves recommend this
462
+ for anything beyond an occasional one-off lookup -- this
463
+ class always wants line/channel listings and random-access
555
464
  reads, so it always builds the index.
556
465
 
557
466
  Warns
558
467
  -----
559
468
  GDBParseWarning
560
- If a real line's channel has more than one blob in the
561
- blob chain. By default the **last** one in chain order is
562
- used, but that is not always the current copy (issue #2):
563
- an older copy with the same values in a different row order
564
- can sit either before or after the current one, and nothing
565
- decoded so far says which is which. With
566
- `duplicate_blobs="row_order"` the warning also says which
567
- pairs were switched to an earlier copy. Duplicates in the
568
- administrative slots past the last real line (the REG/IPJ
569
- registry, whose stale copies are expected and handled by
570
- `pygdb.registry`) are not reported.
469
+ See `_select_live_blobs`: when blobs are skipped because the
470
+ file's blob directory does not list them, when a directory
471
+ entry fails validation, or when a pair is duplicated and
472
+ the file has no directory to say which copy is current.
571
473
  """
572
474
  if self._blob_index is None:
573
475
  chans_max = self.chans_max
574
476
  copies: Dict[Tuple[int, int], List[BlobHeader]] = {}
477
+ by_offset: Dict[int, BlobHeader] = {}
575
478
  for blob in iter_blobs(self.path):
576
479
  copies.setdefault(blob.line_channel(chans_max), []).append(blob)
577
- index: Dict[Tuple[int, int], BlobHeader] = {k: v[-1] for k, v in copies.items()}
578
- duplicated = [k for k, v in copies.items() if len(v) > 1]
579
- self._blob_index = index
580
- self._calibrate_line_indices()
581
- if duplicated:
582
- overridden: List[Tuple[int, int]] = []
583
- if self.duplicate_blobs == "row_order":
584
- overridden = self._prefer_row_order_copies(duplicated, copies, index)
585
- self._warn_duplicate_blobs(duplicated, overridden)
480
+ by_offset[blob.offset] = blob
481
+ self._blob_index = {k: v[-1] for k, v in copies.items()}
482
+ if not self.lines:
483
+ return self._blob_index
484
+ if not self._lines_exact:
485
+ self._calibrate_line_indices()
486
+ self._select_live_blobs(copies, by_offset)
586
487
  return self._blob_index
587
488
 
588
- def _read_copy(self, blob: BlobHeader, channel: ChannelRecord) -> np.ndarray:
589
- return read_blob_values(
590
- self.path, blob, channel,
591
- comp_level=self.comp_level or 0, page_size=self.page_size, file=self._file,
592
- )
593
-
594
- def _prefer_row_order_copies(
489
+ def _select_live_blobs(
595
490
  self,
596
- duplicated: List[Tuple[int, int]],
597
491
  copies: Dict[Tuple[int, int], List[BlobHeader]],
598
- index: Dict[Tuple[int, int], BlobHeader],
599
- ) -> List[Tuple[int, int]]:
492
+ by_offset: Dict[int, BlobHeader],
493
+ ) -> None:
600
494
  """
601
- Apply `duplicate_blobs="row_order"` to the duplicated pairs.
495
+ Choose each real (line, channel)'s live blob using the blob directory.
602
496
 
603
497
  Parameters
604
498
  ----------
605
- duplicated : list of (int, int)
606
- `(line_slot, channel_slot)` keys with more than one blob.
607
499
  copies : dict of {(int, int) : list of BlobHeader}
608
500
  Every blob for each key, in chain order.
609
- index : dict of {(int, int) : BlobHeader}
610
- The blob index being built; updated in place with the
611
- earlier copy wherever one is preferred.
501
+ by_offset : dict of {int : BlobHeader}
502
+ Every blob of the chain, keyed by absolute offset.
612
503
 
613
- Returns
614
- -------
615
- list of (int, int)
616
- The keys switched from the last copy to an earlier one.
504
+ Warns
505
+ -----
506
+ GDBParseWarning
507
+ Once per situation, naming counts and examples: (1) pairs
508
+ skipped because the directory has no live entry for them;
509
+ (2) pairs whose non-zero directory entry failed validation
510
+ (the last copy in chain order is used); (3) with no usable
511
+ directory at all, pairs that have more than one blob.
512
+ Administrative slots past the last real line (the REG/IPJ
513
+ registry, whose stale copies `pygdb.registry` handles
514
+ itself) are never judged or reported.
617
515
 
618
516
  Notes
619
517
  -----
620
- See the class docstring for exactly when a pair qualifies. A pair
621
- that doesn't (a string or array channel, more than two copies,
622
- different values, no order-defining channel on the line, or no
623
- clear winner) simply keeps the last copy.
518
+ Updates `self._blob_index` in place. See the class docstring for
519
+ the rules.
624
520
  """
521
+ index = self._blob_index
625
522
  real_lines = {line.index for line in self.lines}
626
- channels = {c.index: c for c in self.channels}
627
- duplicated_keys = set(duplicated)
628
- reference_cache: Dict[Tuple[int, int], bool] = {}
629
- overridden: List[Tuple[int, int]] = []
630
- for key in duplicated:
631
- line_slot, channel_slot = key
632
- channel = channels.get(channel_slot)
633
- blobs = copies[key]
634
- if (
635
- line_slot not in real_lines or channel is None or len(blobs) != 2
636
- or channel.is_string or channel.is_array
637
- ):
638
- continue
639
- first, last = self._read_copy(blobs[0], channel), self._read_copy(blobs[1], channel)
640
- if len(first) != len(last):
641
- continue
642
- ref_key = (line_slot, len(first))
643
- if ref_key not in reference_cache:
644
- reference_cache[ref_key] = self._line_has_order_reference(
645
- line_slot, len(first), duplicated_keys, channels
523
+ directory = read_blob_directory(self.path)
524
+ if directory is None:
525
+ duplicated = [k for k, v in copies.items() if len(v) > 1 and k[0] in real_lines]
526
+ if duplicated:
527
+ self._warn_pairs(
528
+ f"{len(duplicated)} (line, channel) pair(s) have more than one blob in the "
529
+ f"blob chain ({self._describe_pairs(duplicated)}) and the file has no blob "
530
+ f"directory to say which is current -- using the last one in chain order, "
531
+ f"which is not always the current copy. If a channel's rows look scrambled "
532
+ f"against the line's other channels, this is the likely cause (see issue #2)"
646
533
  )
647
- if reference_cache[ref_key] and _row_order_pick(first, last) == 0:
648
- index[key] = blobs[0]
649
- overridden.append(key)
650
- return overridden
651
-
652
- def _line_has_order_reference(
653
- self,
654
- line_slot: int,
655
- n_rows: int,
656
- duplicated_keys: set,
657
- channels: Dict[int, ChannelRecord],
658
- ) -> bool:
659
- """
660
- Whether a line has a channel proving its rows are in some fixed order.
661
-
662
- Parameters
663
- ----------
664
- line_slot : int
665
- The line's slot.
666
- n_rows : int
667
- The row count of the duplicated copies being judged.
668
- duplicated_keys : set of (int, int)
669
- Keys with more than one blob -- excluded, since their own
670
- order is what is in question.
671
- channels : dict of {int : ChannelRecord}
672
- Channel records by slot.
673
-
674
- Returns
675
- -------
676
- bool
677
- True if some single-copy, numeric, non-array channel of
678
- `n_rows` values on this line is stored in monotone
679
- (non-decreasing) order and isn't constant -- typically an
680
- ID, date or time channel.
681
- """
682
- for (ls, cs), blob in sorted(self._ensure_blob_index().items()):
683
- channel = channels.get(cs)
684
- if (
685
- ls != line_slot or (ls, cs) in duplicated_keys or channel is None
686
- or channel.is_string or channel.is_array
687
- ):
534
+ return
535
+ first_offset = min(by_offset)
536
+ skipped: List[Tuple[int, int]] = []
537
+ invalid: List[Tuple[int, int]] = []
538
+ for key in copies:
539
+ line_slot, channel_slot = key
540
+ if line_slot not in real_lines:
688
541
  continue
689
- values = self._read_copy(blob, channel)
690
- if len(values) == n_rows and _is_monotone_reference(values):
691
- return True
692
- return False
693
-
694
- def _warn_duplicate_blobs(
695
- self,
696
- duplicated: List[Tuple[int, int]],
697
- overridden: Sequence[Tuple[int, int]] = (),
698
- ) -> None:
699
- """
700
- Warn about (line, channel) pairs that have more than one blob.
701
-
702
- Parameters
703
- ----------
704
- duplicated : list of (int, int)
705
- `(line_slot, channel_slot)` keys seen more than once in the
706
- blob chain.
707
- overridden : sequence of (int, int), optional
708
- The keys `duplicate_blobs="row_order"` switched to an
709
- earlier copy.
542
+ status, blob = directory.resolve(
543
+ line_slot * self.chans_max + channel_slot, by_offset, first_offset, self.page_size,
544
+ )
545
+ if status == DIRECTORY_LIVE:
546
+ index[key] = blob
547
+ elif status == DIRECTORY_ABSENT:
548
+ skipped.append(key)
549
+ elif status == DIRECTORY_INVALID:
550
+ invalid.append(key)
551
+ if skipped and not self.include_unlisted_blobs:
552
+ for key in skipped:
553
+ del index[key]
554
+ channel_names = {c.index: c.name for c in self.channels}
555
+ by_channel: Dict[int, int] = {}
556
+ for _, channel_slot in skipped:
557
+ by_channel[channel_slot] = by_channel.get(channel_slot, 0) + 1
558
+ listing = ", ".join(
559
+ f"{channel_names.get(cs, f'#{cs}')!r} ({n})" for cs, n in sorted(by_channel.items())[:5]
560
+ )
561
+ more = f" and {len(by_channel) - 5} more channel(s)" if len(by_channel) > 5 else ""
562
+ self._warn_pairs(
563
+ f"skipped {len(skipped)} (line, channel) blob(s) in {len(by_channel)} channel(s) "
564
+ f"[{listing}{more}] that the file's blob directory does not list as live. Pass "
565
+ f"include_unlisted_blobs=True to read them anyway"
566
+ )
567
+ if invalid:
568
+ self._warn_pairs(
569
+ f"{len(invalid)} (line, channel) pair(s) have a blob-directory entry that does not "
570
+ f"point at a blob with the right index and size ({self._describe_pairs(invalid)}) "
571
+ f"-- using the last copy in chain order for them"
572
+ )
710
573
 
711
- Warns
712
- -----
713
- GDBParseWarning
714
- Once, naming how many real (line, channel) pairs are
715
- affected and a few examples (and, for `"row_order"`, which
716
- ones were switched). Nothing is emitted if every duplicate
717
- is in an administrative slot.
718
- """
574
+ def _describe_pairs(self, keys: Sequence[Tuple[int, int]], limit: int = 3) -> str:
719
575
  line_names = {line.index: line.name for line in self.lines}
720
576
  channel_names = {c.index: c.name for c in self.channels}
577
+ named = [
578
+ f"line {line_names[ls]!r} channel {channel_names.get(cs, f'#{cs}')!r}"
579
+ for ls, cs in keys if ls in line_names
580
+ ]
581
+ more = f" and {len(named) - limit} more" if len(named) > limit else ""
582
+ return ", ".join(named[:limit]) + more
721
583
 
722
- def describe(keys, limit=3):
723
- named = [
724
- f"line {line_names[ls]!r} channel {channel_names.get(cs, f'#{cs}')!r}"
725
- for ls, cs in keys if ls in line_names
726
- ]
727
- more = f" and {len(named) - limit} more" if len(named) > limit else ""
728
- return ", ".join(named[:limit]) + more
729
-
730
- n_real = sum(1 for ls, _ in duplicated if ls in line_names)
731
- if not n_real:
732
- return
733
- head = (
734
- f"{self.path}: {n_real} (line, channel) pair(s) have more than one "
735
- f"blob in the blob chain ({describe(duplicated)})"
736
- )
737
- if self.duplicate_blobs == "row_order":
738
- tail = (
739
- f" -- duplicate_blobs='row_order': switched {len(overridden)} pair(s) to "
740
- f"an earlier copy because the last copy was the same values in a "
741
- f"markedly rougher row order ({describe(overridden)}); kept the last "
742
- f"copy in chain order for the other {n_real - len(overridden)}. This "
743
- f"is a heuristic, not a decoded field (see issue #2)"
744
- if overridden else
745
- f" -- duplicate_blobs='row_order': no pair needed switching, so the "
746
- f"last copy in chain order was kept for all of them (see issue #2)"
747
- )
748
- else:
749
- tail = (
750
- " -- using the last one in chain order, which is not always the "
751
- "current copy. If a channel's rows look scrambled against the line's "
752
- "other channels, this is the likely cause; duplicate_blobs='row_order' "
753
- "can pick the acquisition-order copy (see issue #2)"
754
- )
755
- warnings.warn(head + tail, GDBParseWarning, stacklevel=4)
584
+ def _warn_pairs(self, message: str) -> None:
585
+ # stacklevel: here -> _select_live_blobs -> _ensure_blob_index -> public method -> caller
586
+ warnings.warn(f"{self.path}: {message}", GDBParseWarning, stacklevel=5)
756
587
 
757
588
  def _calibrate_line_indices(self) -> None:
758
589
  """
759
590
  Correct a possible small, fixed off-by-N in every line's index.
760
591
 
761
- See `find_line_table`'s and `read_lines`'s docstrings in
762
- `gdb_reader.py`. Checks, for a handful of small integer shifts,
592
+ Only used when `read_lines` had to fall back to the
593
+ `find_line_table` heuristic (`self._lines_exact` is False): a
594
+ file whose line table is located exactly already has true
595
+ indices. See `find_line_table`'s and `read_lines`'s docstrings
596
+ in `gdb_reader.py`. Checks, for a handful of small integer shifts,
763
597
  which one makes the most already-found lines actually have at
764
598
  least one real data blob on disk for *some* channel -- then
765
599
  applies the winning shift to every `LineRecord.index` in
@@ -443,11 +443,15 @@ def header_fields(data: bytes) -> dict:
443
443
  Returns
444
444
  -------
445
445
  dict
446
- Maps each of `"chans_max"`, `"users_max"`, `"page_size"`, and
447
- `"comp_level"` to its decoded int32 value. See
448
- docs/provenance/notes.md section 6.1 for the full table
449
- including the still-unknown offsets, and for why each
450
- confidence label was assigned.
446
+ Maps each of `"chans_max"` (word 24), `"blobs_max"` (28),
447
+ `"lines_max"` (36), `"users_max"` (40), `"index_slots"` (44,
448
+ the total number of blob-directory slots), `"data_slots"` (48,
449
+ the number of those that address (line, channel) data blobs),
450
+ `"page_size"` (100), and `"comp_level"` (120) to its decoded
451
+ int32 value. See docs/spec.md section 2 for the full table
452
+ with a confidence rating per word, and
453
+ docs/provenance/notes.md section 6.1/6.1b for the derivations
454
+ and the still-unknown offsets.
451
455
 
452
456
  Warns
453
457
  -----
@@ -466,7 +470,8 @@ def header_fields(data: bytes) -> dict:
466
470
  (`DB_COMP_SIZE`) IS confirmed real zlib.
467
471
  """
468
472
  result = {}
469
- for name, offset in (("chans_max", 24), ("users_max", 40),
473
+ for name, offset in (("chans_max", 24), ("blobs_max", 28), ("lines_max", 36),
474
+ ("users_max", 40), ("index_slots", 44), ("data_slots", 48),
470
475
  ("page_size", 100), ("comp_level", 120)):
471
476
  try:
472
477
  result[name] = struct.unpack_from("<i", data, offset)[0]
@@ -713,10 +718,10 @@ class LineRecord:
713
718
  -----
714
719
  **[LIKELY]/[UNKNOWN]** -- much less firmly established than
715
720
  `ChannelRecord`: only the name (relative +32) and category code
716
- (relative +108) fields are decoded, and locating the table itself
717
- (`find_line_table` below) is a heuristic scan rather than the
718
- structurally-proven SUPER-anchor technique used for the channel
719
- table. See docs/spec.md section 3.2 and docs/provenance/notes.md
721
+ (relative +108) fields are decoded. The table is located exactly
722
+ (`exact_line_table_start`, **[CONFIRMED]** on the corpus) and only
723
+ falls back to the `find_line_table` heuristic scan when that
724
+ cannot be validated. See docs/spec.md section 3.2 and docs/provenance/notes.md
720
725
  section 6.3.
721
726
 
722
727
  `eq=False` keeps the default identity-based `__eq__`/`__hash__`
@@ -785,23 +790,25 @@ def find_line_table(data: bytes, search_window: Tuple[int, Optional[int]] = (128
785
790
 
786
791
  Notes
787
792
  -----
793
+ **Prefer `exact_line_table_start`** (via `read_lines`): the line
794
+ table's start follows exactly from `lines_max` and the channel
795
+ table's position (docs/spec.md section 2.1), which this heuristic
796
+ predates. This scan is now only the fallback for a file where that
797
+ arithmetic cannot be validated.
798
+
788
799
  Unlike `find_channel_table`, there's no known default-name anchor
789
800
  (the line table has nothing analogous to the channel table's
790
- "SUPER" user record immediately after it) and no confirmed header
791
- field gives its start offset directly -- reconciling one with the
792
- header's capacity fields was tried and didn't cleanly round-trip
793
- (docs/provenance/log.md Session 1 section 1.16,
794
- docs/provenance/notes.md section 6.3). This is therefore a
795
- heuristic **[LIKELY]** scan, not the structurally-proven technique
796
- used for the channel table: it looks for a run of 128-byte records
801
+ "SUPER" user record immediately after it), so this is a
802
+ heuristic **[LIKELY]** scan, not a structurally-proven technique:
803
+ it looks for a run of 128-byte records
797
804
  whose relative +32 field looks like a clean, NUL-terminated,
798
805
  printable line name and whose relative +108 category field
799
806
  matches one of the two confirmed real values (100=NORMAL/FLIGHT,
800
807
  200=GROUP), then returns the earliest such record in the run with
801
808
  the most hits at a consistent 128-byte phase.
802
809
 
803
- **Known limitation, found by real-file testing, not yet fixed
804
- here:** if a table's true first slot(s) don't carry a category
810
+ **Known limitation, found by real-file testing (fixed by using
811
+ `exact_line_table_start` instead, not here):** if a table's true first slot(s) don't carry a category
805
812
  code in {100, 200}, this returns a start that's one or more slots
806
813
  too late -- every subsequent `LineRecord.index` is then off by
807
814
  that same fixed amount, which breaks blob_index lookups by line
@@ -847,6 +854,60 @@ def find_line_table(data: bytes, search_window: Tuple[int, Optional[int]] = (128
847
854
  return min(phase_hits[best_phase])
848
855
 
849
856
 
857
+ _LINE_TABLE_GAP = 24 # [CONFIRMED] bytes between the end of the line table and the
858
+ # channel table -- docs/spec.md section 2.1
859
+
860
+
861
+ def exact_line_table_start(data: bytes, lines_max: Optional[int]) -> Optional[int]:
862
+ """
863
+ Compute the line table's start from the header and the channel table.
864
+
865
+ Parameters
866
+ ----------
867
+ data : bytes
868
+ The file's bytes up to at least the end of the channel table
869
+ (`read_lines` passes everything before the blob region).
870
+ lines_max : int or None
871
+ The line-table capacity (header word 36); `None` or non-positive
872
+ means unknown.
873
+
874
+ Returns
875
+ -------
876
+ int or None
877
+ Byte offset of the line table's first record, or `None` if the
878
+ arithmetic cannot be validated (unknown `lines_max`, no
879
+ locatable channel table, a start before the header ends, or no
880
+ line-shaped record anywhere in the computed table) -- the
881
+ caller then falls back to `find_line_table`.
882
+
883
+ Notes
884
+ -----
885
+ The line table is `lines_max` 128-byte slots followed by a 24-byte
886
+ gap and then the channel table, so its start is
887
+ `find_channel_table(data) - 24 - lines_max * 128`. **[CONFIRMED]**
888
+ on all 23 real files examined (docs/spec.md section 2.1;
889
+ docs/provenance/notes.md section 6.1b): the computed start is
890
+ always a slot boundary that matches the heuristic's start, or is an
891
+ earlier slot the heuristic missed. The at-least-one-line-record
892
+ check only guards a file whose layout does not follow this
893
+ arithmetic; it is not needed for any real file seen.
894
+ """
895
+ if not lines_max or lines_max <= 0:
896
+ return None
897
+ try:
898
+ channel_start = find_channel_table(data)
899
+ except ValueError:
900
+ return None
901
+ start = channel_start - _LINE_TABLE_GAP - lines_max * SYMBOL_RECORD_SIZE
902
+ if start < 256:
903
+ return None
904
+ for i in range(lines_max):
905
+ rec = _parse_line_record(data, start + i * SYMBOL_RECORD_SIZE, i)
906
+ if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
907
+ return start
908
+ return None
909
+
910
+
850
911
  def read_lines(path: str) -> List[LineRecord]:
851
912
  """
852
913
  Decode the line symbol table.
@@ -869,30 +930,59 @@ def read_lines(path: str) -> List[LineRecord]:
869
930
 
870
931
  Notes
871
932
  -----
872
- Heuristic (see `find_line_table`) -- less firmly established than
873
- `read_channels`. Since no confirmed header field gives the line
874
- table's slot capacity (the way `chans_max` does for the channel
875
- table), this reads forward from the located start until 8
933
+ The table is located **exactly** whenever possible (see
934
+ `exact_line_table_start`): it holds `lines_max` (header word 36)
935
+ 128-byte slots and ends 24 bytes before the channel table, so its
936
+ start is `channel_table_start - 24 - lines_max * 128`
937
+ (**[CONFIRMED]** on every real file in the corpus, docs/spec.md
938
+ section 2.1). Every one of the `lines_max` slots is then examined,
939
+ and `LineRecord.index` is the true slot number, so
940
+ `LineRecord.index` is exact and blob_index lookups keyed on it
941
+ are right.
942
+
943
+ Only when that arithmetic cannot be validated (a header without
944
+ `lines_max`, or no line-shaped record at the computed position) does
945
+ this fall back to the older heuristic (see `find_line_table`):
946
+ **[LIKELY]**, less firmly established, reading forward until 8
876
947
  consecutive records fail to look like either a populated line
877
- record or clean unused capacity -- a tolerance against one-off
878
- corruption/false-positive records, not a precisely-known table
879
- boundary.
880
-
881
- **`LineRecord.index` can be off by a small, fixed amount** on a
882
- file where `find_line_table`'s heuristic starts one or more slots
883
- late -- see that function's docstring. This makes `.name` still
884
- correct but `.index` (and therefore any blob_index lookup keyed on
885
- it) wrong. `GDB` (in `gdb.py`) corrects this against the actual
886
- blob chain before exposing lines by name; call it instead of this
887
- function directly when you need working (line, channel) data
888
- access, not just a list of names.
948
+ record or clean unused capacity. In that fallback
949
+ **`LineRecord.index` can be off by a small, fixed amount** when the
950
+ scan starts one or more slots late -- `.name` is still correct but
951
+ `.index` is wrong. `GDB` (in `gdb.py`) corrects this against the
952
+ actual blob chain only in that case.
953
+
954
+ A populated slot whose category is neither `100` nor `200` (for
955
+ example the `65636` slot 0 of one 1991 file) is not returned, as
956
+ before -- but, unlike the fallback, it no longer shifts the
957
+ indices of the lines after it.
958
+ """
959
+ return _read_lines(path)[0]
960
+
961
+
962
+ def _read_lines(path: str) -> Tuple[List[LineRecord], bool]:
963
+ """
964
+ Decode the line symbol table, also reporting how it was located.
965
+
966
+ Parameters
967
+ ----------
968
+ path : str
969
+ Path to the `.gdb` file.
970
+
971
+ Returns
972
+ -------
973
+ lines : list of LineRecord
974
+ As `read_lines`.
975
+ exact : bool
976
+ True if the table was located by `exact_line_table_start`
977
+ (indices are exact); False if the heuristic fallback was used
978
+ (indices may need `GDB`'s blob-chain calibration).
889
979
  """
890
980
  with open(path, "rb") as f:
891
981
  header = f.read(4096)
892
982
  if not check_magic(header):
893
983
  _warn(f"{path}: does not start with the expected '!CBD' magic -- "
894
984
  f"not a recognized .gdb file, returning no lines")
895
- return []
985
+ return [], False
896
986
  blob_start = blob_region_start(header)
897
987
  f.seek(0, 2)
898
988
  size = f.tell()
@@ -900,14 +990,24 @@ def read_lines(path: str) -> List[LineRecord]:
900
990
  read_size = blob_start if (blob_start is not None and 0 < blob_start <= size) else min(size, 20_000_000)
901
991
  data = f.read(read_size)
902
992
 
993
+ lines_max = header_fields(header).get("lines_max")
994
+ exact_start = exact_line_table_start(data, lines_max)
995
+ if exact_start is not None:
996
+ lines = []
997
+ for i in range(lines_max):
998
+ rec = _parse_line_record(data, exact_start + i * SYMBOL_RECORD_SIZE, i)
999
+ if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
1000
+ lines.append(rec)
1001
+ return lines, True
1002
+
903
1003
  try:
904
1004
  table_start = find_line_table(data, search_window=(128, len(data)))
905
1005
  except ValueError as e:
906
1006
  _warn(f"{path}: could not locate the line symbol table ({e}) -- "
907
1007
  f"returning no lines")
908
- return []
1008
+ return [], False
909
1009
 
910
- lines: List[LineRecord] = []
1010
+ lines = []
911
1011
  consecutive_bad = 0
912
1012
  i = 0
913
1013
  while True:
@@ -929,7 +1029,7 @@ def read_lines(path: str) -> List[LineRecord]:
929
1029
  consecutive_bad += 1
930
1030
  if consecutive_bad >= 8:
931
1031
  break
932
- return lines
1032
+ return lines, False
933
1033
 
934
1034
 
935
1035
  BLOB_MAGIC = b"\xcc\xcc\x00\xff"
@@ -1301,6 +1401,145 @@ def find_blob(path: str, line_slot: int, channel_slot: int, chans_max: Optional[
1301
1401
  return None
1302
1402
 
1303
1403
 
1404
+ DIRECTORY_OFFSET = 280 # [CONFIRMED] first blob-directory slot -- docs/spec.md section 2.2
1405
+ _DIRECTORY_SLOT_DTYPE = np.dtype([("word", "<u4"), ("n_pages", "<u2")]) # 6 bytes, unaligned
1406
+ _DIRECTORY_LIVE_FLAG = 0x8 # top nibble of a live (line, channel) entry's 32-bit word
1407
+
1408
+ DIRECTORY_LIVE = "live"
1409
+ DIRECTORY_ABSENT = "absent"
1410
+ DIRECTORY_INVALID = "invalid"
1411
+ DIRECTORY_OUTSIDE = "outside"
1412
+
1413
+
1414
+ @dataclass(eq=False)
1415
+ class BlobDirectory:
1416
+ """
1417
+ The persisted blob directory: which blob is the live one for each slot.
1418
+
1419
+ Attributes
1420
+ ----------
1421
+ data_slots : int
1422
+ Number of directory slots that address (line, channel) data
1423
+ blobs (header word 48, `lines_max * chans_max`); slot `i` is
1424
+ the blob whose `blob_index` is `i`.
1425
+ entries : dict of {int : (int, int)}
1426
+ Every **non-zero** data slot as `(32-bit word, n_pages)`, keyed
1427
+ by slot. A zero slot is simply absent from this dict.
1428
+
1429
+ Notes
1430
+ -----
1431
+ **[CONFIRMED]** layout, **[LIKELY]** interpretation
1432
+ (docs/spec.md section 2.2; docs/provenance/notes.md section 6.1c).
1433
+ The directory is an array of 6-byte slots starting at file offset
1434
+ 280. A live entry is `(0x80000000 | start page, n_pages)` where
1435
+ the start page is relative to the first blob (`(offset - blob
1436
+ region start) / page_size`). Across the real corpus it addressed
1437
+ 100% of the real (line, channel) blobs of 20 of 22 files, and for
1438
+ every duplicated pair with an independent oracle (13 of 13) it
1439
+ pointed at the correct copy. A slot that is all-zero belongs to a
1440
+ blob the file does not list as live.
1441
+ """
1442
+
1443
+ data_slots: int
1444
+ entries: dict
1445
+
1446
+ def resolve(self, blob_index: int, blobs_by_offset: dict, first_offset: int, page_size: int):
1447
+ """
1448
+ Look up the live blob for `blob_index`.
1449
+
1450
+ Parameters
1451
+ ----------
1452
+ blob_index : int
1453
+ The slot, `line_slot * chans_max + channel_slot`.
1454
+ blobs_by_offset : dict of {int : BlobHeader}
1455
+ Every blob of the chain walk, keyed by absolute `offset`.
1456
+ first_offset : int
1457
+ Absolute offset of the first blob (`blob_region_start`).
1458
+ page_size : int
1459
+ The file's page size.
1460
+
1461
+ Returns
1462
+ -------
1463
+ status : str
1464
+ `"live"`: the entry's start page lands on a walked blob
1465
+ header whose `blob_index` equals `blob_index` and whose
1466
+ `n_pages` equals the entry's page count (the strict
1467
+ check). `"absent"`: the slot is all-zero. `"invalid"`: the
1468
+ slot is non-zero but fails the strict check. `"outside"`:
1469
+ `blob_index` is not a data slot at all (an administrative
1470
+ slot), so the directory says nothing about it.
1471
+ blob : BlobHeader or None
1472
+ The live blob for `"live"`, otherwise `None`.
1473
+ """
1474
+ if not 0 <= blob_index < self.data_slots:
1475
+ return DIRECTORY_OUTSIDE, None
1476
+ entry = self.entries.get(blob_index)
1477
+ if entry is None:
1478
+ return DIRECTORY_ABSENT, None
1479
+ word, n_pages = entry
1480
+ if word >> 28 != _DIRECTORY_LIVE_FLAG:
1481
+ return DIRECTORY_INVALID, None
1482
+ blob = blobs_by_offset.get(first_offset + (word & 0x7FFFFFFF) * page_size)
1483
+ if blob is None or blob.blob_index != blob_index or blob.n_pages != n_pages:
1484
+ return DIRECTORY_INVALID, None
1485
+ return DIRECTORY_LIVE, blob
1486
+
1487
+
1488
+ def read_blob_directory(path: str) -> Optional[BlobDirectory]:
1489
+ """
1490
+ Read the persisted blob directory.
1491
+
1492
+ Parameters
1493
+ ----------
1494
+ path : str
1495
+ Path to the `.gdb` file.
1496
+
1497
+ Returns
1498
+ -------
1499
+ BlobDirectory or None
1500
+ The directory, or `None` when the file has none to trust: a bad
1501
+ magic or truncated header, header words that are inconsistent
1502
+ with the layout (`data_slots != lines_max * chans_max`, or the
1503
+ data slots would run past the blob region), or a directory
1504
+ whose every data slot is zero. Absence is not an anomaly, so
1505
+ nothing is warned.
1506
+
1507
+ Notes
1508
+ -----
1509
+ See `BlobDirectory`. Only the data slots (`0 <= slot <
1510
+ data_slots`) are read; the registry blob-symbol slots and the
1511
+ cache slots after them are not used by the reader.
1512
+ """
1513
+ with open(path, "rb") as f:
1514
+ header = f.read(4096)
1515
+ if not check_magic(header):
1516
+ return None
1517
+ fields = header_fields(header)
1518
+ chans_max, lines_max, data_slots, page_size = (
1519
+ fields["chans_max"], fields["lines_max"], fields["data_slots"], fields["page_size"],
1520
+ )
1521
+ if None in (chans_max, lines_max, data_slots, page_size):
1522
+ return None
1523
+ blob_start = blob_region_start(header)
1524
+ if (
1525
+ blob_start is None or data_slots <= 0 or data_slots != lines_max * chans_max
1526
+ or DIRECTORY_OFFSET + data_slots * _DIRECTORY_SLOT_DTYPE.itemsize > blob_start
1527
+ ):
1528
+ return None
1529
+ f.seek(DIRECTORY_OFFSET)
1530
+ raw = f.read(data_slots * _DIRECTORY_SLOT_DTYPE.itemsize)
1531
+ if len(raw) != data_slots * _DIRECTORY_SLOT_DTYPE.itemsize:
1532
+ return None
1533
+ slots = np.frombuffer(raw, dtype=_DIRECTORY_SLOT_DTYPE)
1534
+ nonzero = np.nonzero((slots["word"] != 0) | (slots["n_pages"] != 0))[0]
1535
+ if len(nonzero) == 0:
1536
+ return None
1537
+ entries = {
1538
+ int(i): (int(slots["word"][i]), int(slots["n_pages"][i])) for i in nonzero
1539
+ }
1540
+ return BlobDirectory(data_slots=data_slots, entries=entries)
1541
+
1542
+
1304
1543
  def _element_width(channel: ChannelRecord) -> Optional[int]:
1305
1544
  """
1306
1545
  Byte width of one element of `channel`'s data.
@@ -46,7 +46,7 @@ dependencies = [
46
46
 
47
47
  [[package]]
48
48
  name = "pygdb-native"
49
- version = "0.2.1"
49
+ version = "0.3.0"
50
50
  dependencies = [
51
51
  "flate2",
52
52
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pygdb-native"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  description = "Optional Rust-accelerated backend for python-gdb (pygdb._native)"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes