cloud_file/lib.rs
1#![warn(missing_docs)]
2#![warn(clippy::pedantic)]
3#![allow(
4 clippy::missing_panics_doc, // LATER: add panics docs
5 clippy::missing_errors_doc, // LATER: add errors docs
6 clippy::similar_names,
7 clippy::cast_possible_truncation,
8 clippy::cast_possible_wrap,
9 clippy::cast_sign_loss,
10 clippy::cast_lossless
11)]
12#![doc = include_str!("../README.md")]
13//! ## Main Functions
14//!
15//! | Function | Description |
16//! | -------- | ----------- |
17//! | [`CloudFile::new`](struct.CloudFile.html#method.new) | Use a URL string to specify a cloud file for reading. |
18//! | [`CloudFile::new_with_options`](struct.CloudFile.html#method.new_with_options) | Use a URL string and string options to specify a cloud file for reading. |
19//!
20//! ## URLs
21//!
22//! | Cloud Service | Example |
23//! | ------------- | ------- |
24//! | HTTP | `https://www.gutenberg.org/cache/epub/100/pg100.txt` |
25//! | local file | `file:///M:/data%20files/small.bed` |
26//! | AWS S3 | `s3://bedreader/v1/toydata.5chrom.bed` |
27//!
28//! Note: For local files, use the [`abs_path_to_url_string`](fn.abs_path_to_url_string.html) function to properly encode into a URL.
29//!
30//! ## Options
31//!
32//! | Cloud Service | Details | Example |
33//! | -------- | ------- | ----------- |
34//! | HTTP | [`ClientConfigKey`](https://docs.rs/object_store/latest/object_store/enum.ClientConfigKey.html#variant.Timeout) | `[("timeout", "30s")]` |
35//! | local file | *none* | |
36//! | AWS S3 | [`AmazonS3ConfigKey`](https://docs.rs/object_store/latest/object_store/aws/enum.AmazonS3ConfigKey.html) | `[("aws_region", "us-west-2"), ("aws_access_key_id",` ...`), ("aws_secret_access_key",` ...`)]` |
37//! | Azure | [`AzureConfigKey`](https://docs.rs/object_store/latest/object_store/azure/enum.AzureConfigKey.html) | |
38//! | Google | [`GoogleConfigKey`](https://docs.rs/object_store/latest/object_store/gcp/enum.GoogleConfigKey.html) | |
39//!
40//!
41//! ## High-Level [`CloudFile`](struct.CloudFile.html) Methods
42//!
43//! | Method | Retrieves |
44//! |-------------------------------|-------------------------------------------------------------------|
45//! | [`stream_chunks`](struct.CloudFile.html#method.stream_chunks) | File contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) |
46//! | [`stream_line_chunks`](struct.CloudFile.html#method.stream_line_chunks) | File contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html), each containing one or more whole lines |
47//! | [`read_all`](struct.CloudFile.html#method.read_all) | Whole file contents as an in-memory [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) |
48//! | [`read_range`](struct.CloudFile.html#method.read_range) | [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range |
49//! | [`read_ranges`](struct.CloudFile.html#method.read_ranges) | `Vec` of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from specified ranges |
50//! | [`read_range_and_file_size`](struct.CloudFile.html#method.read_range_and_file_size) | [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range & the file's size |
51//! | [`read_file_size`](struct.CloudFile.html#method.read_file_size) | Size of the file |
52//! | [`count_lines`](struct.CloudFile.html#method.count_lines) | Number of lines in the file |
53//!
54//! Additional methods:
55//!
56//! | Method | Description |
57//! |-------------------------------|-------------------------------------------------------------------|
58//! | [`clone`](struct.CloudFile.html#method.clone) | Clone the [`CloudFile`](struct.CloudFile.html) instance. Efficient by design. |
59//! | [`set_extension`](struct.CloudFile.html#method.set_extension) | Change the [`CloudFile`](struct.CloudFile.html)'s file extension (in place). |
60//!
61//! ## Low-Level [`CloudFile`](struct.CloudFile.html) Methods
62//!
63//! | Method | Description |
64//! | -------- | ----------- |
65//! | [`get`](struct.CloudFile.html#method.get) | Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get) crate's `get` method. |
66//! | [`get_opts`](struct.CloudFile.html#method.get_opts) | Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get_opts) crate's `get_opts` method. |
67//!
68//! ## Lowest-Level [`CloudFile`](struct.CloudFile.html) Methods
69//!
70//! You can call any method from the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html) crate. For example, here we
71//! use [`head`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#tymethod.head) to get the metadata for a file and the last_modified time.
72//!
73//! ```
74//! use cloud_file::CloudFile;
75//! use object_store::ObjectStoreExt;
76//!
77//! # Runtime::new().unwrap().block_on(async {
78//! let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
79//! let cloud_file = CloudFile::new(url)?;
80//! let meta = cloud_file.cloud_service.head(&cloud_file.store_path).await?;
81//! let last_modified = meta.last_modified;
82//! println!("last_modified: {}", last_modified);
83//! assert_eq!(meta.size, 303);
84//! # Ok::<(), CloudFileError>(())}).unwrap();
85//! # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
86//! ```
87
88#[cfg(not(target_pointer_width = "64"))]
89compile_error!("This code requires a 64-bit target architecture.");
90
91use bytes::Bytes;
92use core::fmt;
93use futures_util::stream::BoxStream;
94use futures_util::TryStreamExt;
95use object_store::delimited::newline_delimited_stream;
96use object_store::http::HttpBuilder;
97#[doc(no_inline)]
98pub use object_store::path::Path as StorePath;
99use object_store::{GetOptions, GetRange, GetResult, ObjectStore, ObjectStoreExt};
100use std::ops::{Deref, Range};
101use std::path::Path;
102use std::sync::Arc;
103use thiserror::Error;
104use url::Url;
105
106#[derive(Debug)]
107/// The main struct representing the location of a file in the cloud.
108///
109/// It is constructed with [`CloudFile::new`](struct.CloudFile.html#method.new). It is, by design, cheap to clone.
110///
111/// Internally, it stores two pieces of information: the file's cloud service and the path to the file on that service.
112///
113/// # Examples
114///
115/// ```
116/// use cloud_file::CloudFile;
117///
118/// # Runtime::new().unwrap().block_on(async {
119/// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
120/// let cloud_file = CloudFile::new(url)?;
121/// assert_eq!(cloud_file.read_file_size().await?, 303);
122/// # Ok::<(), CloudFileError>(())}).unwrap();
123/// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
124/// ```
125pub struct CloudFile {
126 /// A cloud service, for example, Http, AWS S3, Azure, the local file system, etc.
127 /// Under the covers, it is an `Arc`-wrapped [`DynObjectStore`](struct.DynObjectStore.html).
128 /// The `DynObjectStore`, in turn, holds an [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html) from the
129 /// powerful [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate.
130 pub cloud_service: Arc<DynObjectStore>,
131 /// A path to a file on the cloud service.
132 /// Under the covers, `StorePath` is an alias for a [`Path`](https://docs.rs/object_store/latest/object_store/path/struct.Path.html)
133 /// in the [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate.
134 pub store_path: StorePath,
135}
136
137impl Clone for CloudFile {
138 fn clone(&self) -> Self {
139 CloudFile {
140 cloud_service: self.cloud_service.clone(),
141 store_path: self.store_path.clone(),
142 }
143 }
144}
145
146/// An empty set of cloud options
147///
148/// # Example
149/// ```
150/// use cloud_file::{EMPTY_OPTIONS, CloudFile};
151///
152/// # Runtime::new().unwrap().block_on(async {
153/// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
154/// let cloud_file = CloudFile::new_with_options(url, EMPTY_OPTIONS)?;
155/// assert_eq!(cloud_file.read_file_size().await?, 303);
156/// # Ok::<(), CloudFileError>(())}).unwrap();
157/// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
158/// ```
159pub const EMPTY_OPTIONS: [(&str, String); 0] = [];
160
161impl CloudFile {
162 /// Create a new [`CloudFile`] from a URL string.
163 ///
164 /// # Example
165 /// ```
166 /// use cloud_file::CloudFile;
167 ///
168 /// # Runtime::new().unwrap().block_on(async {
169 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
170 /// let cloud_file = CloudFile::new(url)?;
171 /// assert_eq!(cloud_file.read_file_size().await?, 303);
172 /// # Ok::<(), CloudFileError>(())}).unwrap();
173 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
174 /// ```
175 pub fn new(location: impl AsRef<str>) -> Result<CloudFile, CloudFileError> {
176 let location = location.as_ref();
177 let url = Url::parse(location)
178 .map_err(|e| CloudFileError::CannotParseUrl(location.to_string(), e.to_string()))?;
179
180 let (object_store, store_path): (DynObjectStore, StorePath) =
181 parse_url_opts_work_around(&url, EMPTY_OPTIONS)?;
182 let cloud_file = CloudFile {
183 cloud_service: Arc::new(object_store),
184 store_path,
185 };
186 Ok(cloud_file)
187 }
188
189 /// Create a new [`CloudFile`] from an [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html)
190 /// and a [`object_store::path::Path`](https://docs.rs/object_store/latest/object_store/path/struct.Path.html).
191 ///
192 /// # Example
193 ///
194 /// ```
195 /// use cloud_file::CloudFile;
196 /// use object_store::{http::HttpBuilder, path::Path as StorePath, ClientOptions};
197 /// use std::time::Duration;
198 ///
199 /// # Runtime::new().unwrap().block_on(async {
200 /// let client_options = ClientOptions::new().with_timeout(Duration::from_secs(30));
201 /// let http = HttpBuilder::new()
202 /// .with_url("https://raw.githubusercontent.com")
203 /// .with_client_options(client_options)
204 /// .build()?;
205 /// let store_path = StorePath::parse("fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed")?;
206 ///
207 /// let cloud_file = CloudFile::from_structs(http, store_path);
208 /// assert_eq!(cloud_file.read_file_size().await?, 303);
209 /// # Ok::<(), CloudFileError>(())}).unwrap();
210 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
211 /// ```
212
213 #[inline]
214 pub fn from_structs(store: impl ObjectStore, store_path: StorePath) -> Self {
215 CloudFile {
216 cloud_service: Arc::new(DynObjectStore(Box::new(store))),
217 store_path,
218 }
219 }
220
221 /// Create a new [`CloudFile`] from a URL string and options.
222 ///
223 /// # Example
224 /// ```
225 /// use cloud_file::CloudFile;
226 ///
227 /// # Runtime::new().unwrap().block_on(async {
228 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
229 /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
230 /// assert_eq!(cloud_file.read_file_size().await?, 303);
231 /// # Ok::<(), CloudFileError>(())}).unwrap();
232 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
233 /// ```
234 pub fn new_with_options<I, K, V>(
235 location: impl AsRef<str>,
236 options: I,
237 ) -> Result<CloudFile, CloudFileError>
238 where
239 I: IntoIterator<Item = (K, V)>,
240 K: AsRef<str>,
241 V: Into<String>,
242 {
243 let location = location.as_ref();
244 let url = Url::parse(location)
245 .map_err(|e| CloudFileError::CannotParseUrl(location.to_string(), e.to_string()))?;
246
247 let (object_store, store_path): (DynObjectStore, StorePath) =
248 parse_url_opts_work_around(&url, options)?;
249 let cloud_file = CloudFile {
250 cloud_service: Arc::new(object_store),
251 store_path,
252 };
253 Ok(cloud_file)
254 }
255
256 /// Count the lines in a file stored in the cloud.
257 ///
258 /// # Example
259 /// ```
260 /// use cloud_file::CloudFile;
261 ///
262 /// # Runtime::new().unwrap().block_on(async {
263 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.fam";
264 /// let cloud_file = CloudFile::new(url)?;
265 /// assert_eq!(cloud_file.count_lines().await?, 10);
266 /// # Ok::<(), CloudFileError>(())}).unwrap();
267 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
268 /// ```
269 pub async fn count_lines(&self) -> Result<usize, CloudFileError> {
270 let stream = self.stream_chunks().await?;
271
272 let newline_count = stream
273 .try_fold(0, |acc, bytes| async move {
274 let count = bytecount::count(&bytes, b'\n');
275 Ok(acc + count) // Accumulate the count
276 })
277 .await
278 .map_err(CloudFileError::ObjectStoreError)?;
279 Ok(newline_count)
280 }
281
282 /// Return the size of a file stored in the cloud.
283 ///
284 /// # Example
285 /// ```
286 /// use cloud_file::CloudFile;
287 ///
288 /// # Runtime::new().unwrap().block_on(async {
289 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
290 /// let cloud_file = CloudFile::new(url)?;
291 /// assert_eq!(cloud_file.read_file_size().await?, 303);
292 /// # Ok::<(), CloudFileError>(())}).unwrap();
293 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
294 /// ```
295 pub async fn read_file_size(&self) -> Result<u64, CloudFileError> {
296 let meta = self.cloud_service.head(&self.store_path).await?;
297 Ok(meta.size)
298 }
299
300 /// Return the [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range.
301 ///
302 /// # Example
303 /// ```
304 /// use cloud_file::CloudFile;
305 ///
306 /// # Runtime::new().unwrap().block_on(async {
307 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bim";
308 /// let cloud_file = CloudFile::new(url)?;
309 /// let bytes = cloud_file.read_range((0..10)).await?;
310 /// assert_eq!(bytes.as_ref(), b"1\t1:1:A:C\t");
311 /// # Ok::<(), CloudFileError>(())}).unwrap();
312 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
313 /// ```
314 pub async fn read_range(&self, range: Range<u64>) -> Result<Bytes, CloudFileError> {
315 Ok(self
316 .cloud_service
317 .get_range(&self.store_path, range)
318 .await?)
319 }
320
321 /// Return the `Vec` of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from specified ranges.
322 ///
323 /// # Example
324 /// ```
325 /// use cloud_file::CloudFile;
326 ///
327 /// # Runtime::new().unwrap().block_on(async {
328 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bim";
329 /// let cloud_file = CloudFile::new(url)?;
330 /// let bytes_vec = cloud_file.read_ranges(&[0..10, 1000..1010]).await?;
331 /// assert_eq!(bytes_vec.len(), 2);
332 /// assert_eq!(bytes_vec[0].as_ref(), b"1\t1:1:A:C\t");
333 /// assert_eq!(bytes_vec[1].as_ref(), b":A:C\t0.0\t4");
334 /// # Ok::<(), CloudFileError>(())}).unwrap();
335 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
336 /// ```
337 pub async fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Bytes>, CloudFileError> {
338 Ok(self
339 .cloud_service
340 .get_ranges(&self.store_path, ranges)
341 .await?)
342 }
343
344 /// Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get_opts) crate's `get_opts` method.
345 ///
346 /// You can, for example, in one call retrieve a range of bytes from the file and the file's metadata. The
347 /// result is a [`GetResult`](https://docs.rs/object_store/latest/object_store/struct.GetResult.html).
348 ///
349 /// # Example
350 ///
351 /// In one call, read the first three bytes of a genomic data file and get
352 /// the size of the file. Check that the file starts with the expected file signature.
353 /// ```
354 /// use cloud_file::CloudFile;
355 /// use object_store::{GetRange, GetOptions};
356 ///
357 /// # Runtime::new().unwrap().block_on(async {
358 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
359 /// let cloud_file = CloudFile::new(url)?;
360 /// let get_options = GetOptions {
361 /// range: Some(GetRange::Bounded(0..3)),
362 /// ..Default::default()
363 /// };
364 /// let get_result = cloud_file.get_opts(get_options).await?;
365 /// let size: u64 = get_result.meta.size;
366 /// let bytes = get_result
367 /// .bytes()
368 /// .await?;
369 /// assert_eq!(bytes.len(), 3);
370 /// assert_eq!(bytes[0], 0x6c);
371 /// assert_eq!(bytes[1], 0x1b);
372 /// assert_eq!(bytes[2], 0x01);
373 /// assert_eq!(size, 303);
374 /// # Ok::<(), CloudFileError>(())}).unwrap();
375 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
376 /// ```
377 pub async fn get_opts(&self, get_options: GetOptions) -> Result<GetResult, CloudFileError> {
378 Ok(self
379 .cloud_service
380 .get_opts(&self.store_path, get_options)
381 .await?)
382 }
383
384 /// Retrieve the [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range & the file's size.
385 ///
386 /// # Example
387 ///
388 /// In one call, read the first three bytes of a genomic data file and get
389 /// the size of the file. Check that the file starts with the expected file signature.
390 /// ```
391 /// use cloud_file::CloudFile;
392 ///
393 /// # Runtime::new().unwrap().block_on(async {
394 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
395 /// let cloud_file = CloudFile::new(url)?;
396 /// let (bytes, size) = cloud_file.read_range_and_file_size(0..3).await?;
397 /// assert_eq!(bytes.len(), 3);
398 /// assert_eq!(bytes[0], 0x6c);
399 /// assert_eq!(bytes[1], 0x1b);
400 /// assert_eq!(bytes[2], 0x01);
401 /// assert_eq!(size, 303);
402 /// # Ok::<(), CloudFileError>(())}).unwrap();
403 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
404 /// ```
405 pub async fn read_range_and_file_size(
406 &self,
407 range: Range<u64>,
408 ) -> Result<(Bytes, u64), CloudFileError> {
409 let get_options = GetOptions {
410 range: Some(GetRange::Bounded(range)),
411 ..Default::default()
412 };
413 let get_result = self
414 .cloud_service
415 .get_opts(&self.store_path, get_options)
416 .await?;
417 let size = get_result.meta.size;
418 let bytes = get_result
419 .bytes()
420 .await
421 .map_err(CloudFileError::ObjectStoreError)?;
422 Ok((bytes, size))
423 }
424
425 /// Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get) crate's `get` method.
426 ///
427 /// The result is a [`GetResult`](https://docs.rs/object_store/latest/object_store/struct.GetResult.html) which can,
428 /// for example, be converted into a stream of bytes.
429 ///
430 /// # Example
431 ///
432 /// Do a 'get', turn result into a stream, then scan all the bytes of the
433 /// file for the newline character.
434 ///
435 /// ```rust
436 /// use cloud_file::CloudFile;
437 /// use futures_util::StreamExt; // Enables `.next()` on streams.
438 ///
439 /// # Runtime::new().unwrap().block_on(async {
440 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
441 /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
442 /// let mut stream = cloud_file.get().await?.into_stream();
443 /// let mut newline_count: usize = 0;
444 /// while let Some(bytes) = stream.next().await {
445 /// let bytes = bytes?;
446 /// newline_count += bytecount::count(&bytes, b'\n');
447 /// }
448 /// assert_eq!(newline_count, 500);
449 /// # Ok::<(), CloudFileError>(())}).unwrap();
450 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
451 /// ```
452 pub async fn get(&self) -> Result<GetResult, CloudFileError> {
453 Ok(self.cloud_service.get(&self.store_path).await?)
454 }
455
456 /// Read the whole file into an in-memory [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html).
457 ///
458 /// # Example
459 ///
460 /// Read the whole file, then scan all the bytes of the
461 /// for the newline character.
462 ///
463 /// ```rust
464 /// use cloud_file::CloudFile;
465 ///
466 /// # Runtime::new().unwrap().block_on(async {
467 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
468 /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
469 /// let all = cloud_file.read_all().await?;
470 /// let newline_count = bytecount::count(&all, b'\n');
471 /// assert_eq!(newline_count, 500);
472 /// # Ok::<(), CloudFileError>(())}).unwrap();
473 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
474 /// ```
475 pub async fn read_all(&self) -> Result<Bytes, CloudFileError> {
476 let all = self
477 .cloud_service
478 .get(&self.store_path)
479 .await?
480 .bytes()
481 .await?;
482 Ok(all)
483 }
484
485 /// Retrieve the file's contents as a stream of
486 /// [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html).
487 ///
488 /// # Example
489 ///
490 /// Open the file as a stream of bytes, then scan all the bytes
491 /// for the newline character.
492 ///
493 /// ```rust
494 /// use cloud_file::CloudFile;
495 /// use futures::StreamExt; // Enables `.next()` on streams.
496 ///
497 /// # Runtime::new().unwrap().block_on(async {
498 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
499 /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
500 /// let mut chunks = cloud_file.stream_chunks().await?;
501 /// let mut newline_count: usize = 0;
502 /// while let Some(chunk) = chunks.next().await {
503 /// let chunk = chunk?;
504 /// newline_count += bytecount::count(&chunk, b'\n');
505 /// }
506 /// assert_eq!(newline_count, 500);
507 /// # Ok::<(), CloudFileError>(())}).unwrap();
508 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
509 /// ```
510 pub async fn stream_chunks(
511 &self,
512 ) -> Result<BoxStream<'static, object_store::Result<Bytes>>, CloudFileError> {
513 let stream = self
514 .cloud_service
515 .get(&self.store_path)
516 .await?
517 .into_stream();
518 Ok(stream)
519 }
520
521 /// Retrieve the file's contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html),
522 /// each containing one or more whole lines.
523 ///
524 /// # Example
525 ///
526 /// Return the 12th line of a file.
527 ///
528 /// ```rust
529 /// use cloud_file::CloudFile;
530 /// use futures::StreamExt; // Enables `.next()` on streams.
531 /// use std::str::from_utf8;
532 ///
533 /// # Runtime::new().unwrap().block_on(async {
534 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
535 /// let goal_index = 12;
536 ///
537 /// let cloud_file = CloudFile::new(url)?;
538 /// let mut line_chunks = cloud_file.stream_line_chunks().await?;
539 /// let mut index_iter = 0..;
540 /// let mut goal_line = None;
541 /// 'outer_loop: while let Some(line_chunk) = line_chunks.next().await {
542 /// let line_chunk = line_chunk?;
543 /// let lines = from_utf8(&line_chunk)?.lines();
544 /// for line in lines {
545 /// let index = index_iter.next().unwrap(); // Safe because the iterator is infinite
546 /// if index == goal_index {
547 /// goal_line = Some(line.to_string());
548 /// break 'outer_loop;
549 /// }
550 /// }
551 /// }
552 /// assert_eq!(goal_line, Some("per12 per12 0 0 2 -0.0382707".to_string()));
553 /// # Ok::<(), CloudFileError>(())}).unwrap();
554 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
555 /// ```
556 ///
557 pub async fn stream_line_chunks(
558 &self,
559 ) -> Result<BoxStream<'static, object_store::Result<Bytes>>, CloudFileError> {
560 let chunks = self.stream_chunks().await?;
561 let line_chunks = newline_delimited_stream(chunks);
562 Ok(Box::pin(line_chunks))
563 }
564
565 /// Change the [`CloudFile`]'s extension (in place).
566 ///
567 /// It removes the current extension, if any.
568 /// It appends the given extension, if any.
569 ///
570 /// The method is in-place rather than functional to make it consistent with
571 /// [`std::path::PathBuf::set_extension`](https://doc.rust-lang.org/stable/std/path/struct.PathBuf.html#method.set_extension).
572 ///
573 /// # Example
574 /// ```
575 /// use cloud_file::CloudFile;
576 ///
577 /// # Runtime::new().unwrap().block_on(async {
578 /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
579 /// let mut cloud_file = CloudFile::new(url)?;
580 /// assert_eq!(cloud_file.read_file_size().await?, 303);
581 /// cloud_file.set_extension("fam")?;
582 /// assert_eq!(cloud_file.read_file_size().await?, 130);
583 /// # Ok::<(), CloudFileError>(())}).unwrap();
584 /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
585 /// ```
586 pub fn set_extension(&mut self, extension: &str) -> Result<(), CloudFileError> {
587 let mut path_str = self.store_path.to_string();
588
589 // Find the last dot in the object path
590 if let Some(dot_index) = path_str.rfind('.') {
591 // Remove the current extension
592 path_str.truncate(dot_index);
593 }
594
595 if !extension.is_empty() {
596 // Append the new extension
597 path_str.push('.');
598 path_str.push_str(extension);
599 }
600
601 // Parse the string back to StorePath
602 self.store_path = StorePath::parse(&path_str)?;
603 Ok(())
604 }
605}
606
607#[allow(clippy::match_bool)]
608fn parse_work_around(url: &Url) -> Result<(bool, StorePath), object_store::Error> {
609 let strip_bucket = || Some(url.path().strip_prefix('/')?.split_once('/')?.1);
610
611 let (scheme, path) = match (url.scheme(), url.host_str()) {
612 ("http", Some(_)) => (true, url.path()),
613 ("https", Some(host)) => {
614 if host.ends_with("dfs.core.windows.net")
615 || host.ends_with("blob.core.windows.net")
616 || host.ends_with("dfs.fabric.microsoft.com")
617 || host.ends_with("blob.fabric.microsoft.com")
618 {
619 (false, url.path())
620 } else if host.ends_with("amazonaws.com") {
621 match host.starts_with("s3") {
622 true => (false, strip_bucket().unwrap_or_default()),
623 false => (false, url.path()),
624 }
625 } else if host.ends_with("r2.cloudflarestorage.com") {
626 (false, strip_bucket().unwrap_or_default())
627 } else {
628 (true, url.path())
629 }
630 }
631 _ => (false, url.path()),
632 };
633
634 Ok((scheme, StorePath::from_url_path(path)?))
635}
636
637// LATER when https://github.com/apache/arrow-rs/issues/5310 gets fixed, can remove work around
638fn parse_url_opts_work_around<I, K, V>(
639 url: &Url,
640 options: I,
641) -> Result<(DynObjectStore, StorePath), object_store::Error>
642where
643 I: IntoIterator<Item = (K, V)>,
644 K: AsRef<str>,
645 V: Into<String>,
646{
647 let (is_http, path) = parse_work_around(url)?;
648 if is_http {
649 let url = &url[..url::Position::BeforePath];
650 let path = StorePath::parse(path)?;
651 let builder = options.into_iter().fold(
652 <HttpBuilder>::new().with_url(url),
653 |builder, (key, value)| match key.as_ref().parse() {
654 Ok(k) => builder.with_config(k, value),
655 Err(_) => builder,
656 },
657 );
658 let store = DynObjectStore::new(builder.build()?);
659 Ok((store, path))
660 } else {
661 let (store, path) = object_store::parse_url_opts(url, options)?;
662 Ok((DynObjectStore(store), path))
663 }
664}
665
666impl fmt::Display for CloudFile {
667 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668 write!(f, "CloudFile: {:?}", self.store_path)
669 }
670}
671
672/// Wraps `Box<dyn ObjectStore>` for easier usage. An [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html), from the
673/// powerful [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate, represents a cloud service.
674#[derive(Debug)]
675pub struct DynObjectStore(pub Box<dyn ObjectStore>);
676
677// Implement Deref to allow access to the inner `ObjectStore` methods
678impl Deref for DynObjectStore {
679 type Target = dyn ObjectStore;
680
681 fn deref(&self) -> &Self::Target {
682 &*self.0
683 }
684}
685
686impl DynObjectStore {
687 #[inline]
688 fn new(store: impl ObjectStore) -> Self {
689 DynObjectStore(Box::new(store) as Box<dyn ObjectStore>)
690 }
691}
692
693/// The error type for [`CloudFile`](struct.CloudFile.html) methods.
694#[derive(Error, Debug)]
695pub enum CloudFileError {
696 /// An error from [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate
697 #[error("Object store error: {0}")]
698 ObjectStoreError(#[from] object_store::Error),
699
700 /// An path-related error from [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate
701 #[error("Object store path error: {0}")]
702 ObjectStorePathError(#[from] object_store::path::Error),
703
704 /// An error related to converting bytes into UTF-8
705 #[error("UTF-8 error: {0}")]
706 Utf8Error(#[from] std::str::Utf8Error),
707
708 /// An error related to parsing a URL string
709 #[error("Cannot parse URL: {0} {1}")]
710 CannotParseUrl(String, String),
711
712 /// An error related to creating a URL from a file path
713 #[error("Cannot create URL from this absolute file path: '{0}'")]
714 CannotCreateUrlFromFilePath(String),
715}
716
717#[tokio::test]
718async fn cloud_file_2() -> Result<(), CloudFileError> {
719 let cloud_file = CloudFile::new(
720 "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed",
721
722 )?;
723 assert_eq!(cloud_file.read_file_size().await?, 303);
724 Ok(())
725}
726
727#[tokio::test]
728async fn line_n() -> Result<(), CloudFileError> {
729 use futures_util::StreamExt;
730 use std::str::from_utf8; // Enables `.next()` on streams.
731
732 let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
733 let goal_index = 12;
734
735 let cloud_file = CloudFile::new(url)?;
736 let mut line_chunks = cloud_file.stream_line_chunks().await?;
737 let mut index_iter = 0..;
738 let mut goal_line = None;
739 'outer_loop: while let Some(line_chunk) = line_chunks.next().await {
740 let line_chunk = line_chunk?;
741 let lines = from_utf8(&line_chunk)?.lines();
742 for line in lines {
743 let index = index_iter.next().unwrap(); // safe because we know the iterator is infinite
744 if index == goal_index {
745 goal_line = Some(line.to_string());
746 break 'outer_loop;
747 }
748 }
749 }
750
751 assert_eq!(goal_line, Some("per12 per12 0 0 2 -0.0382707".to_string()));
752 Ok(())
753}
754
755#[tokio::test]
756async fn cloud_file_extension() -> Result<(), CloudFileError> {
757 let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
758 let mut cloud_file = CloudFile::new(url)?;
759 assert_eq!(cloud_file.read_file_size().await?, 303);
760 cloud_file.set_extension("fam")?;
761 assert_eq!(cloud_file.read_file_size().await?, 130);
762 Ok(())
763}
764
765// The AWS tests are skipped if credentials are not available.
766#[tokio::test]
767async fn s3_play_cloud() -> Result<(), CloudFileError> {
768 use rusoto_credential::{CredentialsError, ProfileProvider, ProvideAwsCredentials};
769 let credentials = if let Ok(provider) = ProfileProvider::new() {
770 provider.credentials().await
771 } else {
772 Err(CredentialsError::new("No credentials found"))
773 };
774
775 let Ok(credentials) = credentials else {
776 eprintln!("Skipping test because no AWS credentials found");
777 return Ok(());
778 };
779
780 let url = "s3://bedreader/v1/toydata.5chrom.bed";
781 let options = [
782 ("aws_region", "us-west-2"),
783 ("aws_access_key_id", credentials.aws_access_key_id()),
784 ("aws_secret_access_key", credentials.aws_secret_access_key()),
785 ];
786
787 let cloud_file = CloudFile::new_with_options(url, options)?;
788 assert_eq!(cloud_file.read_file_size().await?, 1_250_003);
789 Ok(())
790}
791
792/// Given a local file's absolute path, return a URL string to that file.
793///
794/// # Example
795/// ```
796/// use cloud_file::abs_path_to_url_string;
797///
798/// // Define a sample file_name and expected_url based on the target OS
799/// #[cfg(target_os = "windows")]
800/// let (file_name, expected_url) = (r"M:\data files\small.bed", "file:///M:/data%20files/small.bed");
801///
802/// #[cfg(not(target_os = "windows"))]
803/// let (file_name, expected_url) = (r"/data files/small.bed", "file:///data%20files/small.bed");
804///
805/// let url = abs_path_to_url_string(file_name)?;
806/// assert_eq!(url, expected_url);
807/// # use cloud_file::CloudFileError;
808/// # Ok::<(), CloudFileError>(())
809/// ```
810pub fn abs_path_to_url_string(path: impl AsRef<Path>) -> Result<String, CloudFileError> {
811 let path = path.as_ref();
812 let url = Url::from_file_path(path)
813 .map_err(|_e| {
814 CloudFileError::CannotCreateUrlFromFilePath(path.to_string_lossy().to_string())
815 })?
816 .to_string();
817 Ok(url)
818}
819
820#[test]
821fn readme_1() {
822 use futures_util::StreamExt; // Enables `.next()` on streams.
823 use tokio::runtime::Runtime;
824
825 Runtime::new()
826 .unwrap()
827 .block_on(async {
828 let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
829 let cloud_file = CloudFile::new(url)?;
830 let mut chunks = cloud_file.stream_chunks().await?;
831 let mut newline_count: usize = 0;
832 while let Some(chunk) = chunks.next().await {
833 let chunk = chunk?;
834 newline_count += bytecount::count(&chunk, b'\n');
835 }
836 assert_eq!(newline_count, 500);
837 Ok::<(), CloudFileError>(())
838 })
839 .unwrap();
840}
841
842#[tokio::test]
843async fn check_file_signature() -> Result<(), CloudFileError> {
844 let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
845 let cloud_file = CloudFile::new(url)?;
846 let (bytes, size) = cloud_file.read_range_and_file_size(0..3).await?;
847
848 assert_eq!(bytes.len(), 3);
849 assert_eq!(bytes[0], 0x6c);
850 assert_eq!(bytes[1], 0x1b);
851 assert_eq!(bytes[2], 0x01);
852 assert_eq!(size, 303);
853 Ok(())
854}
855
856#[tokio::test]
857async fn from_structs_example() -> Result<(), CloudFileError> {
858 use object_store::{http::HttpBuilder, path::Path as StorePath, ClientOptions};
859 use std::time::Duration;
860
861 let client_options = ClientOptions::new().with_timeout(Duration::from_secs(30));
862 let http = HttpBuilder::new()
863 .with_url("https://raw.githubusercontent.com")
864 .with_client_options(client_options)
865 .build()?;
866 let store_path =
867 StorePath::parse("fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed")?;
868
869 let cloud_file = CloudFile::from_structs(http, store_path);
870 assert_eq!(cloud_file.read_file_size().await?, 303);
871 Ok(())
872}
873
874#[tokio::test]
875async fn local_file() -> Result<(), CloudFileError> {
876 use std::env;
877
878 let apache_url =
879 abs_path_to_url_string(env::var("CARGO_MANIFEST_DIR").unwrap() + "/LICENSE-APACHE")?;
880 let cloud_file = CloudFile::new(&apache_url)?;
881 assert_eq!(cloud_file.count_lines().await?, 175);
882 Ok(())
883}