gwseq_io/source/mod.rs
1//! Byte sources and sinks.
2//!
3//! [`ByteSource`] reads are *positioned* and take `&self`, so one handle
4//! serves every worker thread. That removes the need for a pool of handles —
5//! a checkout semaphore, worker threads, a promise per buffer — and leaves the
6//! part that earns its keep, the aligned LRU block cache, as the
7//! [`CachedSource`] decorator.
8//!
9//! Sources compose by wrapping:
10//!
11//! ```no_run
12//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
13//! use gwseq_io::source::{CachedSource, UrlSource};
14//!
15//! let url = "https://example.org/track.bigwig";
16//! let src = CachedSource::new(UrlSource::open(url)?, 1 << 20, 128);
17//! # let _ = src;
18//! # Ok(())
19//! # }
20//! ```
21
22use std::ops::Range;
23
24use bytes::Bytes;
25
26use crate::error::{Error, Result};
27
28mod cache;
29mod gzip;
30mod local;
31mod sink;
32#[cfg(feature = "url")]
33pub(crate) mod url;
34
35pub use cache::CachedSource;
36pub use gzip::{is_gzipped, open_text, GzipReader, TextInput};
37pub use local::LocalSource;
38pub use sink::{ByteSink, LocalSink, SinkSource};
39#[cfg(feature = "url")]
40pub use url::{http_get_text, RetryPolicy, UrlSource};
41
42/// Recommended block size for the cache in front of a local file.
43pub const LOCAL_BLOCK_SIZE: u64 = 32 * 1024;
44/// Recommended block size for the cache in front of a URL: a range request has
45/// a fixed cost worth amortising over more bytes.
46pub const URL_BLOCK_SIZE: u64 = 1024 * 1024;
47/// Recommended cache capacity, in blocks.
48pub const MAX_BLOCKS: usize = 128;
49
50/// A random-access source of bytes.
51///
52/// `Send + Sync` and read by offset, so the readers hand `&dyn ByteSource` to
53/// every worker rather than checking a handle out of a pool.
54///
55/// `Debug` is a supertrait so that the readers, which hold an
56/// `Arc<dyn ByteSource>`, can derive it — and so a source turns up legibly in a
57/// failed assertion rather than as an opaque pointer.
58pub trait ByteSource: Send + Sync + std::fmt::Debug {
59 /// Path or URL this reads, for error messages.
60 fn path(&self) -> &str;
61
62 /// Total length in bytes.
63 fn len(&self) -> Result<u64>;
64
65 fn is_empty(&self) -> Result<bool> {
66 Ok(self.len()? == 0)
67 }
68
69 /// Read up to `len` bytes at `offset`, returning fewer at end of file.
70 fn read_at(&self, offset: u64, len: usize) -> Result<Bytes>;
71
72 /// Read exactly `len` bytes at `offset`; a short read is [`Error::Corrupt`].
73 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Bytes> {
74 let data = self.read_at(offset, len)?;
75 if data.len() != len {
76 return Err(Error::corrupt(
77 self.path(),
78 offset,
79 format!("wanted {len} bytes, got {}", data.len()),
80 ));
81 }
82 Ok(data)
83 }
84
85 /// Read from `offset` to end of file.
86 fn read_to_end(&self, offset: u64) -> Result<Bytes> {
87 let len = self.len()?.saturating_sub(offset);
88 self.read_at(offset, len as usize)
89 }
90
91 /// Announce ranges that are about to be read, so a source that can fetch
92 /// them together does. A no-op unless overridden.
93 ///
94 /// This is where read-ahead lives: the R-tree walk knows every leaf it is
95 /// about to visit before it visits any of them, and over HTTP that
96 /// knowledge is worth a multi-range request.
97 fn prefetch(&self, _ranges: &[Range<u64>]) {}
98
99 /// Release the underlying handle. Idempotent; reads after it fail with
100 /// [`Error::Closed`].
101 fn close(&self) {}
102}
103
104impl<T: ByteSource + ?Sized> ByteSource for std::sync::Arc<T> {
105 fn path(&self) -> &str {
106 (**self).path()
107 }
108 fn len(&self) -> Result<u64> {
109 (**self).len()
110 }
111 fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
112 (**self).read_at(offset, len)
113 }
114 fn prefetch(&self, ranges: &[Range<u64>]) {
115 (**self).prefetch(ranges)
116 }
117 fn close(&self) {
118 (**self).close()
119 }
120}
121
122/// True for a path a [`UrlSource`] handles.
123///
124/// HTTP and HTTPS, and nothing else. `ftp://` used to be routed here as well,
125/// which meant an FTP path was accepted, carried through the retry policy and
126/// then refused by `ureq` — an HTTP client — as an unsupported scheme. A path
127/// this library cannot read should say so at once, and "no such file" is the
128/// clearer thing to say about a scheme it does not speak.
129pub fn is_url(path: &str) -> bool {
130 let lower = path.to_ascii_lowercase();
131 lower.starts_with("http://") || lower.starts_with("https://")
132}
133
134/// Open `path` as a local file or a URL, wrapped in a block cache.
135///
136/// `block_size` and `max_blocks` of `None` take the recommended values, which
137/// differ by source kind — this is where the `file_buffer_size = -1` and
138/// `max_file_buffer_count = -1` of the Python API are resolved.
139pub fn open(
140 path: &str,
141 block_size: Option<u64>,
142 max_blocks: Option<usize>,
143) -> Result<std::sync::Arc<dyn ByteSource>> {
144 let max_blocks = max_blocks.unwrap_or(MAX_BLOCKS).max(1);
145 if is_url(path) {
146 #[cfg(feature = "url")]
147 {
148 let block_size = block_size.unwrap_or(URL_BLOCK_SIZE).max(1);
149 let source = UrlSource::open(path)?;
150 return Ok(std::sync::Arc::new(CachedSource::new(
151 source, block_size, max_blocks,
152 )));
153 }
154 #[cfg(not(feature = "url"))]
155 return Err(Error::Unsupported(format!(
156 "{path} is a URL and the `url` feature is off"
157 )));
158 }
159 let block_size = block_size.unwrap_or(LOCAL_BLOCK_SIZE).max(1);
160 let source = LocalSource::open(path)?;
161 Ok(std::sync::Arc::new(CachedSource::new(
162 source, block_size, max_blocks,
163 )))
164}
165
166/// An in-memory [`ByteSource`], for tests that want to hand a parser exact
167/// bytes rather than build a file.
168///
169/// Behind `cfg(test)` for this crate's own tests and behind the `fuzzing`
170/// feature for `fuzz/`, which is a separate crate and cannot see the first.
171/// Not part of the stable surface either way: a caller with bytes in hand
172/// wants `Cursor`, not this.
173#[cfg(any(test, feature = "fuzzing"))]
174pub mod testing {
175 use super::*;
176
177 #[derive(Debug)]
178 pub struct MemorySource {
179 data: Bytes,
180 path: String,
181 }
182
183 impl MemorySource {
184 pub fn new(data: impl Into<Bytes>) -> Self {
185 Self {
186 data: data.into(),
187 path: "memory".to_string(),
188 }
189 }
190
191 pub fn named(path: &str, data: impl Into<Bytes>) -> Self {
192 Self {
193 data: data.into(),
194 path: path.to_string(),
195 }
196 }
197 }
198
199 impl ByteSource for MemorySource {
200 fn path(&self) -> &str {
201 &self.path
202 }
203 fn len(&self) -> Result<u64> {
204 Ok(self.data.len() as u64)
205 }
206 fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
207 let start = (offset as usize).min(self.data.len());
208 let end = start.saturating_add(len).min(self.data.len());
209 Ok(self.data.slice(start..end))
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn urls_are_recognised_by_scheme_and_case() {
220 for path in [
221 "http://example.org/a.bigwig",
222 "HTTPS://example.org/a.bigwig",
223 ] {
224 assert!(is_url(path), "{path}");
225 }
226 for path in [
227 "/data/a.bigwig",
228 "a.bigwig",
229 "C:/data/a.bigwig",
230 "file:///data/a.bigwig",
231 "s3://bucket/a.bigwig",
232 // Routed here once, and refused by an HTTP client for its scheme
233 // after the retry policy had had its say. A scheme this library
234 // does not speak is not a URL as far as this is concerned.
235 "ftp://example.org/a.bigwig",
236 ] {
237 assert!(!is_url(path), "{path}");
238 }
239 }
240}