nanofastq 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nanofastq-0.0.1/.gitignore +8 -0
- nanofastq-0.0.1/.pre-commit-config.yaml +35 -0
- nanofastq-0.0.1/AGENTS.md +28 -0
- nanofastq-0.0.1/CMakeLists.txt +45 -0
- nanofastq-0.0.1/PKG-INFO +56 -0
- nanofastq-0.0.1/README.md +44 -0
- nanofastq-0.0.1/benchmarks/README.md +31 -0
- nanofastq-0.0.1/benchmarks/bench.py +43 -0
- nanofastq-0.0.1/benchmarks/check.py +20 -0
- nanofastq-0.0.1/benchmarks/fetch_data.py +31 -0
- nanofastq-0.0.1/benchmarks/run_one.py +56 -0
- nanofastq-0.0.1/benchmarks/test_perf.py +64 -0
- nanofastq-0.0.1/pyproject.toml +95 -0
- nanofastq-0.0.1/src/fastq.c +386 -0
- nanofastq-0.0.1/src/fastq.h +21 -0
- nanofastq-0.0.1/src/module.cc +41 -0
- nanofastq-0.0.1/src/nanofastq/__init__.py +31 -0
- nanofastq-0.0.1/src/nanofastq/_core.pyi +5 -0
- nanofastq-0.0.1/src/nanofastq/py.typed +0 -0
- nanofastq-0.0.1/tests/test_reader.py +254 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# See https://pre-commit.com for more information
|
|
2
|
+
# See https://pre-commit.com/hooks.html for more hooks
|
|
3
|
+
repos:
|
|
4
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
5
|
+
rev: v6.0.0
|
|
6
|
+
hooks:
|
|
7
|
+
- id: trailing-whitespace
|
|
8
|
+
- id: end-of-file-fixer
|
|
9
|
+
- id: check-yaml
|
|
10
|
+
- id: check-added-large-files
|
|
11
|
+
|
|
12
|
+
- repo: https://github.com/python-jsonschema/check-jsonschema
|
|
13
|
+
rev: 0.38.2
|
|
14
|
+
hooks:
|
|
15
|
+
- id: check-github-workflows
|
|
16
|
+
- id: check-renovate
|
|
17
|
+
language: python
|
|
18
|
+
additional_dependencies:
|
|
19
|
+
- pyjson5==2.0.1
|
|
20
|
+
|
|
21
|
+
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
22
|
+
rev: "v2.30.3"
|
|
23
|
+
hooks:
|
|
24
|
+
- id: pyproject-fmt
|
|
25
|
+
|
|
26
|
+
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
|
27
|
+
rev: v1.31.1
|
|
28
|
+
hooks:
|
|
29
|
+
- id: zizmor
|
|
30
|
+
args: ["--persona=pedantic"]
|
|
31
|
+
|
|
32
|
+
- repo: https://github.com/hukkin/mdformat
|
|
33
|
+
rev: 1.0.0
|
|
34
|
+
hooks:
|
|
35
|
+
- id: mdformat
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
nanofastq is a FASTQ reader written in C (nanoarrow + zlib), wrapped with nanobind, that exposes the Arrow PyCapsule stream interface (`__arrow_c_stream__`).
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
- `src/fastq.c`, `src/fastq.h`: the parser, which builds Arrow record batches.
|
|
8
|
+
- `src/module.cc`: the nanobind wrapper (`open_stream`).
|
|
9
|
+
- `src/nanofastq/`: the Python API. `_core.pyi` is a hand-written stub for the compiled module.
|
|
10
|
+
- `tests/`: pytest. `benchmarks/`: CodSpeed benchmarks (`test_perf.py`) and a biobear comparison (see its README).
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
- Test: `uv run pytest`. Every supported Python plus the type check: `tox p`.
|
|
15
|
+
- Type check: `tox -e type` (mypy and ty; nothing to compile).
|
|
16
|
+
- Lint and format: `pre-commit run -a`.
|
|
17
|
+
- Benchmarks: `uv run pytest benchmarks/test_perf.py --codspeed` (only checks they run; the numbers come from CI).
|
|
18
|
+
|
|
19
|
+
## Gotchas
|
|
20
|
+
|
|
21
|
+
- Keep `_core.pyi` in sync with `module.cc`; nothing checks it.
|
|
22
|
+
- The version comes from git tags (setuptools-scm). Don't set it in `pyproject.toml`; any job that builds the package needs `fetch-depth: 0`.
|
|
23
|
+
- The parser validates a whole record (structure, lengths, UTF-8) before appending it, so a bad record never leaves a half-built row. Keep it that way.
|
|
24
|
+
- Output must stay identical to biobear's (`benchmarks/check.py`).
|
|
25
|
+
- Workflow actions are pinned by SHA with a `# vX.Y.Z` comment, and runner images are pinned too; Renovate updates both. zizmor (pedantic) runs in pre-commit.
|
|
26
|
+
- PR titles use Conventional Commits (`feat:`, `fix:`, `ci:`, `chore(deps):`, ...). `.github/semantic.yml` enforces this, and Release Drafter derives labels, sections and version bumps from them.
|
|
27
|
+
- Never name a branch `renovate`: it collides with the Renovate app's `renovate/*` branches.
|
|
28
|
+
- CodSpeed can flag a roughly 12% "regression" on untouched code when the run landed on different hardware. Re-run before chasing it.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.28)
|
|
2
|
+
project(nanofastq LANGUAGES C CXX)
|
|
3
|
+
|
|
4
|
+
set(CMAKE_C_STANDARD 11)
|
|
5
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
6
|
+
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
|
7
|
+
|
|
8
|
+
find_package(Python 3.12 COMPONENTS Interpreter Development.Module Development.SABIModule REQUIRED)
|
|
9
|
+
find_package(ZLIB QUIET)
|
|
10
|
+
if(NOT ZLIB_FOUND)
|
|
11
|
+
# No system zlib (e.g. Windows): build a static copy.
|
|
12
|
+
include(FetchContent)
|
|
13
|
+
FetchContent_Declare(
|
|
14
|
+
zlib
|
|
15
|
+
URL https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz
|
|
16
|
+
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
|
|
17
|
+
EXCLUDE_FROM_ALL)
|
|
18
|
+
set(ZLIB_BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
|
19
|
+
set(ZLIB_BUILD_SHARED OFF CACHE BOOL "" FORCE)
|
|
20
|
+
set(ZLIB_INSTALL OFF CACHE BOOL "" FORCE)
|
|
21
|
+
FetchContent_MakeAvailable(zlib)
|
|
22
|
+
add_library(ZLIB::ZLIB ALIAS zlibstatic)
|
|
23
|
+
endif()
|
|
24
|
+
|
|
25
|
+
execute_process(
|
|
26
|
+
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
|
|
27
|
+
OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT)
|
|
28
|
+
find_package(nanobind CONFIG REQUIRED)
|
|
29
|
+
|
|
30
|
+
include(FetchContent)
|
|
31
|
+
FetchContent_Declare(
|
|
32
|
+
nanoarrow
|
|
33
|
+
URL https://github.com/apache/arrow-nanoarrow/archive/refs/tags/apache-arrow-nanoarrow-0.9.0.tar.gz
|
|
34
|
+
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
|
|
35
|
+
EXCLUDE_FROM_ALL) # keep nanoarrow's headers/libs out of the wheel
|
|
36
|
+
set(NANOARROW_NAMESPACE "NanoFastq")
|
|
37
|
+
FetchContent_MakeAvailable(nanoarrow)
|
|
38
|
+
|
|
39
|
+
add_library(fastq_core STATIC src/fastq.c)
|
|
40
|
+
target_include_directories(fastq_core PUBLIC src)
|
|
41
|
+
target_link_libraries(fastq_core PUBLIC nanoarrow::nanoarrow ZLIB::ZLIB)
|
|
42
|
+
|
|
43
|
+
nanobind_add_module(_core STABLE_ABI src/module.cc)
|
|
44
|
+
target_link_libraries(_core PRIVATE fastq_core)
|
|
45
|
+
install(TARGETS _core LIBRARY DESTINATION .)
|
nanofastq-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: nanofastq
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Tiny, dependency-free FASTQ reader that exposes the Arrow PyCapsule stream interface.
|
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# nanofastq
|
|
14
|
+
|
|
15
|
+
[](https://codspeed.io/edgarrmondragon/nanofastq)
|
|
16
|
+
[](https://pypi.org/project/nanofastq/)
|
|
17
|
+
|
|
18
|
+
Tiny [FASTQ] reader (C + nanoarrow) exposing `__arrow_c_stream__`.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import pyarrow as pa
|
|
22
|
+
from nanofastq import read_fastq
|
|
23
|
+
|
|
24
|
+
table = pa.table(read_fastq("reads.fastq.gz"))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
Run the tests and the type check with [ty] (requires [Tox]):
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
tox p
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Performance is tracked with [CodSpeed]. Running the benchmarks locally only checks that they
|
|
36
|
+
work; the reported numbers come from CI:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
uv run pytest benchmarks/test_perf.py --codspeed
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Releasing
|
|
43
|
+
|
|
44
|
+
The version is derived from git tags with [setuptools-scm]; there is no version to edit in
|
|
45
|
+
`pyproject.toml`. [Release Drafter] keeps a draft GitHub release up to date from merged pull
|
|
46
|
+
requests (their labels pick the section and the next version). To release, review the draft and
|
|
47
|
+
publish it: that creates the `vX.Y.Z` tag, builds the wheels and uploads them to PyPI (see
|
|
48
|
+
`.github/workflows/wheels.yml`). Untagged commits get development versions such as
|
|
49
|
+
`0.1.dev12+gabc1234`.
|
|
50
|
+
|
|
51
|
+
[codspeed]: https://codspeed.io
|
|
52
|
+
[fastq]: https://en.wikipedia.org/wiki/FASTQ_format
|
|
53
|
+
[release drafter]: https://github.com/release-drafter/release-drafter
|
|
54
|
+
[setuptools-scm]: https://setuptools-scm.readthedocs.io
|
|
55
|
+
[tox]: https://tox.wiki
|
|
56
|
+
[ty]: https://docs.astral.sh/ty/
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# nanofastq
|
|
2
|
+
|
|
3
|
+
[](https://codspeed.io/edgarrmondragon/nanofastq)
|
|
4
|
+
[](https://pypi.org/project/nanofastq/)
|
|
5
|
+
|
|
6
|
+
Tiny [FASTQ] reader (C + nanoarrow) exposing `__arrow_c_stream__`.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
from nanofastq import read_fastq
|
|
11
|
+
|
|
12
|
+
table = pa.table(read_fastq("reads.fastq.gz"))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Development
|
|
16
|
+
|
|
17
|
+
Run the tests and the type check with [ty] (requires [Tox]):
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
tox p
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Performance is tracked with [CodSpeed]. Running the benchmarks locally only checks that they
|
|
24
|
+
work; the reported numbers come from CI:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
uv run pytest benchmarks/test_perf.py --codspeed
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Releasing
|
|
31
|
+
|
|
32
|
+
The version is derived from git tags with [setuptools-scm]; there is no version to edit in
|
|
33
|
+
`pyproject.toml`. [Release Drafter] keeps a draft GitHub release up to date from merged pull
|
|
34
|
+
requests (their labels pick the section and the next version). To release, review the draft and
|
|
35
|
+
publish it: that creates the `vX.Y.Z` tag, builds the wheels and uploads them to PyPI (see
|
|
36
|
+
`.github/workflows/wheels.yml`). Untagged commits get development versions such as
|
|
37
|
+
`0.1.dev12+gabc1234`.
|
|
38
|
+
|
|
39
|
+
[codspeed]: https://codspeed.io
|
|
40
|
+
[fastq]: https://en.wikipedia.org/wiki/FASTQ_format
|
|
41
|
+
[release drafter]: https://github.com/release-drafter/release-drafter
|
|
42
|
+
[setuptools-scm]: https://setuptools-scm.readthedocs.io
|
|
43
|
+
[tox]: https://tox.wiki
|
|
44
|
+
[ty]: https://docs.astral.sh/ty/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Benchmarks
|
|
2
|
+
|
|
3
|
+
Compares `nanofastq` with [biobear](https://github.com/wheretrue/biobear) on real
|
|
4
|
+
Illumina FASTQ files from ENA. Each measurement runs in a fresh process (imports are
|
|
5
|
+
excluded from the timer); the first run of each case is a warm-up and the table shows the
|
|
6
|
+
median of the rest.
|
|
7
|
+
|
|
8
|
+
```console
|
|
9
|
+
$ uv venv --python 3.13 && uv pip install biobear pyarrow ..
|
|
10
|
+
$ python fetch_data.py data # ~220 MB download, ~1.2 GB on disk
|
|
11
|
+
$ python check.py data/SRR1770413_1.fastq.gz # both readers return identical columns
|
|
12
|
+
$ python bench.py data 5
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`inflate-only` / `read-only` are floors: just decompressing (system zlib) or reading the
|
|
16
|
+
bytes, with no parsing.
|
|
17
|
+
|
|
18
|
+
## Results
|
|
19
|
+
|
|
20
|
+
Apple M3 Pro, Python 3.13, biobear 0.23.7, pyarrow 26.0, warm file cache. Median wall time
|
|
21
|
+
to build a full Arrow table:
|
|
22
|
+
|
|
23
|
+
| File | nanofastq | biobear | Floor |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| SRR1770413 `.gz` (643k × ~120 bp) | 0.45 s | 1.22 s | 0.37 s |
|
|
26
|
+
| SRR1770413 plain | 0.14 s | 0.47 s | 0.02 s |
|
|
27
|
+
| SRR390728 `.gz` (7.2M × 36 bp) | 0.82 s | 2.29 s | 0.30 s |
|
|
28
|
+
| SRR390728 plain | 0.60 s | 1.99 s | 0.04 s |
|
|
29
|
+
|
|
30
|
+
Streaming batch by batch gives the same ratios. Neither reader used more than one core.
|
|
31
|
+
One machine and two files: treat the ratios as indicative, not general.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Compare nanofastq with biobear, each run in a fresh process.
|
|
2
|
+
|
|
3
|
+
Usage: python bench.py DATA_DIR [RUNS] (needs biobear: see README)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import statistics as st
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
data = Path(sys.argv[1])
|
|
13
|
+
runs = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
|
14
|
+
run_one = Path(__file__).with_name("run_one.py")
|
|
15
|
+
|
|
16
|
+
IMPLS = ["nanofastq", "biobear", "nanofastq-stream", "biobear-stream"]
|
|
17
|
+
CASES = [
|
|
18
|
+
("SRR1770413_1.fastq.gz", ["inflate-only", *IMPLS]),
|
|
19
|
+
("SRR1770413_1.fastq", ["read-only", *IMPLS]),
|
|
20
|
+
("SRR390728_1.fastq.gz", ["inflate-only", *IMPLS]),
|
|
21
|
+
("SRR390728_1.fastq", ["read-only", *IMPLS]),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
for name, impls in CASES:
|
|
25
|
+
for impl in impls:
|
|
26
|
+
res = []
|
|
27
|
+
for _ in range(runs + 1): # first run is a warm-up (page cache)
|
|
28
|
+
p = subprocess.run(
|
|
29
|
+
[sys.executable, "-I", str(run_one), impl, str(data / name)],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=True,
|
|
33
|
+
)
|
|
34
|
+
res.append(json.loads(p.stdout.splitlines()[-1]))
|
|
35
|
+
res = res[1:]
|
|
36
|
+
print(
|
|
37
|
+
f"{name:22} {impl:16} "
|
|
38
|
+
f"med={st.median(r['wall'] for r in res):6.2f}s "
|
|
39
|
+
f"min={min(r['wall'] for r in res):6.2f}s "
|
|
40
|
+
f"cpu={st.median(r['cpu'] for r in res):6.2f}s "
|
|
41
|
+
f"rss={max(r['rss_mb'] for r in res):6.0f}MB",
|
|
42
|
+
flush=True,
|
|
43
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import biobear as bb
|
|
3
|
+
import pyarrow as pa
|
|
4
|
+
import nanofastq
|
|
5
|
+
|
|
6
|
+
path = sys.argv[1]
|
|
7
|
+
mine = pa.table(nanofastq.read_fastq(path))
|
|
8
|
+
theirs = bb.connect().read_fastq_file(path).to_arrow()
|
|
9
|
+
print("rows:", mine.num_rows, theirs.num_rows)
|
|
10
|
+
print("nanofastq schema:", mine.schema.to_string().replace("\n", "; "))
|
|
11
|
+
print("biobear schema:", theirs.schema.to_string().replace("\n", "; "))
|
|
12
|
+
for col in mine.column_names:
|
|
13
|
+
a = mine[col].combine_chunks().cast(pa.large_string())
|
|
14
|
+
b = theirs[col].combine_chunks().cast(pa.large_string())
|
|
15
|
+
print(f" {col}: equal={a.equals(b)}")
|
|
16
|
+
if not a.equals(b):
|
|
17
|
+
for i in range(len(a)):
|
|
18
|
+
if a[i].as_py() != b[i].as_py():
|
|
19
|
+
print(" first diff at", i, repr(a[i].as_py())[:80], "|", repr(b[i].as_py())[:80])
|
|
20
|
+
break
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Download the benchmark FASTQ files from ENA and write plain copies next to them.
|
|
2
|
+
|
|
3
|
+
Usage: python fetch_data.py [DATA_DIR] (default: ./data)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import gzip
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
BASE = "https://ftp.sra.ebi.ac.uk/vol1/fastq"
|
|
13
|
+
FILES = [
|
|
14
|
+
# 643k reads of ~120 bp (119 MB gzipped)
|
|
15
|
+
f"{BASE}/SRR177/003/SRR1770413/SRR1770413_1.fastq.gz",
|
|
16
|
+
# 7.2M reads of 36 bp (101 MB gzipped)
|
|
17
|
+
f"{BASE}/SRR390/SRR390728/SRR390728_1.fastq.gz",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
data = Path(sys.argv[1] if len(sys.argv) > 1 else "data")
|
|
21
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
for url in FILES:
|
|
23
|
+
gz = data / url.rsplit("/", 1)[1]
|
|
24
|
+
if not gz.exists():
|
|
25
|
+
print("downloading", url)
|
|
26
|
+
urllib.request.urlretrieve(url, gz) # noqa: S310
|
|
27
|
+
plain = gz.with_suffix("")
|
|
28
|
+
if not plain.exists():
|
|
29
|
+
print("decompressing", gz.name)
|
|
30
|
+
with gzip.open(gz, "rb") as src, plain.open("wb") as dst:
|
|
31
|
+
shutil.copyfileobj(src, dst)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Run one implementation once in this process; print a JSON line.
|
|
2
|
+
|
|
3
|
+
Imports happen before the timer starts and are reported separately.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import resource
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
impl, path = sys.argv[1], sys.argv[2]
|
|
12
|
+
|
|
13
|
+
t_imp = time.perf_counter()
|
|
14
|
+
if impl.startswith("nanofastq"):
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
import nanofastq
|
|
17
|
+
elif impl.startswith("biobear"):
|
|
18
|
+
import biobear as bb
|
|
19
|
+
import pyarrow as pa
|
|
20
|
+
t_imp = time.perf_counter() - t_imp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run():
|
|
24
|
+
if impl == "nanofastq":
|
|
25
|
+
return pa.table(nanofastq.read_fastq(path)).num_rows
|
|
26
|
+
if impl == "nanofastq-stream":
|
|
27
|
+
r = pa.RecordBatchReader.from_stream(nanofastq.read_fastq(path))
|
|
28
|
+
return sum(len(b) for b in r)
|
|
29
|
+
if impl == "biobear":
|
|
30
|
+
return bb.connect().read_fastq_file(path).to_arrow().num_rows
|
|
31
|
+
if impl == "biobear-stream":
|
|
32
|
+
r = bb.connect().read_fastq_file(path).to_arrow_record_batch_reader()
|
|
33
|
+
return sum(len(b) for b in r)
|
|
34
|
+
if impl == "inflate-only": # floor: just decompress (system zlib), no parsing
|
|
35
|
+
n = 0
|
|
36
|
+
d = zlib.decompressobj(31)
|
|
37
|
+
with open(path, "rb") as f:
|
|
38
|
+
while chunk := f.read(1 << 20):
|
|
39
|
+
n += len(d.decompress(chunk))
|
|
40
|
+
return n
|
|
41
|
+
if impl == "read-only": # floor for uncompressed: just read the bytes
|
|
42
|
+
n = 0
|
|
43
|
+
with open(path, "rb") as f:
|
|
44
|
+
while chunk := f.read(1 << 20):
|
|
45
|
+
n += len(chunk)
|
|
46
|
+
return n
|
|
47
|
+
raise SystemExit(f"unknown impl {impl}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ru0 = resource.getrusage(resource.RUSAGE_SELF)
|
|
51
|
+
t0 = time.perf_counter()
|
|
52
|
+
out = run()
|
|
53
|
+
wall = time.perf_counter() - t0
|
|
54
|
+
ru1 = resource.getrusage(resource.RUSAGE_SELF)
|
|
55
|
+
cpu = (ru1.ru_utime - ru0.ru_utime) + (ru1.ru_stime - ru0.ru_stime)
|
|
56
|
+
print(json.dumps({"impl": impl, "wall": wall, "cpu": cpu, "rss_mb": ru1.ru_maxrss / 1e6, "imp": t_imp, "out": out}))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CodSpeed benchmarks: small, deterministic inputs (the simulator is slow, and counts must
|
|
2
|
+
be stable between runs). Run with `pytest benchmarks/test_perf.py --codspeed`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from nanofastq import read_fastq
|
|
12
|
+
|
|
13
|
+
QUALS = "".join(chr(33 + q) for q in range(41))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_fastq(n_reads: int, read_len: int, *, utf8_headers: bool = False) -> bytes:
|
|
17
|
+
rng = random.Random(0) # fixed seed: identical bytes on every run
|
|
18
|
+
out = []
|
|
19
|
+
for i in range(n_reads):
|
|
20
|
+
seq = "".join(rng.choices("ACGT", k=read_len))
|
|
21
|
+
qual = "".join(rng.choices(QUALS, k=read_len))
|
|
22
|
+
name = f"réad{i}" if utf8_headers else f"read{i}"
|
|
23
|
+
out.append(f"@{name} lane=1 sample=a\n{seq}\n+\n{qual}\n")
|
|
24
|
+
return "".join(out).encode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# name -> (reads, read length, non-ASCII headers, gzip)
|
|
28
|
+
CASES = {
|
|
29
|
+
"short-plain": (20_000, 36, False, False),
|
|
30
|
+
"short-gz": (20_000, 36, False, True),
|
|
31
|
+
"long-plain": (200, 10_000, False, False),
|
|
32
|
+
"long-gz": (200, 10_000, False, True),
|
|
33
|
+
"short-utf8-headers": (20_000, 36, True, False), # exercises the strict UTF-8 fallback
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture(scope="session")
|
|
38
|
+
def files(tmp_path_factory):
|
|
39
|
+
d = tmp_path_factory.mktemp("fastq")
|
|
40
|
+
paths = {}
|
|
41
|
+
for name, (n, length, utf8, gz) in CASES.items():
|
|
42
|
+
data = make_fastq(n, length, utf8_headers=utf8)
|
|
43
|
+
p = d / (f"{name}.fastq.gz" if gz else f"{name}.fastq")
|
|
44
|
+
p.write_bytes(gzip.compress(data, 1, mtime=0) if gz else data)
|
|
45
|
+
paths[name] = p
|
|
46
|
+
return paths
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("case", CASES)
|
|
50
|
+
def test_read_table(benchmark, files, case):
|
|
51
|
+
path = files[case]
|
|
52
|
+
n_reads = CASES[case][0]
|
|
53
|
+
assert benchmark(lambda: pa.table(read_fastq(path)).num_rows) == n_reads
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_stream_small_batches(benchmark, files):
|
|
57
|
+
"""Per-batch overhead: many small batches instead of a few large ones."""
|
|
58
|
+
path = files["short-plain"]
|
|
59
|
+
|
|
60
|
+
def run():
|
|
61
|
+
reader = pa.RecordBatchReader.from_stream(read_fastq(path, batch_size=512))
|
|
62
|
+
return sum(len(b) for b in reader)
|
|
63
|
+
|
|
64
|
+
assert benchmark(run) == CASES["short-plain"][0]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "scikit_build_core.build"
|
|
3
|
+
requires = [
|
|
4
|
+
"nanobind>=2",
|
|
5
|
+
"scikit-build-core>=0.10",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "nanofastq"
|
|
10
|
+
description = "Tiny, dependency-free FASTQ reader that exposes the Arrow PyCapsule stream interface."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Programming Language :: Python :: 3.15",
|
|
19
|
+
]
|
|
20
|
+
dynamic = [ "version" ]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"duckdb",
|
|
26
|
+
"mypy",
|
|
27
|
+
"polars",
|
|
28
|
+
"pyarrow",
|
|
29
|
+
"pyarrow-stubs>=20.0.0.20260819",
|
|
30
|
+
"pytest",
|
|
31
|
+
"pytest-codspeed>=5.0.4",
|
|
32
|
+
"ty",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Version comes from the latest git tag (e.g. v0.1.0); see "Releasing" in the README.
|
|
36
|
+
[tool.setuptools_scm]
|
|
37
|
+
|
|
38
|
+
[tool.scikit-build]
|
|
39
|
+
[tool.scikit-build.cmake]
|
|
40
|
+
build-type = "Release"
|
|
41
|
+
version = ">=3.28"
|
|
42
|
+
[tool.scikit-build.sdist]
|
|
43
|
+
exclude = [ ".github" ]
|
|
44
|
+
[tool.scikit-build.wheel]
|
|
45
|
+
install-dir = "nanofastq"
|
|
46
|
+
py-api = "cp312"
|
|
47
|
+
packages = [ "src/nanofastq" ]
|
|
48
|
+
[tool.scikit-build.metadata.version]
|
|
49
|
+
provider = "scikit_build_core.metadata.setuptools_scm"
|
|
50
|
+
|
|
51
|
+
[tool.uv]
|
|
52
|
+
cache-keys = [
|
|
53
|
+
{ git = { commit = true, tags = true } },
|
|
54
|
+
{ file = "pyproject.toml" },
|
|
55
|
+
{ file = "CMakeLists.txt" },
|
|
56
|
+
{ file = "src/**/*.{c,h,cc}" },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.cibuildwheel]
|
|
60
|
+
# One abi3 wheel (cp312-abi3) per platform covers every supported Python.
|
|
61
|
+
build = "cp312-*"
|
|
62
|
+
skip = "*-win32 *_i686"
|
|
63
|
+
test-command = "pytest {project}/tests"
|
|
64
|
+
test-requires = [ "pytest", "pyarrow", "polars", "duckdb" ]
|
|
65
|
+
[[tool.cibuildwheel.overrides]]
|
|
66
|
+
# pyarrow has no musllinux wheels, so only smoke-test the import there.
|
|
67
|
+
select = "*-musllinux*"
|
|
68
|
+
test-command = "python -c 'import nanofastq'"
|
|
69
|
+
test-requires = []
|
|
70
|
+
|
|
71
|
+
[tool.pyproject-fmt]
|
|
72
|
+
table_format = "long"
|
|
73
|
+
|
|
74
|
+
[tool.pytest]
|
|
75
|
+
minversion = "9"
|
|
76
|
+
testpaths = [ "tests" ]
|
|
77
|
+
|
|
78
|
+
[tool.tox]
|
|
79
|
+
env_list = [
|
|
80
|
+
{ prefix = "3.", start = 12, stop = 15 },
|
|
81
|
+
"type",
|
|
82
|
+
]
|
|
83
|
+
[tool.tox.env.type]
|
|
84
|
+
description = "type check with ty"
|
|
85
|
+
# ty reads src/ and the _core.pyi stub directly, so nothing needs compiling.
|
|
86
|
+
package = "skip"
|
|
87
|
+
dependency_groups = [ "dev" ]
|
|
88
|
+
commands = [
|
|
89
|
+
[ "mypy", "src", "tests" ],
|
|
90
|
+
[ "ty", "check", "src", "tests" ],
|
|
91
|
+
]
|
|
92
|
+
[tool.tox.env_run_base]
|
|
93
|
+
description = "run tests on Python {py_dot_ver}"
|
|
94
|
+
dependency_groups = [ "dev" ]
|
|
95
|
+
commands = [ [ "pytest", { replace = "posargs", extend = true, substitute = true } ] ]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#include "fastq.h"
|
|
2
|
+
|
|
3
|
+
#include <errno.h>
|
|
4
|
+
#include <stdint.h>
|
|
5
|
+
#include <stdio.h>
|
|
6
|
+
#include <stdlib.h>
|
|
7
|
+
#include <string.h>
|
|
8
|
+
#include <zlib.h>
|
|
9
|
+
|
|
10
|
+
#define READ_CHUNK (1 << 20)
|
|
11
|
+
#define NO_PIN ((size_t)-1)
|
|
12
|
+
|
|
13
|
+
struct FastqState {
|
|
14
|
+
gzFile file;
|
|
15
|
+
int64_t batch_size;
|
|
16
|
+
int64_t record_no;
|
|
17
|
+
int done;
|
|
18
|
+
// line reader: [pos, end) holds unconsumed bytes in buf
|
|
19
|
+
char* buf;
|
|
20
|
+
size_t cap, pos, end;
|
|
21
|
+
// Start of the record being parsed, or NO_PIN. Buffer compaction keeps bytes from
|
|
22
|
+
// here on so the record's earlier lines stay addressable while later ones are read.
|
|
23
|
+
size_t pin;
|
|
24
|
+
// Error found after good rows were already built: reported on the next get_next.
|
|
25
|
+
int pending_rc;
|
|
26
|
+
struct ArrowSchema schema;
|
|
27
|
+
char error[256];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Records an error message and returns a negative errno-style code.
|
|
31
|
+
static int Fail(struct FastqState* s, const char* fmt, long long n) {
|
|
32
|
+
snprintf(s->error, sizeof(s->error), fmt, n);
|
|
33
|
+
return -EINVAL;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Strict UTF-8 check (rejects overlongs, surrogates and code points > U+10FFFF).
|
|
37
|
+
// Has an 8-bytes-at-a-time fast path since FASTQ is almost always pure ASCII.
|
|
38
|
+
static int IsValidUtf8(const char* data, size_t n) {
|
|
39
|
+
const unsigned char* p = (const unsigned char*)data;
|
|
40
|
+
const unsigned char* end = p + n;
|
|
41
|
+
while (p < end) {
|
|
42
|
+
if (end - p >= 8) {
|
|
43
|
+
uint64_t w;
|
|
44
|
+
memcpy(&w, p, 8);
|
|
45
|
+
if ((w & 0x8080808080808080ULL) == 0) {
|
|
46
|
+
p += 8;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
unsigned char c = *p;
|
|
51
|
+
if (c < 0x80) {
|
|
52
|
+
p++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
size_t need;
|
|
56
|
+
unsigned char lo = 0x80, hi = 0xBF; // allowed range of the 2nd byte
|
|
57
|
+
if (c >= 0xC2 && c <= 0xDF) {
|
|
58
|
+
need = 1;
|
|
59
|
+
} else if (c >= 0xE0 && c <= 0xEF) {
|
|
60
|
+
need = 2;
|
|
61
|
+
if (c == 0xE0) lo = 0xA0;
|
|
62
|
+
if (c == 0xED) hi = 0x9F;
|
|
63
|
+
} else if (c >= 0xF0 && c <= 0xF4) {
|
|
64
|
+
need = 3;
|
|
65
|
+
if (c == 0xF0) lo = 0x90;
|
|
66
|
+
if (c == 0xF4) hi = 0x8F;
|
|
67
|
+
} else {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if ((size_t)(end - p) <= need) return 0;
|
|
71
|
+
if (p[1] < lo || p[1] > hi) return 0;
|
|
72
|
+
for (size_t i = 2; i <= need; i++) {
|
|
73
|
+
if ((p[i] & 0xC0) != 0x80) return 0;
|
|
74
|
+
}
|
|
75
|
+
p += need + 1;
|
|
76
|
+
}
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// True if every byte is < 0x80. Written as a branch-free OR-reduction so the compiler
|
|
81
|
+
// can vectorize it.
|
|
82
|
+
static int IsAscii(const char* data, size_t n) {
|
|
83
|
+
uint64_t acc = 0;
|
|
84
|
+
size_t i = 0;
|
|
85
|
+
for (; i + 8 <= n; i += 8) {
|
|
86
|
+
uint64_t w;
|
|
87
|
+
memcpy(&w, data + i, 8);
|
|
88
|
+
acc |= w;
|
|
89
|
+
}
|
|
90
|
+
unsigned char tail = 0;
|
|
91
|
+
for (; i < n; i++) tail |= (unsigned char)data[i];
|
|
92
|
+
return ((acc & 0x8080808080808080ULL) | (tail & 0x80)) == 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns 1 with the line's offset into buf and length (without the trailing \n or
|
|
96
|
+
// \r\n), 0 on EOF, -1 on error. Offsets, unlike pointers, survive later calls as long
|
|
97
|
+
// as they are taken relative to s->pin.
|
|
98
|
+
static int NextLine(struct FastqState* s, size_t* off, size_t* len) {
|
|
99
|
+
for (;;) {
|
|
100
|
+
char* nl = s->pos < s->end ? memchr(s->buf + s->pos, '\n', s->end - s->pos) : NULL;
|
|
101
|
+
if (nl) {
|
|
102
|
+
size_t start = s->pos;
|
|
103
|
+
size_t n = (size_t)(nl - (s->buf + start));
|
|
104
|
+
s->pos = start + n + 1;
|
|
105
|
+
if (n > 0 && s->buf[start + n - 1] == '\r') n--;
|
|
106
|
+
*off = start;
|
|
107
|
+
*len = n;
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
// Need more data: compact (keeping the pinned record), grow, then refill.
|
|
111
|
+
size_t from = s->pin < s->pos ? s->pin : s->pos;
|
|
112
|
+
if (from > 0) {
|
|
113
|
+
memmove(s->buf, s->buf + from, s->end - from);
|
|
114
|
+
s->end -= from;
|
|
115
|
+
s->pos -= from;
|
|
116
|
+
if (s->pin != NO_PIN) s->pin -= from;
|
|
117
|
+
}
|
|
118
|
+
if (s->cap - s->end < READ_CHUNK) {
|
|
119
|
+
size_t new_cap = s->cap * 2;
|
|
120
|
+
char* nb = realloc(s->buf, new_cap);
|
|
121
|
+
if (!nb) {
|
|
122
|
+
snprintf(s->error, sizeof(s->error), "out of memory");
|
|
123
|
+
return -1;
|
|
124
|
+
}
|
|
125
|
+
s->buf = nb;
|
|
126
|
+
s->cap = new_cap;
|
|
127
|
+
}
|
|
128
|
+
int got = gzread(s->file, s->buf + s->end, (unsigned)(s->cap - s->end));
|
|
129
|
+
if (got < 0) {
|
|
130
|
+
int zerr;
|
|
131
|
+
snprintf(s->error, sizeof(s->error), "read error: %s", gzerror(s->file, &zerr));
|
|
132
|
+
return -1;
|
|
133
|
+
}
|
|
134
|
+
if (got == 0) {
|
|
135
|
+
if (s->pos == s->end) return 0;
|
|
136
|
+
// final line without trailing newline
|
|
137
|
+
size_t start = s->pos, n = s->end - s->pos;
|
|
138
|
+
s->pos = s->end;
|
|
139
|
+
if (s->buf[start + n - 1] == '\r') n--;
|
|
140
|
+
*off = start;
|
|
141
|
+
*len = n;
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
s->end += (size_t)got;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
struct Record {
|
|
149
|
+
const char *name, *desc, *seq, *qual; // desc is NULL when the header has none
|
|
150
|
+
size_t name_len, desc_len, seq_len; // qual has the same length as seq
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Reads and fully validates one record before anything is appended to Arrow.
|
|
154
|
+
// Returns 1 with `r` filled in (pointers valid until the next NextLine call),
|
|
155
|
+
// 0 on EOF, or a negative errno-style code with s->error set.
|
|
156
|
+
static int ParseRecordLines(struct FastqState* s, struct Record* r) {
|
|
157
|
+
size_t rel[4], len[4], off; // rel[i]: offset of line i relative to s->pin
|
|
158
|
+
int got;
|
|
159
|
+
|
|
160
|
+
for (;;) {
|
|
161
|
+
s->pin = s->pos;
|
|
162
|
+
if ((got = NextLine(s, &off, &len[0])) < 0) return -EIO;
|
|
163
|
+
if (got == 0) return 0;
|
|
164
|
+
if (len[0] > 0) break; // tolerate blank lines between records
|
|
165
|
+
}
|
|
166
|
+
rel[0] = off - s->pin;
|
|
167
|
+
s->record_no++;
|
|
168
|
+
if (s->buf[off] != '@') {
|
|
169
|
+
return Fail(s, "record %lld: header does not start with '@'", s->record_no);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
static const char* const truncated[3] = {
|
|
173
|
+
"record %lld: truncated after header",
|
|
174
|
+
"record %lld: missing '+' separator line",
|
|
175
|
+
"record %lld: truncated before quality line",
|
|
176
|
+
};
|
|
177
|
+
for (int i = 1; i < 4; i++) {
|
|
178
|
+
if ((got = NextLine(s, &off, &len[i])) < 0) return -EIO;
|
|
179
|
+
if (got == 0) return Fail(s, truncated[i - 1], s->record_no);
|
|
180
|
+
rel[i] = off - s->pin;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const char* base = s->buf + s->pin;
|
|
184
|
+
const char* header = base + rel[0];
|
|
185
|
+
const char* plus = base + rel[2];
|
|
186
|
+
if (len[2] == 0 || plus[0] != '+') return Fail(s, truncated[1], s->record_no);
|
|
187
|
+
if (len[3] != len[1]) {
|
|
188
|
+
return Fail(s, "record %lld: sequence and quality lengths differ", s->record_no);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
r->name = header + 1;
|
|
192
|
+
r->name_len = len[0] - 1;
|
|
193
|
+
r->desc = NULL;
|
|
194
|
+
r->desc_len = 0;
|
|
195
|
+
for (size_t i = 0; i < r->name_len; i++) {
|
|
196
|
+
if (r->name[i] == ' ' || r->name[i] == '\t') {
|
|
197
|
+
r->desc = r->name + i + 1;
|
|
198
|
+
r->desc_len = r->name_len - i - 1;
|
|
199
|
+
r->name_len = i;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
r->seq = base + rel[1];
|
|
204
|
+
r->seq_len = len[1];
|
|
205
|
+
r->qual = base + rel[3];
|
|
206
|
+
|
|
207
|
+
// The four lines are contiguous in the buffer, so one pass over the whole record
|
|
208
|
+
// settles the (overwhelmingly common) pure-ASCII case; only otherwise check per field.
|
|
209
|
+
if (IsAscii(base, rel[3] + len[3])) return 1;
|
|
210
|
+
if (!IsValidUtf8(r->name, r->name_len) || (r->desc && !IsValidUtf8(r->desc, r->desc_len))) {
|
|
211
|
+
return Fail(s, "record %lld: header is not valid UTF-8", s->record_no);
|
|
212
|
+
}
|
|
213
|
+
if (!IsValidUtf8(r->seq, r->seq_len)) {
|
|
214
|
+
return Fail(s, "record %lld: sequence is not valid UTF-8", s->record_no);
|
|
215
|
+
}
|
|
216
|
+
if (!IsValidUtf8(r->qual, r->seq_len)) {
|
|
217
|
+
return Fail(s, "record %lld: quality scores are not valid UTF-8", s->record_no);
|
|
218
|
+
}
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Appends one value to a non-nullable utf8 column by writing its offset and data buffers
|
|
223
|
+
// directly. This skips ArrowArrayAppendString's type dispatch and validity-bitmap handling,
|
|
224
|
+
// which are pure overhead for columns that can never hold a null (no bitmap is allocated).
|
|
225
|
+
static inline int AppendNonNull(struct ArrowArray* col, const char* data, size_t n) {
|
|
226
|
+
struct ArrowBuffer* offsets = ArrowArrayBuffer(col, 1);
|
|
227
|
+
struct ArrowBuffer* values = ArrowArrayBuffer(col, 2);
|
|
228
|
+
if (values->size_bytes + (int64_t)n > INT32_MAX) return EOVERFLOW;
|
|
229
|
+
int rc = ArrowBufferReserve(offsets, (int64_t)sizeof(int32_t));
|
|
230
|
+
if (rc != NANOARROW_OK) return rc;
|
|
231
|
+
if ((rc = ArrowBufferReserve(values, (int64_t)n)) != NANOARROW_OK) return rc;
|
|
232
|
+
memcpy(values->data + values->size_bytes, data, n);
|
|
233
|
+
values->size_bytes += (int64_t)n;
|
|
234
|
+
int32_t end = (int32_t)values->size_bytes;
|
|
235
|
+
memcpy(offsets->data + offsets->size_bytes, &end, sizeof(end));
|
|
236
|
+
offsets->size_bytes += (int64_t)sizeof(end);
|
|
237
|
+
col->length++;
|
|
238
|
+
return NANOARROW_OK;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
static int AppendRecord(struct ArrowArray* array, const struct Record* r) {
|
|
242
|
+
int rc;
|
|
243
|
+
if ((rc = AppendNonNull(array->children[0], r->name, r->name_len)) != NANOARROW_OK) return rc;
|
|
244
|
+
if (r->desc) {
|
|
245
|
+
struct ArrowStringView dv = {r->desc, (int64_t)r->desc_len};
|
|
246
|
+
rc = ArrowArrayAppendString(array->children[1], dv);
|
|
247
|
+
} else {
|
|
248
|
+
rc = ArrowArrayAppendNull(array->children[1], 1);
|
|
249
|
+
}
|
|
250
|
+
if (rc != NANOARROW_OK) return rc;
|
|
251
|
+
if ((rc = AppendNonNull(array->children[2], r->seq, r->seq_len)) != NANOARROW_OK) return rc;
|
|
252
|
+
if ((rc = AppendNonNull(array->children[3], r->qual, r->seq_len)) != NANOARROW_OK) return rc;
|
|
253
|
+
// Equivalent to ArrowArrayFinishElement for a struct; the children's lengths are
|
|
254
|
+
// validated once per batch by ArrowArrayFinishBuildingDefault.
|
|
255
|
+
array->length++;
|
|
256
|
+
return NANOARROW_OK;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
static int GetSchema(struct ArrowArrayStream* stream, struct ArrowSchema* out) {
|
|
260
|
+
struct FastqState* s = stream->private_data;
|
|
261
|
+
return ArrowSchemaDeepCopy(&s->schema, out);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
static int GetNext(struct ArrowArrayStream* stream, struct ArrowArray* out) {
|
|
265
|
+
struct FastqState* s = stream->private_data;
|
|
266
|
+
out->release = NULL;
|
|
267
|
+
if (s->pending_rc) {
|
|
268
|
+
int rc = s->pending_rc;
|
|
269
|
+
s->pending_rc = 0;
|
|
270
|
+
s->done = 1;
|
|
271
|
+
return rc;
|
|
272
|
+
}
|
|
273
|
+
if (s->done) return 0;
|
|
274
|
+
|
|
275
|
+
struct ArrowArray array;
|
|
276
|
+
struct ArrowError nerr;
|
|
277
|
+
int rc = ArrowArrayInitFromSchema(&array, &s->schema, &nerr);
|
|
278
|
+
if (rc != NANOARROW_OK) {
|
|
279
|
+
snprintf(s->error, sizeof(s->error), "%.200s", nerr.message);
|
|
280
|
+
return rc;
|
|
281
|
+
}
|
|
282
|
+
if ((rc = ArrowArrayStartAppending(&array)) != NANOARROW_OK) goto fail;
|
|
283
|
+
|
|
284
|
+
int64_t n = 0;
|
|
285
|
+
while (n < s->batch_size) {
|
|
286
|
+
struct Record rec;
|
|
287
|
+
int got = ParseRecordLines(s, &rec);
|
|
288
|
+
if (got == 0) {
|
|
289
|
+
s->done = 1;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
if (got < 0) {
|
|
293
|
+
s->pin = NO_PIN;
|
|
294
|
+
if (n == 0) {
|
|
295
|
+
s->done = 1;
|
|
296
|
+
rc = -got;
|
|
297
|
+
goto fail;
|
|
298
|
+
}
|
|
299
|
+
// Hand back the good rows now; the error surfaces on the next call.
|
|
300
|
+
s->pending_rc = -got;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
rc = AppendRecord(&array, &rec);
|
|
304
|
+
s->pin = NO_PIN; // the record's bytes were copied into Arrow buffers
|
|
305
|
+
if (rc != NANOARROW_OK) goto fail;
|
|
306
|
+
n++;
|
|
307
|
+
}
|
|
308
|
+
s->pin = NO_PIN;
|
|
309
|
+
|
|
310
|
+
if (n == 0) {
|
|
311
|
+
array.release(&array);
|
|
312
|
+
return 0; // end of stream: out->release stays NULL
|
|
313
|
+
}
|
|
314
|
+
if ((rc = ArrowArrayFinishBuildingDefault(&array, &nerr)) != NANOARROW_OK) {
|
|
315
|
+
snprintf(s->error, sizeof(s->error), "%.200s", nerr.message);
|
|
316
|
+
array.release(&array);
|
|
317
|
+
return rc;
|
|
318
|
+
}
|
|
319
|
+
*out = array;
|
|
320
|
+
return 0;
|
|
321
|
+
|
|
322
|
+
fail:
|
|
323
|
+
if (!s->error[0]) snprintf(s->error, sizeof(s->error), "failed to build batch (%d)", rc);
|
|
324
|
+
array.release(&array);
|
|
325
|
+
return rc;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
static const char* GetLastError(struct ArrowArrayStream* stream) {
|
|
329
|
+
struct FastqState* s = stream->private_data;
|
|
330
|
+
return s->error[0] ? s->error : NULL;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
static void Release(struct ArrowArrayStream* stream) {
|
|
334
|
+
struct FastqState* s = stream->private_data;
|
|
335
|
+
if (s->file) gzclose(s->file);
|
|
336
|
+
if (s->schema.release) s->schema.release(&s->schema);
|
|
337
|
+
free(s->buf);
|
|
338
|
+
free(s);
|
|
339
|
+
stream->release = NULL;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
int NanoFastqOpen(const char* path, int64_t batch_size, struct ArrowArrayStream* out,
|
|
343
|
+
struct ArrowError* err) {
|
|
344
|
+
if (batch_size <= 0) {
|
|
345
|
+
ArrowErrorSet(err, "batch_size must be positive");
|
|
346
|
+
return EINVAL;
|
|
347
|
+
}
|
|
348
|
+
struct FastqState* s = calloc(1, sizeof(*s));
|
|
349
|
+
if (!s) return ENOMEM;
|
|
350
|
+
s->batch_size = batch_size;
|
|
351
|
+
s->pin = NO_PIN;
|
|
352
|
+
s->cap = 2 * READ_CHUNK;
|
|
353
|
+
s->buf = malloc(s->cap);
|
|
354
|
+
s->file = gzopen(path, "rb"); // transparently reads uncompressed files too
|
|
355
|
+
if (!s->buf || !s->file) {
|
|
356
|
+
ArrowErrorSet(err, "cannot open '%s'", path);
|
|
357
|
+
if (s->file) gzclose(s->file);
|
|
358
|
+
free(s->buf);
|
|
359
|
+
free(s);
|
|
360
|
+
return ENOENT;
|
|
361
|
+
}
|
|
362
|
+
gzbuffer(s->file, 1 << 18);
|
|
363
|
+
|
|
364
|
+
ArrowSchemaInit(&s->schema);
|
|
365
|
+
int rc = ArrowSchemaSetTypeStruct(&s->schema, 4);
|
|
366
|
+
static const char* names[4] = {"name", "description", "sequence", "quality_scores"};
|
|
367
|
+
for (int i = 0; rc == NANOARROW_OK && i < 4; i++) {
|
|
368
|
+
rc = ArrowSchemaSetType(s->schema.children[i], NANOARROW_TYPE_STRING);
|
|
369
|
+
if (rc == NANOARROW_OK) rc = ArrowSchemaSetName(s->schema.children[i], names[i]);
|
|
370
|
+
if (rc == NANOARROW_OK && i != 1) s->schema.children[i]->flags &= ~ARROW_FLAG_NULLABLE;
|
|
371
|
+
}
|
|
372
|
+
if (rc != NANOARROW_OK) {
|
|
373
|
+
ArrowErrorSet(err, "failed to build schema");
|
|
374
|
+
struct ArrowArrayStream tmp = {0};
|
|
375
|
+
tmp.private_data = s;
|
|
376
|
+
Release(&tmp);
|
|
377
|
+
return rc;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
out->get_schema = GetSchema;
|
|
381
|
+
out->get_next = GetNext;
|
|
382
|
+
out->get_last_error = GetLastError;
|
|
383
|
+
out->release = Release;
|
|
384
|
+
out->private_data = s;
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#ifndef NANOFASTQ_FASTQ_H
|
|
2
|
+
#define NANOFASTQ_FASTQ_H
|
|
3
|
+
|
|
4
|
+
#include "nanoarrow/nanoarrow.h"
|
|
5
|
+
|
|
6
|
+
#ifdef __cplusplus
|
|
7
|
+
extern "C" {
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
/// Open a (optionally gzip-compressed) FASTQ file and expose it as a stream
|
|
11
|
+
/// of record batches with schema:
|
|
12
|
+
/// name: utf8, description: utf8 (nullable), sequence: utf8, quality_scores: utf8
|
|
13
|
+
/// Returns 0 on success or an errno-style code (message written to `err`).
|
|
14
|
+
int NanoFastqOpen(const char* path, int64_t batch_size, struct ArrowArrayStream* out,
|
|
15
|
+
struct ArrowError* err);
|
|
16
|
+
|
|
17
|
+
#ifdef __cplusplus
|
|
18
|
+
}
|
|
19
|
+
#endif
|
|
20
|
+
|
|
21
|
+
#endif
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#include <nanobind/nanobind.h>
|
|
2
|
+
#include <nanobind/stl/string.h>
|
|
3
|
+
|
|
4
|
+
#include <cstdlib>
|
|
5
|
+
|
|
6
|
+
#include "fastq.h"
|
|
7
|
+
|
|
8
|
+
namespace nb = nanobind;
|
|
9
|
+
|
|
10
|
+
static void stream_capsule_destructor(PyObject* capsule) {
|
|
11
|
+
auto* stream = static_cast<ArrowArrayStream*>(PyCapsule_GetPointer(capsule, "arrow_array_stream"));
|
|
12
|
+
if (stream == nullptr) {
|
|
13
|
+
PyErr_Clear();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (stream->release != nullptr) stream->release(stream);
|
|
17
|
+
free(stream);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
NB_MODULE(_core, m) {
|
|
21
|
+
m.doc() = "Native FASTQ -> Arrow C stream reader";
|
|
22
|
+
m.def(
|
|
23
|
+
"open_stream",
|
|
24
|
+
[](const std::string& path, int64_t batch_size) {
|
|
25
|
+
auto* stream = static_cast<ArrowArrayStream*>(malloc(sizeof(ArrowArrayStream)));
|
|
26
|
+
ArrowError err{};
|
|
27
|
+
int rc = NanoFastqOpen(path.c_str(), batch_size, stream, &err);
|
|
28
|
+
if (rc != 0) {
|
|
29
|
+
free(stream);
|
|
30
|
+
throw nb::value_error(err.message);
|
|
31
|
+
}
|
|
32
|
+
PyObject* cap = PyCapsule_New(stream, "arrow_array_stream", stream_capsule_destructor);
|
|
33
|
+
if (!cap) {
|
|
34
|
+
stream->release(stream);
|
|
35
|
+
free(stream);
|
|
36
|
+
throw nb::python_error();
|
|
37
|
+
}
|
|
38
|
+
return nb::steal(cap);
|
|
39
|
+
},
|
|
40
|
+
nb::arg("path"), nb::arg("batch_size") = 65536);
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Tiny FASTQ reader exposing the Arrow PyCapsule stream interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from nanofastq import _core
|
|
8
|
+
|
|
9
|
+
__all__ = ["FastqReader", "read_fastq"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastqReader:
|
|
13
|
+
"""A lazily-read FASTQ file.
|
|
14
|
+
|
|
15
|
+
Implements ``__arrow_c_stream__`` so it can be passed directly to
|
|
16
|
+
pyarrow, polars, duckdb, pandas, etc. Each call opens the file afresh, so
|
|
17
|
+
consumers that probe the schema first (e.g. duckdb) work, and the object
|
|
18
|
+
can be scanned more than once.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: str | os.PathLike[str], batch_size: int = 65536) -> None:
|
|
22
|
+
self._path = os.fspath(path)
|
|
23
|
+
self._batch_size = batch_size
|
|
24
|
+
_core.open_stream(self._path, batch_size) # fail early if unreadable
|
|
25
|
+
|
|
26
|
+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
|
|
27
|
+
return _core.open_stream(self._path, self._batch_size)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_fastq(path: str | os.PathLike[str], batch_size: int = 65536) -> FastqReader:
|
|
31
|
+
return FastqReader(path, batch_size)
|
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pyarrow as pa
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nanofastq import read_fastq
|
|
8
|
+
|
|
9
|
+
DATA = b"""\
|
|
10
|
+
@r1 sample=a
|
|
11
|
+
ACGT
|
|
12
|
+
+
|
|
13
|
+
IIII
|
|
14
|
+
@r2
|
|
15
|
+
GG
|
|
16
|
+
+r2
|
|
17
|
+
#!
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_lines(p: Path, lines: list[str]):
|
|
22
|
+
p.write_text("\n".join(lines) + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_bytes(p: Path, lines: list[bytes]):
|
|
26
|
+
p.write_bytes(b"\n".join(lines) + b"\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(params=["plain", "gz"])
|
|
30
|
+
def fastq(request: pytest.FixtureRequest, tmp_path: Path) -> Path:
|
|
31
|
+
if request.param == "gz":
|
|
32
|
+
p = tmp_path / "x.fastq.gz"
|
|
33
|
+
p.write_bytes(gzip.compress(DATA))
|
|
34
|
+
else:
|
|
35
|
+
p = tmp_path / "x.fastq"
|
|
36
|
+
p.write_bytes(DATA)
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_roundtrip(fastq: Path):
|
|
41
|
+
t = pa.table(read_fastq(fastq))
|
|
42
|
+
assert t.to_pylist() == [
|
|
43
|
+
{
|
|
44
|
+
"name": "r1",
|
|
45
|
+
"description": "sample=a",
|
|
46
|
+
"sequence": "ACGT",
|
|
47
|
+
"quality_scores": "IIII",
|
|
48
|
+
},
|
|
49
|
+
{"name": "r2", "description": None, "sequence": "GG", "quality_scores": "#!"},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_small_batches(fastq):
|
|
54
|
+
reader = pa.RecordBatchReader.from_stream(read_fastq(fastq, batch_size=1))
|
|
55
|
+
assert [len(b) for b in reader] == [1, 1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_rescan(fastq):
|
|
59
|
+
r = read_fastq(fastq)
|
|
60
|
+
assert pa.table(r).equals(pa.table(r))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_polars_and_duckdb(fastq):
|
|
64
|
+
duckdb = pytest.importorskip("duckdb")
|
|
65
|
+
pl = pytest.importorskip("polars")
|
|
66
|
+
|
|
67
|
+
assert pl.DataFrame(read_fastq(fastq)).height == 2
|
|
68
|
+
r = read_fastq(fastq) # noqa: F841 (duckdb replacement scan)
|
|
69
|
+
assert duckdb.execute("select count(*) from r").fetchall() == [(2,)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_malformed(tmp_path: Path):
|
|
73
|
+
p = tmp_path / "bad.fastq"
|
|
74
|
+
write_lines(p, ["@r1", "ACGT", "+", "II"]) # quality too short
|
|
75
|
+
with pytest.raises(pa.ArrowInvalid, match="lengths differ"):
|
|
76
|
+
pa.table(read_fastq(p))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_missing_file(tmp_path: Path):
|
|
80
|
+
with pytest.raises(ValueError, match="cannot open"):
|
|
81
|
+
read_fastq(tmp_path / "nope.fastq")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_utf8_header_ok(tmp_path: Path):
|
|
85
|
+
p = tmp_path / "u.fastq"
|
|
86
|
+
p.write_bytes("@r\u00e9ad \u2603 \U0001f9ec\nAC\n+\nII\n".encode())
|
|
87
|
+
write_bytes(
|
|
88
|
+
p,
|
|
89
|
+
[
|
|
90
|
+
"@r\u00e9ad \u2603 \U0001f9ec".encode(),
|
|
91
|
+
b"AC",
|
|
92
|
+
b"+",
|
|
93
|
+
b"II",
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
t = pa.table(read_fastq(p))
|
|
97
|
+
assert t["name"].to_pylist() == ["r\u00e9ad"]
|
|
98
|
+
assert t["description"].to_pylist() == ["\u2603 \U0001f9ec"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
"bad",
|
|
103
|
+
[
|
|
104
|
+
b"\xff", # never valid
|
|
105
|
+
b"\xc0\x80", # overlong NUL
|
|
106
|
+
b"\xe0\x80\x80", # overlong
|
|
107
|
+
b"\xed\xa0\x80", # surrogate
|
|
108
|
+
b"\xf4\x90\x80\x80", # > U+10FFFF
|
|
109
|
+
b"\xe2\x98", # truncated
|
|
110
|
+
b"\xc3", # truncated at end of field
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
@pytest.mark.parametrize("where", ["header", "sequence", "quality"])
|
|
114
|
+
def test_invalid_utf8(tmp_path: Path, bad: bytes, where: str):
|
|
115
|
+
hdr, seq, qual = b"r1", b"A" * len(bad), b"I" * len(bad)
|
|
116
|
+
if where == "header":
|
|
117
|
+
hdr = b"r1 " + bad
|
|
118
|
+
elif where == "sequence":
|
|
119
|
+
seq = bad
|
|
120
|
+
else:
|
|
121
|
+
qual = bad
|
|
122
|
+
p = tmp_path / "bad.fastq"
|
|
123
|
+
write_bytes(
|
|
124
|
+
p,
|
|
125
|
+
[
|
|
126
|
+
b"@" + hdr,
|
|
127
|
+
seq,
|
|
128
|
+
b"+",
|
|
129
|
+
qual,
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
with pytest.raises(pa.ArrowInvalid, match="not valid UTF-8"):
|
|
133
|
+
pa.table(read_fastq(p))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_good_rows_before_error_are_kept(tmp_path: Path):
|
|
137
|
+
p = tmp_path / "partial.fastq"
|
|
138
|
+
write_lines(
|
|
139
|
+
p,
|
|
140
|
+
[
|
|
141
|
+
"@a",
|
|
142
|
+
"AC",
|
|
143
|
+
"+",
|
|
144
|
+
"II",
|
|
145
|
+
"@b",
|
|
146
|
+
"GG",
|
|
147
|
+
"+",
|
|
148
|
+
"##",
|
|
149
|
+
"@c",
|
|
150
|
+
"ACGT",
|
|
151
|
+
"+",
|
|
152
|
+
"II",
|
|
153
|
+
"@d",
|
|
154
|
+
"T",
|
|
155
|
+
"+",
|
|
156
|
+
"I",
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
reader = pa.RecordBatchReader.from_stream(read_fastq(p, batch_size=100))
|
|
160
|
+
first = reader.read_next_batch()
|
|
161
|
+
assert first.column("name").to_pylist() == ["a", "b"]
|
|
162
|
+
with pytest.raises(pa.ArrowInvalid, match="record 3: sequence and quality"):
|
|
163
|
+
reader.read_next_batch()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_error_in_first_row_of_batch(tmp_path: Path):
|
|
167
|
+
p = tmp_path / "first.fastq"
|
|
168
|
+
write_lines(
|
|
169
|
+
p,
|
|
170
|
+
[
|
|
171
|
+
"@a",
|
|
172
|
+
"AC",
|
|
173
|
+
"+",
|
|
174
|
+
"II",
|
|
175
|
+
"@b",
|
|
176
|
+
"GG",
|
|
177
|
+
"+",
|
|
178
|
+
"##",
|
|
179
|
+
"@c",
|
|
180
|
+
"ACGT",
|
|
181
|
+
"+",
|
|
182
|
+
"II",
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
reader = pa.RecordBatchReader.from_stream(read_fastq(p, batch_size=2))
|
|
186
|
+
assert len(reader.read_next_batch()) == 2
|
|
187
|
+
with pytest.raises(pa.ArrowInvalid, match="record 3"):
|
|
188
|
+
reader.read_next_batch()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_record_spanning_buffer_refills(tmp_path: Path):
|
|
192
|
+
# Lines far larger than the initial buffer force compaction/realloc mid-record.
|
|
193
|
+
n = 5_000_000
|
|
194
|
+
p = tmp_path / "big.fastq"
|
|
195
|
+
write_lines(
|
|
196
|
+
p,
|
|
197
|
+
[
|
|
198
|
+
"@big desc",
|
|
199
|
+
"A" * n,
|
|
200
|
+
"+",
|
|
201
|
+
"I" * n,
|
|
202
|
+
"@s",
|
|
203
|
+
"AC",
|
|
204
|
+
"+",
|
|
205
|
+
"II",
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
t = pa.table(read_fastq(p))
|
|
209
|
+
assert t["name"].to_pylist() == ["big", "s"]
|
|
210
|
+
|
|
211
|
+
seq0 = t["sequence"][0]
|
|
212
|
+
assert isinstance(seq0, pa.StringScalar)
|
|
213
|
+
assert len(seq0.as_py()) == n
|
|
214
|
+
|
|
215
|
+
qual1 = t["quality_scores"][1]
|
|
216
|
+
assert isinstance(qual1, pa.StringScalar)
|
|
217
|
+
assert qual1.as_py() == "II"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize(
|
|
221
|
+
("data", "msg"),
|
|
222
|
+
[
|
|
223
|
+
pytest.param(
|
|
224
|
+
["garbage"],
|
|
225
|
+
"does not start with '@'",
|
|
226
|
+
id="no-at-start",
|
|
227
|
+
),
|
|
228
|
+
pytest.param(
|
|
229
|
+
["@a"],
|
|
230
|
+
"truncated after header",
|
|
231
|
+
id="truncated-after-header",
|
|
232
|
+
),
|
|
233
|
+
pytest.param(
|
|
234
|
+
["@a", "AC"],
|
|
235
|
+
"missing '\\+'",
|
|
236
|
+
id="missing-plus-after-sequence",
|
|
237
|
+
),
|
|
238
|
+
pytest.param(
|
|
239
|
+
["@a", "AC", "x", "II"],
|
|
240
|
+
"missing '\\+'",
|
|
241
|
+
id="missing-plus-after-quality",
|
|
242
|
+
),
|
|
243
|
+
pytest.param(
|
|
244
|
+
["@a", "AC", "+"],
|
|
245
|
+
"truncated before quality",
|
|
246
|
+
id="truncated-before-quality",
|
|
247
|
+
),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
def test_structural_errors(tmp_path: Path, data: list[str], msg: str):
|
|
251
|
+
p = tmp_path / "s.fastq"
|
|
252
|
+
write_lines(p, data)
|
|
253
|
+
with pytest.raises(pa.ArrowInvalid, match=msg):
|
|
254
|
+
pa.table(read_fastq(p))
|