Skip to content

Commit 4341f79

Browse files
Expose VczReader readahead knobs as CLI flags
Adds --readahead-workers and --readahead-buffer-size to query, view, and view-plink (every cli.make_reader consumer). The size flag accepts a byte count or a humanfriendly-style suffix (100M, 2G, 256MiB), parsed in binary mode so the suffix matches the underlying 256 MiB default. Both knobs flow through ViewPlinkOptions, so biofuse's make_reader_from_options consumers pick them up automatically.
1 parent ba9c63a commit 4341f79

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"numpy>=2",
2020
"zarr>=3.1",
2121
"click>=8.2.0",
22+
"humanfriendly",
2223
"pandas",
2324
"pyparsing>=3.1.0",
2425
"pyranges!=0.1.3 ; python_version < '3.12'",

tests/test_cli.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,3 +920,119 @@ def test_pop_defaults(self):
920920
kwargs = {}
921921
config = cli.LogConfig.pop_from_click_kwargs(kwargs)
922922
assert config == cli.LogConfig()
923+
924+
925+
class TestReadaheadOptions:
926+
"""``--readahead-workers`` / ``--readahead-buffer-size`` wiring for
927+
every ``make_reader`` consumer: ``query``, ``view``, ``view-plink``.
928+
"""
929+
930+
@pytest.mark.parametrize(
931+
("text", "expected"),
932+
[
933+
("0", 0),
934+
("1024", 1024),
935+
("100M", 100 * 1024 * 1024),
936+
("256MiB", 256 * 1024 * 1024),
937+
("2G", 2 * 1024**3),
938+
("1T", 1024**4),
939+
],
940+
)
941+
def test_size_param_parses_suffixes(self, text, expected):
942+
assert cli.SIZE.convert(text, None, None) == expected
943+
944+
def test_size_param_passes_int_through(self):
945+
assert cli.SIZE.convert(4096, None, None) == 4096
946+
947+
def test_size_param_passes_none_through(self):
948+
assert cli.SIZE.convert(None, None, None) is None
949+
950+
def test_size_param_rejects_invalid(self):
951+
with pytest.raises(click.UsageError):
952+
cli.SIZE.convert("not-a-size", None, None)
953+
954+
def test_make_reader_forwards_workers(self, fx_vcz_path):
955+
with cli.make_reader(fx_vcz_path, readahead_workers=4) as reader:
956+
assert reader._readahead_workers == 4
957+
958+
def test_make_reader_forwards_bytes(self, fx_vcz_path):
959+
with cli.make_reader(fx_vcz_path, readahead_bytes=1024) as reader:
960+
assert reader.readahead_bytes == 1024
961+
962+
def test_make_reader_default_passes_none(self, fx_vcz_path):
963+
# ``None`` is what flows from the CLI when the flags are
964+
# omitted. ``VczReader`` then applies its own defaults.
965+
with cli.make_reader(fx_vcz_path) as reader:
966+
assert reader.readahead_bytes is None
967+
968+
@staticmethod
969+
def _spy_vcz_reader_init(monkeypatch):
970+
captured = {}
971+
real_init = cli.retrieval.VczReader.__init__
972+
973+
def spy(self, root, **kw):
974+
captured.update(kw)
975+
real_init(self, root, **kw)
976+
977+
monkeypatch.setattr(cli.retrieval.VczReader, "__init__", spy)
978+
return captured
979+
980+
def test_view_forwards_flags(self, monkeypatch, tmp_path, fx_vcz_path):
981+
captured = self._spy_vcz_reader_init(monkeypatch)
982+
output_path = tmp_path / "tmp.vcf"
983+
runner = ct.CliRunner()
984+
result = runner.invoke(
985+
cli.vcztools_main,
986+
f"view --no-version --readahead-workers 4 "
987+
f"--readahead-buffer-size 100M {fx_vcz_path} "
988+
f"-o {output_path.as_posix()}",
989+
catch_exceptions=False,
990+
)
991+
assert result.exit_code == 0
992+
assert captured == {
993+
"readahead_workers": 4,
994+
"readahead_bytes": 100 * 1024 * 1024,
995+
}
996+
997+
def test_query_forwards_flags(self, monkeypatch, tmp_path, fx_vcz_path):
998+
captured = self._spy_vcz_reader_init(monkeypatch)
999+
output_path = tmp_path / "tmp.txt"
1000+
runner = ct.CliRunner()
1001+
result = runner.invoke(
1002+
cli.vcztools_main,
1003+
f"query -f '%POS\n' --readahead-workers 2 "
1004+
f"--readahead-buffer-size 1024 {fx_vcz_path} "
1005+
f"-o {output_path.as_posix()}",
1006+
catch_exceptions=False,
1007+
)
1008+
assert result.exit_code == 0
1009+
assert captured == {"readahead_workers": 2, "readahead_bytes": 1024}
1010+
1011+
def test_view_plink_forwards_flags(self, monkeypatch, tmp_path, fx_vcz_path):
1012+
captured = self._spy_vcz_reader_init(monkeypatch)
1013+
out = tmp_path / "p"
1014+
runner = ct.CliRunner()
1015+
result = runner.invoke(
1016+
cli.vcztools_main,
1017+
f"view-plink --max-alleles 2 -e 'CHROM==\"X\"' "
1018+
f"--readahead-workers 8 --readahead-buffer-size 2M "
1019+
f"{fx_vcz_path} --out {out.as_posix()}",
1020+
catch_exceptions=False,
1021+
)
1022+
assert result.exit_code == 0
1023+
assert captured == {
1024+
"readahead_workers": 8,
1025+
"readahead_bytes": 2 * 1024 * 1024,
1026+
}
1027+
1028+
def test_view_invalid_buffer_size(self, tmp_path, fx_vcz_path):
1029+
output_path = tmp_path / "tmp.vcf"
1030+
runner = ct.CliRunner()
1031+
result = runner.invoke(
1032+
cli.vcztools_main,
1033+
f"view --readahead-buffer-size garbage {fx_vcz_path} "
1034+
f"-o {output_path.as_posix()}",
1035+
catch_exceptions=False,
1036+
)
1037+
assert result.exit_code != 0
1038+
assert "garbage" in result.stderr

uv.lock

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

vcztools/cli.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from functools import wraps
99

1010
import click
11+
import humanfriendly
1112

1213
from . import bcftools_filter, plink, provenance, retrieval, vcf_writer
1314
from . import query as query_module
@@ -161,6 +162,43 @@ def wrapper(*args, **kwargs):
161162
)
162163

163164

165+
class _SizeParam(click.ParamType):
166+
name = "size"
167+
168+
# binary=True so '256M' == '256MB' == '256MiB' == 2**28, matching
169+
# the underlying VczReader default of 256 MiB.
170+
def convert(self, value, param, ctx):
171+
if value is None:
172+
return None
173+
if isinstance(value, int):
174+
return value
175+
try:
176+
return humanfriendly.parse_size(value, binary=True)
177+
except humanfriendly.InvalidSize as e:
178+
self.fail(str(e), param, ctx)
179+
180+
181+
SIZE = _SizeParam()
182+
183+
readahead_workers = click.option(
184+
"--readahead-workers",
185+
type=int,
186+
default=None,
187+
help=("Worker threads servicing the cross-chunk readahead pool. Default: 32."),
188+
)
189+
readahead_buffer_size = click.option(
190+
"--readahead-buffer-size",
191+
"readahead_bytes",
192+
type=SIZE,
193+
default=None,
194+
help=(
195+
"Cap on the readahead window. Accepts a byte count or a "
196+
"size string with suffix (e.g. '100M', '2G', '256MiB'). "
197+
"Default: 256MiB."
198+
),
199+
)
200+
201+
164202
def setup_logging(level: str, log_file: str | None) -> None:
165203
if log_file is not None:
166204
handler: logging.Handler = logging.FileHandler(log_file)
@@ -329,6 +367,8 @@ def make_reader(
329367
drop_genotypes=False,
330368
backend_storage=None,
331369
storage_options=None,
370+
readahead_workers=None,
371+
readahead_bytes=None,
332372
):
333373
"""Resolve file arguments and create a VczReader."""
334374
if regions is not None and regions_file is not None:
@@ -377,7 +417,11 @@ def make_reader(
377417
backend_storage=backend_storage,
378418
storage_options=storage_options,
379419
)
380-
reader = retrieval.VczReader(root)
420+
reader = retrieval.VczReader(
421+
root,
422+
readahead_workers=readahead_workers,
423+
readahead_bytes=readahead_bytes,
424+
)
381425

382426
variant_filter = None
383427
if include is not None or exclude is not None:
@@ -467,6 +511,8 @@ class ViewPlinkOptions:
467511
max_alleles: int | None = None
468512
backend_storage: str | None = None
469513
storage_options: dict | None = None
514+
readahead_workers: int | None = None
515+
readahead_bytes: int | None = None
470516

471517
@classmethod
472518
def pop_from_click_kwargs(cls, kwargs: dict) -> "ViewPlinkOptions":
@@ -505,6 +551,8 @@ def view_plink_options(f):
505551
max_alleles_opt,
506552
backend_storage,
507553
storage_option,
554+
readahead_workers,
555+
readahead_buffer_size,
508556
]
509557
for d in reversed(decorators):
510558
f = d(f)
@@ -632,6 +680,8 @@ def index(path, nrecords, stats, backend_storage, storage_options, log_level, lo
632680
)
633681
@backend_storage
634682
@storage_option
683+
@readahead_workers
684+
@readahead_buffer_size
635685
@log_level
636686
@log_file
637687
@handle_exception
@@ -652,6 +702,8 @@ def query(
652702
disable_automatic_newline,
653703
backend_storage,
654704
storage_options,
705+
readahead_workers,
706+
readahead_bytes,
655707
log_level,
656708
log_file,
657709
):
@@ -695,6 +747,8 @@ def query(
695747
force_samples=force_samples,
696748
backend_storage=backend_storage,
697749
storage_options=parsed_storage_options,
750+
readahead_workers=readahead_workers,
751+
readahead_bytes=readahead_bytes,
698752
)
699753
with reader, handle_broken_pipe(output):
700754
query_module.write_query(
@@ -752,6 +806,8 @@ def query(
752806
@max_alleles_opt
753807
@backend_storage
754808
@storage_option
809+
@readahead_workers
810+
@readahead_buffer_size
755811
@log_level
756812
@log_file
757813
@handle_exception
@@ -778,6 +834,8 @@ def view(
778834
max_alleles,
779835
backend_storage,
780836
storage_options,
837+
readahead_workers,
838+
readahead_bytes,
781839
log_level,
782840
log_file,
783841
):
@@ -821,6 +879,8 @@ def view(
821879
drop_genotypes=drop_genotypes,
822880
backend_storage=backend_storage,
823881
storage_options=_parse_storage_options(storage_options),
882+
readahead_workers=readahead_workers,
883+
readahead_bytes=readahead_bytes,
824884
)
825885
subsetting_samples = (
826886
samples is not None or samples_file is not None or drop_genotypes

0 commit comments

Comments
 (0)