Skip to content

Commit 4e07102

Browse files
authored
feat(dbfread): include the dbf_reader method by @atiweb and make it as default (#296)
* feat(dbfread): include the dbf_reader method by @atiweb and make it as default * chore(tests): include tests for dbf_reader
1 parent ca7c3bd commit 4e07102

14 files changed

Lines changed: 1288 additions & 92 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: build-wheels
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags:
7+
- "[0-9]*"
8+
pull_request:
9+
branches: [main]
10+
workflow_dispatch:
11+
12+
jobs:
13+
build:
14+
name: Build wheel (${{ matrix.os }})
15+
runs-on: ${{ matrix.os }}
16+
strategy:
17+
matrix:
18+
os: [ubuntu-latest, windows-latest, macos-latest]
19+
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: "3.12"
26+
27+
- name: Install poetry
28+
run: pipx install poetry
29+
30+
- name: Build wheels
31+
run: poetry build
32+
33+
- name: Upload artifacts
34+
uses: actions/upload-artifact@v4
35+
with:
36+
name: dist-${{ matrix.os }}
37+
path: dist/
38+
if-no-files-found: error
39+
40+
release:
41+
name: Create GitHub Release
42+
needs: build
43+
if: startsWith(github.ref, 'refs/tags/')
44+
runs-on: ubuntu-latest
45+
permissions:
46+
contents: write
47+
48+
steps:
49+
- uses: actions/download-artifact@v4
50+
with:
51+
name: dist-ubuntu-latest
52+
path: dist/
53+
54+
- name: Create release
55+
uses: softprops/action-gh-release@v2
56+
with:
57+
files: dist/*
58+
generate_release_notes: true

pysus/api/ducklake/catalog/adapters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,11 @@ async def _download_catalog(
189189

190190
try:
191191
await download_http(
192-
remote_path=remote_path, local_path=local_path,
192+
remote_path=remote_path,
193+
local_path=local_path,
193194
callback=callback,
194195
)
195-
except Exception:
196+
except Exception: # noqa: B902
196197
if local_path.exists():
197198
try:
198199
local_path.unlink()

pysus/api/ducklake/functional.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,13 @@ async def download_http(
4040
if callback:
4141
callback(downloaded, total)
4242
return
43-
except (OSError, httpx.HTTPStatusError, httpx.ConnectError,
44-
httpx.ReadError, httpx.RemoteProtocolError) as e:
43+
except (
44+
OSError,
45+
httpx.HTTPStatusError,
46+
httpx.ConnectError,
47+
httpx.ReadError,
48+
httpx.RemoteProtocolError,
49+
) as e:
4550
if local_path.exists():
4651
try:
4752
local_path.unlink()

pysus/api/extensions.py

Lines changed: 165 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
from pysus.api.errors import ConversionError, FormatError
2424
from pysus.api.metadata.models import Column
2525
from pysus.api.models import BaseCompressedFile, BaseLocalFile, BaseTabularFile
26+
from pysus.data.dbf_reader import (
27+
read_dbf_fast,
28+
read_dbf_schema,
29+
stream_dbf_fast,
30+
)
2631

2732
from .types import FileType
2833

@@ -341,7 +346,24 @@ class DBF(BaseTabularFile):
341346
def columns(self) -> list["Column"]:
342347
"""Return the column metadata from the DBF file."""
343348

344-
reader = DBFReader(self.path, load=False)
349+
try:
350+
schema = read_dbf_schema(self.path)
351+
except Exception: # noqa: B902 — fallback to dbfread
352+
reader = DBFReader(self.path, load=False)
353+
_DBF_DTYPE = {
354+
"C": "VARCHAR",
355+
"N": "INTEGER",
356+
"F": "FLOAT",
357+
"D": "DATE",
358+
"L": "BOOLEAN",
359+
"M": "VARCHAR",
360+
}
361+
return [
362+
Column.from_schema(
363+
name=f.name, dtype=_DBF_DTYPE.get(f.type, "VARCHAR")
364+
)
365+
for f in reader.fields
366+
]
345367
_DBF_DTYPE = {
346368
"C": "VARCHAR",
347369
"N": "INTEGER",
@@ -352,15 +374,18 @@ def columns(self) -> list["Column"]:
352374
}
353375
return [
354376
Column.from_schema(
355-
name=f.name, dtype=_DBF_DTYPE.get(f.type, "VARCHAR")
377+
name=f.name, dtype=_DBF_DTYPE.get(f.type_, "VARCHAR")
356378
)
357-
for f in reader.fields
379+
for f in schema.fields
358380
]
359381

360382
@property
361383
def rows(self) -> int:
362384
"""Return the number of records in the DBF file."""
363-
return len(DBFReader(self.path, load=False))
385+
try:
386+
return read_dbf_schema(self.path).num_records
387+
except Exception: # noqa: B902 — fallback to dbfread
388+
return len(DBFReader(self.path, load=False))
364389

365390
def decode_column(self, value):
366391
"""Decode a raw DBF value, handling byte strings and null bytes.
@@ -386,22 +411,56 @@ def decode_column(self, value):
386411
return value.replace("\x00", "").strip()
387412
return value
388413

389-
async def load(self) -> pd.DataFrame:
390-
"""Read the entire DBF file into a DataFrame."""
414+
def _load_dbfread(self) -> pd.DataFrame:
415+
"""Read the entire DBF file into a DataFrame using dbfread."""
416+
dbf = DBFReader(self.path, encoding="cp1252", raw=True)
417+
df = pd.DataFrame(iter(dbf))
418+
return df.map(self.decode_column)
391419

392-
def _load():
393-
"""Read the DBF file synchronously in a thread."""
394-
dbf = DBFReader(self.path, encoding="cp1252", raw=True)
395-
df = pd.DataFrame(iter(dbf))
396-
return df.map(self.decode_column)
420+
async def load(self, fast: bool = True) -> pd.DataFrame:
421+
"""Read the entire DBF file into a DataFrame.
397422
398-
return await to_thread.run_sync(_load)
423+
Parameters
424+
----------
425+
fast : bool
426+
If ``True`` use the byte-level reader (default), falling back
427+
to dbfread on failure.
428+
If ``False`` use dbfread.
429+
"""
430+
431+
if fast:
432+
try:
433+
return await to_thread.run_sync(read_dbf_fast, self.path)
434+
except Exception: # noqa: B902 — fallback to dbfread
435+
pass
436+
437+
return await to_thread.run_sync(self._load_dbfread)
399438

400439
async def stream(
401440
self,
402441
chunk_size: int = 30000,
442+
fast: bool = True,
403443
) -> AsyncGenerator[pd.DataFrame, None]:
404-
"""Yield the DBF records in chunks of the given size."""
444+
"""Yield the DBF records in chunks of the given size.
445+
446+
Parameters
447+
----------
448+
chunk_size : int
449+
Number of rows per chunk.
450+
fast : bool
451+
If ``True`` use the byte-level reader (default), falling back
452+
to dbfread on failure.
453+
If ``False`` use dbfread.
454+
"""
455+
456+
if fast:
457+
try:
458+
for chunk in stream_dbf_fast(self.path, chunk_size):
459+
yield chunk
460+
await asyncio.sleep(0)
461+
return
462+
except Exception: # noqa: B902 — fallback to dbfread
463+
pass
405464

406465
def _get_db():
407466
"""Open the DBF reader synchronously in a thread."""
@@ -424,8 +483,20 @@ async def to_parquet(
424483
output_path: str | Path | None = None,
425484
chunk_size: int = 30000,
426485
callback: Callable[[int, int], None] | None = None,
486+
fast: bool = True,
427487
) -> "Parquet":
428-
"""Convert the DBF file to Parquet format."""
488+
"""Convert the DBF file to Parquet format.
489+
490+
Parameters
491+
----------
492+
output_path : str or Path, optional
493+
chunk_size : int
494+
Rows per chunk when building Parquet.
495+
callback : callable, optional
496+
fast : bool
497+
If ``True`` use the byte-level reader (default).
498+
If ``False`` use dbfread.
499+
"""
429500
from pysus.api.extensions import ExtensionFactory
430501

431502
out = (
@@ -440,53 +511,98 @@ async def to_parquet(
440511
raise ConversionError(f"Could not parse {out} to Parquet")
441512
return file
442513

443-
async def _stream_to_single_file():
444-
dbf_reader = DBFReader(self.path, encoding="cp1252", raw=True)
445-
total_rows = len(dbf_reader)
446-
writer = None
447-
records = []
448-
514+
if fast:
449515
try:
450-
for i, record in enumerate(dbf_reader):
451-
records.append(record)
452-
current_count = i + 1
453-
454-
if current_count % chunk_size == 0:
455-
df = pd.DataFrame(records).map(self.decode_column)
456-
table = pa.Table.from_pandas(df)
457-
if writer is None:
458-
writer = pq.ParquetWriter(str(out), table.schema)
459-
writer.write_table(table)
460-
records = []
461-
462-
if callback:
463-
callback(current_count, total_rows)
464-
await asyncio.sleep(0)
465-
466-
if records:
516+
await self._to_parquet_fast(out, chunk_size, callback)
517+
except Exception: # noqa: B902 — fallback to dbfread
518+
await self._to_parquet_dbfread(out, chunk_size, callback)
519+
else:
520+
await self._to_parquet_dbfread(out, chunk_size, callback)
521+
522+
file = await ExtensionFactory.instantiate(out)
523+
if not isinstance(file, Parquet):
524+
raise ConversionError(f"Could not parse {out} to Parquet")
525+
return file
526+
527+
async def _to_parquet_fast(
528+
self,
529+
out: Path,
530+
chunk_size: int,
531+
callback: Callable[[int, int], None] | None,
532+
):
533+
schema = read_dbf_schema(self.path)
534+
total_rows = schema.num_records
535+
writer = None
536+
processed = 0
537+
538+
try:
539+
for chunk in stream_dbf_fast(self.path, chunk_size):
540+
table = pa.Table.from_pandas(chunk)
541+
if writer is None:
542+
writer = pq.ParquetWriter(str(out), table.schema)
543+
writer.write_table(table)
544+
processed += len(chunk)
545+
546+
if callback:
547+
callback(processed, total_rows)
548+
await asyncio.sleep(0)
549+
550+
if writer is None:
551+
df_empty = pd.DataFrame(
552+
columns=pd.Index([f.name for f in schema.fields])
553+
)
554+
table_empty = pa.Table.from_pandas(df_empty)
555+
writer = pq.ParquetWriter(str(out), table_empty.schema)
556+
finally:
557+
if writer:
558+
writer.close()
559+
560+
async def _to_parquet_dbfread(
561+
self,
562+
out: Path,
563+
chunk_size: int,
564+
callback: Callable[[int, int], None] | None,
565+
):
566+
dbf_reader = DBFReader(self.path, encoding="cp1252", raw=True)
567+
total_rows = len(dbf_reader)
568+
writer = None
569+
records = []
570+
571+
try:
572+
for i, record in enumerate(dbf_reader):
573+
records.append(record)
574+
current_count = i + 1
575+
576+
if current_count % chunk_size == 0:
467577
df = pd.DataFrame(records).map(self.decode_column)
468578
table = pa.Table.from_pandas(df)
469579
if writer is None:
470580
writer = pq.ParquetWriter(str(out), table.schema)
471581
writer.write_table(table)
582+
records = []
472583

473584
if callback:
474-
callback(total_rows, total_rows)
585+
callback(current_count, total_rows)
586+
await asyncio.sleep(0)
475587

588+
if records:
589+
df = pd.DataFrame(records).map(self.decode_column)
590+
table = pa.Table.from_pandas(df)
476591
if writer is None:
477-
df_empty = pd.DataFrame(columns=pd.Index(self.columns))
478-
table_empty = pa.Table.from_pandas(df_empty)
479-
writer = pq.ParquetWriter(str(out), table_empty.schema)
592+
writer = pq.ParquetWriter(str(out), table.schema)
593+
writer.write_table(table)
480594

481-
finally:
482-
if writer:
483-
writer.close()
595+
if callback:
596+
callback(total_rows, total_rows)
484597

485-
await _stream_to_single_file()
486-
file = await ExtensionFactory.instantiate(out)
487-
if not isinstance(file, Parquet):
488-
raise ConversionError(f"Could not parse {out} to Parquet")
489-
return file
598+
if writer is None:
599+
df_empty = pd.DataFrame(columns=pd.Index(self.columns))
600+
table_empty = pa.Table.from_pandas(df_empty)
601+
writer = pq.ParquetWriter(str(out), table_empty.schema)
602+
603+
finally:
604+
if writer:
605+
writer.close()
490606

491607

492608
class DBC(BaseTabularFile):

pysus/data/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)