Skip to main content

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}