Skip to content

Commit 451411b

Browse files
authored
fix(dbf_reader): skip deleted rows, fix short/NUL exact match, default latin-1; add dbfread parity tests (#298)
1 parent f9af9ce commit 451411b

2 files changed

Lines changed: 276 additions & 17 deletions

File tree

pysus/data/dbf_reader.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _parse_header(path: str | Path) -> DBFSchema:
9393
)
9494

9595

96-
_ENCODING = "cp1252"
96+
_ENCODING = "latin-1"
9797

9898

9999
def _decode(val: bytes) -> str:
@@ -108,7 +108,7 @@ def read_dbf_schema(path: str | Path) -> DBFSchema:
108108
def read_dbf_fast(
109109
path: str | Path,
110110
columns: list[str] | None = None,
111-
encoding: str = "cp1252",
111+
encoding: str = "latin-1",
112112
) -> pd.DataFrame:
113113
"""Read an entire DBF file into a DataFrame using vectorised byte access.
114114
@@ -119,7 +119,8 @@ def read_dbf_fast(
119119
columns : list[str], optional
120120
Subset of columns to read. If *None* all columns are returned.
121121
encoding : str
122-
Text encoding for character fields (default ``cp1252``).
122+
Text encoding for character fields (default ``latin-1``, the encoding
123+
DATASUS uses).
123124
124125
Returns
125126
-------
@@ -134,14 +135,25 @@ def read_dbf_fast(
134135
if n == 0:
135136
return pd.DataFrame(columns=schema.field_names)
136137

137-
target = [f for f in schema.fields if columns is None or f.name in columns]
138+
cols_lower = None if columns is None else {c.lower() for c in columns}
139+
target = [
140+
f
141+
for f in schema.fields
142+
if cols_lower is None or f.name.lower() in cols_lower
143+
]
138144
dtype = schema.build_dtype()
139145

140146
with open(path, "rb") as fh:
141147
fh.seek(schema.header_len)
142148
raw = fh.read(n * schema.record_len)
143149

144150
records: np.ndarray = np.frombuffer(raw, dtype=dtype, count=n)
151+
records = records[
152+
records["_deleted"] != b"*"
153+
] # skip deleted rows (matches dbfread)
154+
n = len(records)
155+
if n == 0:
156+
return pd.DataFrame(columns=[f.name for f in target])
145157

146158
data = {}
147159
for fld in target:
@@ -161,7 +173,7 @@ def read_dbf_filtered(
161173
column: str,
162174
values: list[str],
163175
columns: list[str] | None = None,
164-
encoding: str = "cp1252",
176+
encoding: str = "latin-1",
165177
prefix_match: bool = True,
166178
) -> pd.DataFrame:
167179
"""Read only DBF records where *column* matches one of *values*.
@@ -199,15 +211,11 @@ def read_dbf_filtered(
199211

200212
filter_field = _find_field(schema, column)
201213

202-
target_bytes: list[bytes] = []
203-
for val in values:
204-
b = val.encode(encoding)
205-
if prefix_match and len(b) < filter_field.length:
206-
target_bytes.append(b)
207-
else:
208-
target_bytes.append(
209-
b.ljust(filter_field.length)[: filter_field.length]
210-
)
214+
# Bare (unpadded) targets. The prefix-vs-exact decision is made per value
215+
# in _scan_column by comparing the value length to the field width, so a
216+
# value shorter than the field must NOT be pre-padded: pre-padding broke
217+
# exact match (the scanned chunk is stripped of trailing padding first).
218+
target_bytes: list[bytes] = [val.encode(encoding) for val in values]
211219

212220
matches = _scan_column(
213221
path, schema, filter_field, target_bytes, prefix_match
@@ -223,7 +231,7 @@ def read_dbf_filtered(
223231
def stream_dbf_fast(
224232
path: str | Path,
225233
chunk_size: int = 100_000,
226-
encoding: str = "cp1252",
234+
encoding: str = "latin-1",
227235
) -> Iterator[pd.DataFrame]:
228236
"""Stream records from a DBF file in chunks using vectorised byte access.
229237
@@ -259,6 +267,8 @@ def stream_dbf_fast(
259267
records: np.ndarray = np.frombuffer(
260268
chunk_raw, dtype=dtype, count=chunk_n
261269
)
270+
records = records[records["_deleted"] != b"*"] # skip deleted rows
271+
chunk_n = len(records)
262272

263273
data = {}
264274
for fld in schema.fields:
@@ -306,7 +316,9 @@ def _scan_column(
306316
fh.seek(record_start + field_offset_in_record)
307317
chunk = fh.read(field_width)
308318

309-
stripped = chunk.rstrip(b" ")
319+
stripped = chunk.rstrip(
320+
b"\x00 "
321+
) # DATASUS pads with spaces and/or NULs
310322
for target in target_bytes:
311323
if prefix_match and len(target) < field_width:
312324
if stripped.startswith(target):
@@ -327,8 +339,11 @@ def _materialize_rows(
327339
columns: list[str] | None,
328340
) -> pd.DataFrame:
329341
"""Read only the specified rows from the DBF and return a DataFrame."""
342+
cols_lower = None if columns is None else {c.lower() for c in columns}
330343
target_fields = [
331-
f for f in schema.fields if columns is None or f.name in columns
344+
f
345+
for f in schema.fields
346+
if cols_lower is None or f.name.lower() in cols_lower
332347
]
333348

334349
data: dict[str, list[str]] = {f.name: [] for f in target_fields}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""
2+
Golden parity tests for ``pysus.data.dbf_reader`` against ``dbfread``.
3+
4+
Follow-up to AlertaDengue/PySUS #288 / #296. ``dbfread`` was PySUS's reader for
5+
years, so it is the de-facto reference for correctness: for the same file, the
6+
fast path should produce the same rows and values.
7+
8+
Two groups:
9+
10+
* **Parity** — the fast path matches ``dbfread`` (rows + values), including the
11+
latin-1 round-trip and the ``E104`` vs ``E109`` prefix/exact CID guard.
12+
* **Regression** — the four divergences fixed in this PR:
13+
1. ``read_dbf_fast`` / ``stream_dbf_fast`` now skip deleted records (they
14+
didn't, so they disagreed with ``read_dbf_filtered`` and ``dbfread``).
15+
2. exact match (``prefix_match=False``) now matches values shorter than the
16+
field width and values with trailing NUL padding.
17+
3. default encoding is ``latin-1`` (DATASUS), not ``cp1252``.
18+
4. ``columns=`` in ``read_dbf_fast`` is case-insensitive, like the filter
19+
column lookup.
20+
21+
Fixtures are written by a small in-test dBASE III writer, so no real DATASUS
22+
files are needed.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import struct
28+
from pathlib import Path
29+
30+
import pandas as pd
31+
from dbfread import DBF
32+
from pysus.data import dbf_reader
33+
34+
# ───────────────────────── fixture writer ─────────────────────────
35+
36+
37+
def make_dbf(path, fields, records, deleted=()):
38+
"""Write a minimal dBASE III .dbf with character ('C') fields.
39+
40+
fields : list of (name, length)
41+
records : list of rows; str values are latin-1 encoded and space-padded,
42+
bytes values are written verbatim (truncated/space-padded)
43+
deleted : indices of records to flag as deleted ('*')
44+
"""
45+
record_len = 1 + sum(length for _, length in fields)
46+
header_len = 32 + 32 * len(fields) + 1
47+
48+
out = bytearray(32)
49+
out[0] = 0x03
50+
struct.pack_into("<I", out, 4, len(records))
51+
struct.pack_into("<H", out, 8, header_len)
52+
struct.pack_into("<H", out, 10, record_len)
53+
54+
for name, length in fields:
55+
fd = bytearray(32)
56+
nm = name.encode("ascii")[:11]
57+
fd[0 : len(nm)] = nm
58+
fd[11] = ord("C")
59+
fd[16] = length & 0xFF
60+
out += fd
61+
out.append(0x0D)
62+
63+
for ri, rec in enumerate(records):
64+
out.append(0x2A if ri in deleted else 0x20)
65+
for (_, length), val in zip(fields, rec):
66+
b = (
67+
val
68+
if isinstance(val, bytes)
69+
else str(val).encode("latin-1", "replace")
70+
)
71+
out += b[:length].ljust(length, b" ")
72+
out.append(0x1A)
73+
74+
Path(path).write_bytes(bytes(out))
75+
return str(path)
76+
77+
78+
def dbfread_rows(path, encoding="latin-1"):
79+
return list(DBF(str(path), encoding=encoding, char_decode_errors="replace"))
80+
81+
82+
# ───────────────────────────── parity ─────────────────────────────
83+
84+
85+
def test_parity_read_dbf_fast_vs_dbfread(tmp_path):
86+
"""Row-for-row, value-for-value parity on a plain file."""
87+
fields = [("UF", 2), ("DIAG_PRINC", 4), ("MUNIC_RES", 6), ("VAL_TOT", 10)]
88+
records = [
89+
["SP", "G200", "355030", "1234.56"],
90+
["RJ", "I10", "330455", "0.01"],
91+
["MG", "E104", "310620", ""],
92+
["BA", "", "292740", "99"],
93+
]
94+
p = make_dbf(tmp_path / "plain.dbf", fields, records)
95+
96+
df = dbf_reader.read_dbf_fast(p)
97+
ref = dbfread_rows(p)
98+
99+
assert list(df.columns) == [n for n, _ in fields]
100+
assert len(df) == len(ref)
101+
for i, rec in enumerate(ref):
102+
for name, _ in fields:
103+
assert df.iloc[i][name] == str(rec[name]).strip(), (i, name)
104+
105+
106+
def test_parity_latin1_accents(tmp_path):
107+
"""DATASUS text is latin-1; accented bytes round-trip (default path)."""
108+
p = make_dbf(
109+
tmp_path / "acc.dbf",
110+
[("NOME", 12)],
111+
[[b"JO\xe3O"], [b"CONCEI\xc7\xc3O"], [b"M\xe3E"]],
112+
)
113+
df = dbf_reader.read_dbf_fast(p) # default encoding now latin-1
114+
ref = dbfread_rows(p, encoding="latin-1")
115+
assert (
116+
list(df["NOME"])
117+
== [r["NOME"] for r in ref]
118+
== ["JOãO", "CONCEIÇÃO", "MãE"]
119+
)
120+
121+
122+
def test_parity_filtered_prefix_vs_dbfread(tmp_path):
123+
"""read_dbf_filtered (prefix path) == dbfread + manual filter."""
124+
fields = [("SP_NAIH", 13), ("SP_VALATO", 10)]
125+
records = [["123", "10"], ["456", "20"], ["123", "30"], ["789", "40"]]
126+
p = make_dbf(tmp_path / "filt.dbf", fields, records)
127+
128+
df = dbf_reader.read_dbf_filtered(p, "SP_NAIH", ["123"], prefix_match=True)
129+
ref = [r for r in dbfread_rows(p) if str(r["SP_NAIH"]).strip() == "123"]
130+
assert len(df) == len(ref) == 2
131+
assert list(df["SP_VALATO"]) == [str(r["SP_VALATO"]).strip() for r in ref]
132+
133+
134+
def test_parity_stream_vs_dbfread(tmp_path):
135+
"""Streaming chunks, concatenated, equal the dbfread rows."""
136+
fields = [("A", 3), ("B", 3)]
137+
records = [[f"{i:03d}", "xyz"] for i in range(257)]
138+
p = make_dbf(tmp_path / "stream.dbf", fields, records)
139+
140+
chunks = list(dbf_reader.stream_dbf_fast(p, chunk_size=100))
141+
df = pd.concat(chunks, ignore_index=True)
142+
ref = dbfread_rows(p)
143+
assert len(df) == len(ref)
144+
assert list(df["A"]) == [str(r["A"]).strip() for r in ref]
145+
146+
147+
def test_prefix_match_nul_padding(tmp_path):
148+
"""Prefix matching survives DATASUS NUL padding."""
149+
p = make_dbf(
150+
tmp_path / "nulpref.dbf",
151+
[("CID", 6)],
152+
[[b"G20\x00\x00\x00"], [b"Z999 "]],
153+
)
154+
df = dbf_reader.read_dbf_filtered(p, "CID", ["G20"], prefix_match=True)
155+
assert len(df) == 1
156+
157+
158+
def test_prefix_distinguishes_4char_exact(tmp_path):
159+
"""DATASUS CID semantics: 'E104' (fills the field) must not match 'E109'."""
160+
p = make_dbf(
161+
tmp_path / "cid.dbf",
162+
[("DIAG_PRINC", 4)],
163+
[["E104"], ["E109"], ["G400"]],
164+
)
165+
df = dbf_reader.read_dbf_filtered(
166+
p, "DIAG_PRINC", ["E104"], prefix_match=True
167+
)
168+
assert list(df["DIAG_PRINC"]) == ["E104"]
169+
170+
171+
# ───────────── regression tests for the fixes in this PR ─────────────
172+
173+
174+
def test_read_dbf_fast_skips_deleted(tmp_path):
175+
"""(fix 1) read_dbf_fast drops '*'-flagged records, like dbfread."""
176+
p = make_dbf(
177+
tmp_path / "del.dbf",
178+
[("X", 3)],
179+
[["AAA"], ["BBB"], ["CCC"]],
180+
deleted={1},
181+
)
182+
df = dbf_reader.read_dbf_fast(p)
183+
ref = dbfread_rows(p) # AAA, CCC
184+
assert list(df["X"]) == [str(r["X"]).strip() for r in ref] == ["AAA", "CCC"]
185+
186+
187+
def test_fast_and_filtered_agree_on_deleted(tmp_path):
188+
"""(fix 1) full read and filtered read agree on the same file."""
189+
p = make_dbf(
190+
tmp_path / "del2.dbf",
191+
[("X", 3)],
192+
[["AAA"], ["BBB"], ["CCC"]],
193+
deleted={1},
194+
)
195+
full = set(dbf_reader.read_dbf_fast(p)["X"])
196+
filt = set(
197+
dbf_reader.read_dbf_filtered(
198+
p, "X", ["AAA", "BBB", "CCC"], prefix_match=False
199+
)["X"]
200+
)
201+
assert full == filt == {"AAA", "CCC"}
202+
203+
204+
def test_stream_skips_deleted(tmp_path):
205+
"""(fix 1) streaming also drops deleted records."""
206+
fields = [("X", 3)]
207+
records = [[f"{i:03d}"] for i in range(10)]
208+
p = make_dbf(tmp_path / "delstream.dbf", fields, records, deleted={2, 7})
209+
df = pd.concat(
210+
list(dbf_reader.stream_dbf_fast(p, chunk_size=4)), ignore_index=True
211+
)
212+
ref = dbfread_rows(p)
213+
assert list(df["X"]) == [str(r["X"]).strip() for r in ref]
214+
assert len(df) == 8
215+
216+
217+
def test_exact_match_shorter_than_field(tmp_path):
218+
"""(fix 2) exact match works for values shorter than the field width."""
219+
p = make_dbf(
220+
tmp_path / "short.dbf", [("SP_NAIH", 13)], [["123"], ["456"], ["123"]]
221+
)
222+
df = dbf_reader.read_dbf_filtered(p, "SP_NAIH", ["123"], prefix_match=False)
223+
assert len(df) == 2
224+
225+
226+
def test_exact_match_with_nul_padding(tmp_path):
227+
"""(fix 2) exact match survives trailing NUL padding."""
228+
p = make_dbf(
229+
tmp_path / "nulexact.dbf",
230+
[("CID", 6)],
231+
[[b"G20\x00\x00\x00"], [b"Z999 "]],
232+
)
233+
df = dbf_reader.read_dbf_filtered(p, "CID", ["G20"], prefix_match=False)
234+
assert len(df) == 1
235+
236+
237+
def test_columns_subset_case_insensitive(tmp_path):
238+
"""(fix 4) read_dbf_fast column subset matches names case-insensitively."""
239+
p = make_dbf(
240+
tmp_path / "case.dbf", [("DIAG", 4), ("SEXO", 1)], [["G200", "1"]]
241+
)
242+
df = dbf_reader.read_dbf_fast(p, columns=["diag"])
243+
assert list(df.columns) == ["DIAG"]
244+
assert list(df["DIAG"]) == ["G200"]

0 commit comments

Comments
 (0)