Skip to content

Commit 698b6c8

Browse files
Merge pull request #32 from jeromekelleher/new-bgen-code
Expose BGEN tuning options
2 parents 515ce42 + d558c1b commit 698b6c8

5 files changed

Lines changed: 165 additions & 8 deletions

File tree

biofuse/formats.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ def _plink_encoder_factory(reader, opts):
129129

130130

131131
def _bgen_encoder_factory(reader, opts):
132+
embed_header_samples = not opts.no_header_samples
132133
return vcztools.BgenEncoder(
133134
reader,
134-
embed_header_samples=not opts.no_header_samples,
135+
embed_header_samples=embed_header_samples,
135136
unphased=opts.unphased,
137+
total_string_length=opts.total_string_length,
138+
pad_byte=opts.pad_byte,
136139
)
137140

138141

tests/test_bgen_apps.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,18 @@ async def fx_mounted_bgen(tmp_path, fx_medium_vcz):
7878
yield mnt, "medium", expected, log
7979

8080

81-
def _encoder_bytes(vcz_path: pathlib.Path) -> bytes:
81+
def _encoder_bytes(
82+
vcz_path: pathlib.Path,
83+
*,
84+
total_string_length: int | None = None,
85+
pad_byte: bytes | None = None,
86+
) -> bytes:
8287
reader = _open_reader(vcz_path)
83-
with vcztools.BgenEncoder(reader) as enc:
88+
with vcztools.BgenEncoder(
89+
reader,
90+
total_string_length=total_string_length,
91+
pad_byte=pad_byte,
92+
) as enc:
8493
return enc.read(0, enc.total_size)
8594

8695

@@ -89,14 +98,16 @@ async def _arun(cmd) -> None:
8998

9099

91100
@contextlib.asynccontextmanager
92-
async def _mount_bgen(tmp_path, vcz, opts=None):
101+
async def _mount_bgen(tmp_path, vcz, opts=None, *, mnt_name="mnt"):
93102
"""Mount ``vcz`` as a BGEN fileset; yield ``(mnt, basename)``.
94103
95104
``opts`` is the ``vcztools.ViewBgenOptions`` dataclass the host
96105
runs under; defaults to a fresh ``ViewBgenOptions()`` (every field
97-
at its dataclass default).
106+
at its dataclass default). ``mnt_name`` lets a single test mount
107+
twice under the same ``tmp_path`` without colliding on the
108+
mountpoint directory.
98109
"""
99-
mnt = tmp_path / "mnt"
110+
mnt = tmp_path / mnt_name
100111
mnt.mkdir()
101112
basename = vcz.path.stem
102113
if opts is None:
@@ -199,6 +210,41 @@ async def test_unphased_stable_across_opens(self, tmp_path, fx_small_vcz, unphas
199210
assert data == expected, f"cycle {cycle} differed from reference"
200211

201212

213+
class TestBgenCustomStringPadding:
214+
"""End-to-end coverage that ``--total-string-length`` /
215+
``--pad-byte`` reach the FUSE-served bytes.
216+
"""
217+
218+
async def test_full_bgen_with_custom_budget(self, tmp_path, fx_small_vcz):
219+
opts = vcztools.ViewBgenOptions(total_string_length=128, pad_byte=b"X")
220+
expected = _encoder_bytes(
221+
fx_small_vcz.path, total_string_length=128, pad_byte=b"X"
222+
)
223+
async with _mount_bgen(tmp_path, fx_small_vcz, opts=opts) as (mnt, basename):
224+
bgen_path = mnt / f"{basename}.bgen"
225+
data = await trio.to_thread.run_sync(bgen_path.read_bytes)
226+
assert data == expected
227+
228+
async def test_bytes_differ_from_default(self, tmp_path, fx_small_vcz):
229+
opts_x = vcztools.ViewBgenOptions(pad_byte=b"X")
230+
async with _mount_bgen(
231+
tmp_path, fx_small_vcz, opts=opts_x, mnt_name="mnt_x"
232+
) as (mnt, basename):
233+
data_x = await trio.to_thread.run_sync(
234+
(mnt / f"{basename}.bgen").read_bytes
235+
)
236+
237+
opts_default = vcztools.ViewBgenOptions()
238+
async with _mount_bgen(
239+
tmp_path, fx_small_vcz, opts=opts_default, mnt_name="mnt_default"
240+
) as (mnt, basename):
241+
data_default = await trio.to_thread.run_sync(
242+
(mnt / f"{basename}.bgen").read_bytes
243+
)
244+
245+
assert data_default != data_x
246+
247+
202248
def _pread_sync(path: pathlib.Path, off: int, size: int) -> bytes:
203249
with path.open("rb") as f:
204250
f.seek(off)

tests/test_cli.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def test_mount_bgen_help(self):
5454
"--no-sample-file",
5555
"--no-bgi",
5656
"--no-header-samples",
57+
"--total-string-length",
58+
"--pad-byte",
5759
"--log-level",
5860
]:
5961
assert flag in result.output, f"missing {flag} in mount-bgen help"
@@ -96,6 +98,47 @@ def test_bgen_nonexistent_mount_dir_fails(self, tmp_path):
9698
assert "mount directory does not exist" in result.output
9799

98100

101+
class TestMountBgenStringOptions:
102+
"""Parse-time validation of ``--total-string-length`` / ``--pad-byte``.
103+
104+
The CLI rejects malformed values before any FUSE mount is
105+
attempted, so these tests stay in-process.
106+
"""
107+
108+
def test_pad_byte_rejects_multichar(self, tmp_path):
109+
result = CliRunner().invoke(
110+
cli.biofuse_main,
111+
["mount-bgen", "--pad-byte", "XX", "x.vcz", str(tmp_path)],
112+
)
113+
assert result.exit_code != 0
114+
assert "single ASCII character" in result.output
115+
116+
def test_pad_byte_rejects_non_ascii(self, tmp_path):
117+
# Upstream's --pad-byte callback uses errors="strict", so a
118+
# non-ASCII character surfaces as a UnicodeEncodeError rather
119+
# than a click.BadParameter — only the non-zero exit is stable.
120+
result = CliRunner().invoke(
121+
cli.biofuse_main,
122+
["mount-bgen", "--pad-byte", "é", "x.vcz", str(tmp_path)],
123+
)
124+
assert result.exit_code != 0
125+
126+
def test_pad_byte_rejects_empty(self, tmp_path):
127+
result = CliRunner().invoke(
128+
cli.biofuse_main,
129+
["mount-bgen", "--pad-byte", "", "x.vcz", str(tmp_path)],
130+
)
131+
assert result.exit_code != 0
132+
assert "single ASCII character" in result.output
133+
134+
def test_total_string_length_rejects_zero(self, tmp_path):
135+
result = CliRunner().invoke(
136+
cli.biofuse_main,
137+
["mount-bgen", "--total-string-length", "0", "x.vcz", str(tmp_path)],
138+
)
139+
assert result.exit_code != 0
140+
141+
99142
class TestEndToEndMount:
100143
"""Spawn the CLI as a subprocess, wait for mount, read files, terminate."""
101144

tests/test_formats.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,71 @@ def test_unphased_default_matches_in_process(self, fx_reader, fx_small_vcz):
341341
assert data == ref_data
342342

343343

344+
class TestBgenTotalStringLengthPlumbing:
345+
"""``--total-string-length`` flows through to ``BgenEncoder``.
346+
347+
Compared against an in-process ``BgenEncoder`` built with the same
348+
kwarg, the streamed bytes must be identical.
349+
"""
350+
351+
def test_default_matches_in_process(self, fx_reader, fx_small_vcz):
352+
opts = vcztools.ViewBgenOptions()
353+
assert opts.total_string_length is None
354+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts) as encoder:
355+
data = encoder.read(0, encoder.total_size)
356+
ref_reader = _open_reader(fx_small_vcz.path)
357+
with vcztools.BgenEncoder(ref_reader) as ref:
358+
ref_data = ref.read(0, ref.total_size)
359+
assert data == ref_data
360+
361+
def test_explicit_value_matches_in_process(self, fx_reader, fx_small_vcz):
362+
opts = vcztools.ViewBgenOptions(total_string_length=128)
363+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts) as encoder:
364+
data = encoder.read(0, encoder.total_size)
365+
ref_reader = _open_reader(fx_small_vcz.path)
366+
with vcztools.BgenEncoder(ref_reader, total_string_length=128) as ref:
367+
ref_data = ref.read(0, ref.total_size)
368+
assert data == ref_data
369+
370+
def test_budget_too_small_raises(self, fx_reader):
371+
opts = vcztools.ViewBgenOptions(total_string_length=1)
372+
with pytest.raises(ValueError, match="total_string_length"):
373+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts) as encoder:
374+
encoder.read(0, encoder.total_size)
375+
376+
377+
class TestBgenPadBytePlumbing:
378+
"""``--pad-byte`` flows through to ``BgenEncoder(pad_byte=...)``."""
379+
380+
def test_default_matches_in_process(self, fx_reader, fx_small_vcz):
381+
opts = vcztools.ViewBgenOptions()
382+
assert opts.pad_byte is None
383+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts) as encoder:
384+
data = encoder.read(0, encoder.total_size)
385+
ref_reader = _open_reader(fx_small_vcz.path)
386+
with vcztools.BgenEncoder(ref_reader) as ref:
387+
ref_data = ref.read(0, ref.total_size)
388+
assert data == ref_data
389+
390+
def test_explicit_pad_byte_matches_in_process(self, fx_reader, fx_small_vcz):
391+
opts = vcztools.ViewBgenOptions(pad_byte=b"X")
392+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts) as encoder:
393+
data = encoder.read(0, encoder.total_size)
394+
ref_reader = _open_reader(fx_small_vcz.path)
395+
with vcztools.BgenEncoder(ref_reader, pad_byte=b"X") as ref:
396+
ref_data = ref.read(0, ref.total_size)
397+
assert data == ref_data
398+
399+
def test_pad_byte_changes_bytes(self, fx_reader):
400+
opts_default = vcztools.ViewBgenOptions()
401+
opts_x = vcztools.ViewBgenOptions(pad_byte=b"X")
402+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts_default) as encoder:
403+
data_default = encoder.read(0, encoder.total_size)
404+
with formats.BGEN_SPEC.encoder_factory(fx_reader, opts_x) as encoder:
405+
data_x = encoder.read(0, encoder.total_size)
406+
assert data_default != data_x
407+
408+
344409
class TestSpecsRegistry:
345410
def test_specs_dict_has_both_entries(self):
346411
assert formats.SPECS == {"plink": formats.PLINK_SPEC, "bgen": formats.BGEN_SPEC}

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)