Skip to content

Commit a86b683

Browse files
Merge pull request #17 from jeromekelleher/bgen-initial
Add BGEN support via shared encoder-server stack
2 parents e3d15c8 + 6f1464b commit a86b683

17 files changed

Lines changed: 1935 additions & 1086 deletions

README.md

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
# biofuse
22

33
Read-only views of VCF Zarr (VCZ) data in standard bioinformatics file formats
4-
via a FUSE filesystem. The first supported view is **PLINK 1.9 binary**
5-
(`.bed` / `.bim` / `.fam`).
4+
via a FUSE filesystem. Currently supported views:
5+
6+
- **PLINK 1.9 binary** (`.bed` / `.bim` / `.fam`) — via `mount-plink`.
7+
- **Oxford BGEN** (`.bgen` / `.sample` / `.bgen.bgi`) — via `mount-bgen`.
68

79
## Status
810

9-
Pre-release. The mount serves `.bed` on demand via a worker subprocess
10-
running [`vcztools.BedEncoder`](https://github.com/sgkit-dev/vcztools);
11-
`.bim` and `.fam` are computed once at mount time and held in the
12-
worker's memory.
11+
Pre-release. The mount serves the streaming file (`.bed` / `.bgen`) on
12+
demand via a worker subprocess running the matching
13+
[`vcztools`](https://github.com/sgkit-dev/vcztools) encoder
14+
(`BedEncoder` / `BgenEncoder`); the static sidecars are computed once
15+
at mount time and held in the worker's memory.
1316

14-
The mounted view is read-only and supports the access patterns of `plink1.9`
15-
and `plink2` for typical analysis commands (`--freq`, `--missing`, `--hardy`,
16-
etc.) — see `tests/test_plink_apps.py` for the verified set.
17+
The mounted PLINK view supports the access patterns of `plink1.9` and
18+
`plink2` for typical analysis commands (`--freq`, `--missing`,
19+
`--hardy`, etc.) — see `tests/test_plink_apps.py` for the verified set.
20+
The mounted BGEN view always uses zlib level 0 (stored, fixed-size
21+
blocks) for O(1) random access; `bgenix` / `qctool` parity checks live
22+
in `tests/test_bgen_apps.py`.
1723

1824
## Install
1925

@@ -34,6 +40,8 @@ vcztools is currently consumed as a sibling-directory path dependency
3440

3541
## Usage
3642

43+
### `mount-plink`
44+
3745
```bash
3846
biofuse mount-plink path/to/sample.vcz /mount/dir
3947
```
@@ -62,19 +70,44 @@ plink1.9 --bfile /tmp/plink-mnt/sample --freq --out ./out
6270
fusermount3 -u /tmp/plink-mnt
6371
```
6472

73+
### `mount-bgen`
74+
75+
```bash
76+
biofuse mount-bgen path/to/sample.vcz /mount/dir
77+
```
78+
79+
Mounts a read-only directory at `/mount/dir` containing
80+
`sample.bgen`, `sample.sample`, `sample.bgen.bgi`. The `.bgen` payload
81+
uses zlib level 0 (stored, fixed-size variant blocks) so byte-range
82+
random access is O(1); downstream tools (bgenix, qctool, REGENIE,
83+
SAIGE, BOLT-LMM, plink2 `--bgen`) consume the mount unchanged. The
84+
`.bgen.bgi` SQLite sidecar is generated once at mount time and held in
85+
the worker's memory alongside `.sample`.
86+
87+
Options mirror `mount-plink`: `--basename`, `--access-log`, and the
88+
shared bcftools-style filter / backend / log set inherited from
89+
`vcztools view-bgen`. Run `biofuse mount-bgen --help` or see
90+
`vcztools view-bgen --help` for the full reference.
91+
92+
Example:
93+
94+
```bash
95+
mkdir /tmp/bgen-mnt
96+
biofuse mount-bgen ./sample.vcz /tmp/bgen-mnt &
97+
bgenix -g /tmp/bgen-mnt/sample.bgen -list
98+
fusermount3 -u /tmp/bgen-mnt
99+
```
100+
65101
## Development
66102

67103
```bash
68104
uv sync --group dev
69-
uv run pytest # full suite
70-
uv run pytest tests/test_bed_worker.py # one module
71-
uv run prek install # install git pre-commit hook (one-off)
105+
uv run pytest # full suite
106+
uv run pytest tests/test_encoder_ops.py # one module
107+
uv run prek install # install git pre-commit hook (one-off)
72108
uv run --only-group=lint prek -c prek.toml run --all-files
73109
```
74110

75-
The streaming source spec lives at
76-
[`specs/vcztools_streaming_plink.md`](specs/vcztools_streaming_plink.md).
77-
78111
## Licence
79112

80113
Apache 2.0. See `LICENSE`.

biofuse/cli.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import trio
1212
from vcztools import cli as vcztools_cli
1313

14-
from biofuse import access_log, fuse_adapter, plink_client, plink_ops
14+
from biofuse import access_log, encoder_client, encoder_ops, formats, fuse_adapter
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -30,7 +30,7 @@ def wrapper(*args, **kwargs):
3030

3131

3232
def _default_basename(vcz_url: str) -> str:
33-
"""Strip every suffix off ``vcz_url`` to get a plink fileset stem."""
33+
"""Strip every suffix off ``vcz_url`` to get a fileset stem."""
3434
name = pathlib.Path(vcz_url).name
3535
while True:
3636
stem = pathlib.Path(name).stem
@@ -46,12 +46,15 @@ def _default_basename(vcz_url: str) -> str:
4646
default=None,
4747
help="Write per-read access trace as JSONL to PATH.",
4848
)
49-
basename_opt = click.option(
50-
"--basename",
51-
type=str,
52-
default=None,
53-
help="Basename for the plink fileset (defaults to the VCZ stem).",
54-
)
49+
50+
51+
def _basename_opt(format_label: str) -> click.Option:
52+
return click.option(
53+
"--basename",
54+
type=str,
55+
default=None,
56+
help=f"Basename for the {format_label} fileset (defaults to the VCZ stem).",
57+
)
5558

5659

5760
@click.group()
@@ -66,7 +69,7 @@ def biofuse_main():
6669
"mount_dir",
6770
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
6871
)
69-
@basename_opt
72+
@_basename_opt("plink")
7073
@vcztools_cli.view_plink_options
7174
@access_log_opt
7275
@vcztools_cli.log_options
@@ -85,9 +88,52 @@ def mount_plink(vcz_url, mount_dir, basename, access_log_path, **kwargs):
8588
8689
The mount runs in the foreground until interrupted with Ctrl-C.
8790
"""
88-
log_config = vcztools_cli.LogConfig.pop_from_click_kwargs(kwargs)
89-
reader_options = vcztools_cli.ViewPlinkOptions.pop_from_click_kwargs(kwargs)
90-
assert kwargs == {}, kwargs
91+
_run_mount(
92+
formats.PLINK_SPEC, vcz_url, mount_dir, basename, access_log_path, kwargs
93+
)
94+
95+
96+
@biofuse_main.command(name="mount-bgen")
97+
@click.argument("vcz_url", type=str)
98+
@click.argument(
99+
"mount_dir",
100+
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
101+
)
102+
@_basename_opt("bgen")
103+
@vcztools_cli.view_plink_options
104+
@access_log_opt
105+
@vcztools_cli.log_options
106+
@handle_exception
107+
def mount_bgen(vcz_url, mount_dir, basename, access_log_path, **kwargs):
108+
"""Mount an Oxford BGEN view of VCZ_URL at MOUNT_DIR.
109+
110+
Spawns a bgen-server subprocess that owns the ``VczReader`` and
111+
serves ``.bgen`` reads over an ``AF_UNIX`` socket. ``.sample`` and
112+
``.bgen.bgi`` are precomputed once at mount time and held in the
113+
FUSE process's memory; only ``.bgen`` reads cross the wire.
114+
115+
The bcftools-view-style filter / backend / log options are inherited
116+
from ``vcztools view-bgen``; see ``vcztools view-bgen --help`` for the
117+
full reference. The encoder always uses zlib level 0 (stored,
118+
fixed-size blocks) so byte-range random access into the mounted
119+
``.bgen`` is O(1).
120+
121+
The mount runs in the foreground until interrupted with Ctrl-C.
122+
"""
123+
_run_mount(formats.BGEN_SPEC, vcz_url, mount_dir, basename, access_log_path, kwargs)
124+
125+
126+
def _run_mount(
127+
spec: formats.FormatSpec,
128+
vcz_url: str,
129+
mount_dir: str,
130+
basename: str | None,
131+
access_log_path: str | None,
132+
extra_kwargs: dict,
133+
) -> None:
134+
log_config = vcztools_cli.LogConfig.pop_from_click_kwargs(extra_kwargs)
135+
reader_options = vcztools_cli.ViewPlinkOptions.pop_from_click_kwargs(extra_kwargs)
136+
assert extra_kwargs == {}, extra_kwargs
91137
log_config.apply()
92138

93139
mount_dir_path = pathlib.Path(mount_dir)
@@ -98,9 +144,10 @@ def mount_plink(vcz_url, mount_dir, basename, access_log_path, **kwargs):
98144
resolved_basename = basename if basename is not None else _default_basename(vcz_url)
99145

100146
with tempfile.TemporaryDirectory(prefix="biofuse-") as sock_dir:
101-
sock_path = pathlib.Path(sock_dir) / "plink.sock"
147+
sock_path = pathlib.Path(sock_dir) / f"{spec.name}.sock"
102148
trio.run(
103149
_amount,
150+
spec,
104151
vcz_url,
105152
str(mount_dir_path),
106153
resolved_basename,
@@ -112,6 +159,7 @@ def mount_plink(vcz_url, mount_dir, basename, access_log_path, **kwargs):
112159

113160

114161
async def _amount(
162+
spec: formats.FormatSpec,
115163
vcz_url: str,
116164
mount_dir: str,
117165
basename: str,
@@ -120,14 +168,17 @@ async def _amount(
120168
log_path: pathlib.Path | None,
121169
sock_path: pathlib.Path,
122170
) -> None:
123-
async with await plink_client.PlinkClient.start(
171+
async with await encoder_client.EncoderClient.start(
124172
vcz_url,
125173
sock_path,
174+
spec,
126175
reader_options=reader_options,
127176
log_config=log_config,
128177
) as client:
129178
with access_log.AccessLogger(log_path) as access_logger:
130-
ops = plink_ops.PlinkOps(client, basename, access_logger=access_logger)
179+
ops = encoder_ops.EncoderOps(
180+
client, basename, spec, access_logger=access_logger
181+
)
131182
async with fuse_adapter.mount(ops, mount_dir):
132183
click.echo(f"mounted at {mount_dir}", err=True)
133184
await _wait_for_signal()

0 commit comments

Comments
 (0)