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.
@@ -0,0 +1,8 @@
1
+ uv.lock
2
+ .venv/
3
+ build/
4
+ *.so
5
+ __pycache__/
6
+ wheelhouse/
7
+ dist/
8
+ benchmarks/data/
@@ -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 .)
@@ -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
+ [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/edgarrmondragon/nanofastq)
16
+ [![Python Versions](https://img.shields.io/pypi/pyversions/nanofastq.svg?logo=python&logoColor=FFE873)](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
+ [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/edgarrmondragon/nanofastq)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/nanofastq.svg?logo=python&logoColor=FFE873)](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)
@@ -0,0 +1,5 @@
1
+ def open_stream(path: str, batch_size: int = 65536) -> object:
2
+ """Open a FASTQ file and return an ``arrow_array_stream`` PyCapsule.
3
+
4
+ Raises ``ValueError`` if the file cannot be opened or ``batch_size`` is not positive.
5
+ """
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))