Skip to content

Commit 6e6d629

Browse files
committed
tests
1 parent c37212d commit 6e6d629

4 files changed

Lines changed: 896 additions & 39 deletions

File tree

src/blkcache/ddrescue.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,30 @@
88
import logging
99
from typing import Dict, List
1010

11-
from .file.filemap import FileMap
11+
from .file.filemap import FileMap, STATUSES
1212

1313
# Version of the rescue log format
1414
FORMAT_VERSION = "1.0"
1515

1616
log = logging.getLogger(__name__)
1717

1818

19+
def iter_filemap_ranges(filemap: FileMap):
20+
"""Iterate over FileMap transitions yielding (pos, size, status) tuples."""
21+
if not filemap.transitions:
22+
return
23+
24+
# Process transitions to yield ranges
25+
for i in range(len(filemap.transitions) - 1):
26+
start = filemap.transitions[i][0]
27+
end = filemap.transitions[i + 1][0] - 1
28+
status = filemap.transitions[i][2]
29+
30+
size = end - start + 1
31+
if size > 0: # Skip zero-length ranges
32+
yield (start, size, status)
33+
34+
1935
def load(file, comments: List[str], filemap: FileMap, config: Dict[str, str]) -> None:
2036
"""Load ddrescue mapfile from file-like object, updating provided containers."""
2137
current_pos_line_found = False
@@ -85,17 +101,20 @@ def save(
85101

86102
# Write transition data
87103
file.write("# pos size status\n")
88-
for pos, size, status in filemap:
104+
for pos, size, status in iter_filemap_ranges(filemap):
89105
file.write(f"0x{pos:08x} 0x{size:08x} {status}\n")
90106

91107

92108
def parse_status(line: str) -> tuple[int, int, str]:
93109
"""Parse a status line returning (start, size, status)."""
94-
# Split on '#' and keep only the first half
95-
line = line.split("#")[0]
96110
parts = line.split()
97111
# Let it crash if not enough parts or invalid format
98112
start = int(parts[0], 16)
99113
size = int(parts[1], 16)
100114
status = parts[2]
115+
116+
# Validate status is one we recognize
117+
if status not in STATUSES:
118+
raise ValueError(f"Invalid status '{status}' in line: {line.strip()}")
119+
101120
return start, size, status

src/blkcache/file/filemap.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import bisect
99
import logging
1010

11-
# Used to prevent sorting by anything other than position
11+
# Prevents sort (not a number, so not less, greater or equal to itself)
1212
NO_SORT = float("nan")
1313

1414
# Block status codes
@@ -19,6 +19,12 @@
1919
STATUS_SLOW = "*" # Non-trimmed, non-scraped (slow reads)
2020
STATUS_SCRAPED = "#" # Non-trimmed, scraped (slow reads completed)
2121

22+
# Helper sets for fast status categorization
23+
CACHED = {STATUS_OK, STATUS_SLOW, STATUS_SCRAPED} # Have data
24+
UNCACHED = {STATUS_UNTRIED} # Need data
25+
ERROR = {STATUS_ERROR, STATUS_TRIMMED} # Can't get data
26+
STATUSES = CACHED | UNCACHED | ERROR # All valid statuses
27+
2228
log = logging.getLogger(__name__)
2329

2430

@@ -58,20 +64,70 @@ def __setitem__(self, key, status):
5864
# Single offset
5965
self._set_status_range(key, key, status)
6066

61-
def __iter__(self):
62-
"""Iterate over transitions yielding (pos, size, status) tuples."""
63-
if not self.transitions:
64-
return
67+
def __getitem__(self, key):
68+
"""Get status for range using slice notation: filemap[start:end] returns transitions"""
69+
if isinstance(key, slice):
70+
# Check bounds before calling indices() which clamps values
71+
if key.start is not None and key.start < 0:
72+
raise ValueError(f"Negative start index: {key.start}")
73+
if key.stop is not None and key.stop > self.size:
74+
raise ValueError(f"Stop index beyond device size: {key.stop} > {self.size}")
75+
76+
start, stop, step = key.indices(self.size)
77+
if step != 1:
78+
raise ValueError("Step not supported")
79+
80+
# Return empty list for empty range
81+
if start >= stop:
82+
return []
83+
84+
return self._get_transitions_range(start, stop - 1)
85+
else:
86+
# Single offset
87+
return self._get_status_at(key)
88+
89+
def _get_transitions_range(self, start: int, end: int) -> list[tuple]:
90+
"""Get transitions covering range with synthetic start/end positions."""
91+
# Find transitions that fall within our range [start, end]
92+
# We want transitions where start < transition.pos <= end
93+
result = []
94+
95+
# Get status at start position
96+
start_search = (start + 1, NO_SORT, "")
97+
start_idx = bisect.bisect_left(self.transitions, start_search)
98+
start_transition_idx = max(0, start_idx - 1)
99+
start_status = self.transitions[start_transition_idx][2]
100+
101+
# Add synthetic start
102+
result.append((start, NO_SORT, start_status))
103+
104+
# Add all transitions that fall within (start, end]
105+
for i in range(len(self.transitions)):
106+
pos = self.transitions[i][0]
107+
if start < pos <= end:
108+
result.append(self.transitions[i])
109+
110+
# Get status at end position (might be different from start if we crossed transitions)
111+
end_search = (end + 1, NO_SORT, "")
112+
end_idx = bisect.bisect_left(self.transitions, end_search)
113+
end_transition_idx = max(0, end_idx - 1)
114+
end_status = self.transitions[end_transition_idx][2]
115+
116+
# Add synthetic end
117+
result.append((end, NO_SORT, end_status))
118+
119+
return result
120+
121+
def _get_status_at(self, offset: int) -> str:
122+
"""Get status at single offset using efficient bisect lookup."""
123+
# Search for (offset + 1, ...) to find the transition that starts after offset
124+
search_key = (offset + 1, NO_SORT, "")
125+
idx = bisect.bisect_left(self.transitions, search_key)
65126

66-
# Process transitions to yield ranges
67-
for i in range(len(self.transitions) - 1):
68-
start = self.transitions[i][0]
69-
end = self.transitions[i + 1][0] - 1
70-
status = self.transitions[i][2]
127+
# The transition covering offset is at idx-1 (or 0 if idx is 0)
128+
transition_idx = max(0, idx - 1)
71129

72-
size = end - start + 1
73-
if size > 0: # Skip zero-length ranges
74-
yield (start, size, status)
130+
return self.transitions[transition_idx][2]
75131

76132
def _set_status_range(self, start: int, end: int, status: str) -> None:
77133
"""Set the status for a range of bytes."""
@@ -108,7 +164,7 @@ def _set_status_range(self, start: int, end: int, status: str) -> None:
108164
# if the status is different, we need to add a new entry
109165
splice.append(start_key)
110166

111-
if before_end_status != status:
167+
if before_end_status != status or end_idx == len(self.transitions):
112168
splice.append((end + 1, NO_SORT, before_end_status))
113169
if end + 1 < after_pos:
114170
splice.append((after_pos, NO_SORT, after_status))

0 commit comments

Comments
 (0)