Skip to main content

candle_examples/
hub.rs

1//! `hf-hub` utils
2
3use hf_hub::progress::{DownloadEvent, ProgressEvent, ProgressHandler};
4use hf_hub::{
5    HFClientSync, HFError, HFRepositorySync, HFResult, RepoType, RepoTypeDataset, RepoTypeModel,
6};
7use std::io::{IsTerminal, Write};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11/// Blocking hub client
12#[derive(Clone, Debug)]
13pub struct Api(HFClientSync);
14
15impl Api {
16    /// Client configured from env (`HF_TOKEN`, `HF_HUB_CACHE`, etc)
17    pub fn new() -> HFResult<Self> {
18        Ok(Self(HFClientSync::new()?))
19    }
20
21    pub fn with_cache_dir<P: Into<PathBuf>>(cache_dir: P) -> HFResult<Self> {
22        let client = hf_hub::HFClient::builder().cache_dir(cache_dir).build()?;
23        Ok(Self(HFClientSync::from_inner(client)?))
24    }
25
26    /// Creates a blocking handle for a model repository
27    pub fn model(&self, id: impl AsRef<str>) -> Repo<RepoTypeModel> {
28        let (owner, name) = hf_hub::split_id(id.as_ref());
29        Repo::new(self.0.model(owner, name))
30    }
31
32    /// Creates a blocking handle for a dataset repository
33    pub fn dataset(&self, id: impl AsRef<str>) -> Repo<RepoTypeDataset> {
34        let (owner, name) = hf_hub::split_id(id.as_ref());
35        Repo::new(self.0.dataset(owner, name))
36    }
37}
38
39impl AsRef<HFClientSync> for Api {
40    fn as_ref(&self) -> &HFClientSync {
41        &self.0
42    }
43}
44
45/// Repository handle. Optionally pinned to a revision.
46#[derive(Clone, Debug)]
47pub struct Repo<T: RepoType> {
48    repo: HFRepositorySync<T>,
49    revision: Option<String>,
50}
51
52impl<T: RepoType> Repo<T> {
53    pub fn new(repo: HFRepositorySync<T>) -> Self {
54        Self {
55            repo,
56            revision: None,
57        }
58    }
59
60    /// Pins every subsequent [`Repo::get`] to `revision`.
61    pub fn with_revision<S: AsRef<str>>(mut self, revision: S) -> Self {
62        self.revision = Some(revision.as_ref().to_string());
63        self
64    }
65
66    pub fn revision(&self) -> Option<&str> {
67        self.revision.as_deref()
68    }
69
70    /// Returns the path to `filename`. Downloads if not in cache.
71    ///
72    /// Download progress is printed on stderr.
73    pub fn get<S: AsRef<str>>(&self, filename: S) -> HFResult<PathBuf> {
74        let filename = filename.as_ref();
75        let download = self
76            .repo
77            .download_file()
78            .filename(filename)
79            .maybe_revision(self.revision.clone());
80        match download.clone().local_files_only(true).send() {
81            Err(HFError::LocalEntryNotFound { .. }) => {
82                download.progress(StderrProgress::new(filename)).send()
83            }
84            cached => cached,
85        }
86    }
87}
88
89impl<T: RepoType> AsRef<HFRepositorySync<T>> for Repo<T> {
90    fn as_ref(&self) -> &HFRepositorySync<T> {
91        &self.repo
92    }
93}
94
95/// Download progress on stderr.
96///
97/// On terminal redraws in place every 1 percent.
98/// On stderr redirect prints full lines every 10 percent.
99struct StderrProgress {
100    filename: String,
101    downloaded: AtomicU64,
102    total: AtomicU64,
103    /// Last step drawn
104    drawn: AtomicU64,
105    /// Whether stderr is a terminal and `\r` redraw makes sense.
106    tty: bool,
107}
108
109const NOT_DRAWN: u64 = u64::MAX;
110
111impl StderrProgress {
112    fn new(filename: &str) -> Self {
113        Self {
114            filename: filename.to_string(),
115            downloaded: AtomicU64::new(0),
116            total: AtomicU64::new(0),
117            drawn: AtomicU64::new(NOT_DRAWN),
118            tty: std::io::stderr().is_terminal(),
119        }
120    }
121
122    fn draw(&self) {
123        let total = self.total.load(Ordering::Relaxed);
124        if total == 0 {
125            return;
126        }
127        let downloaded = self.downloaded.load(Ordering::Relaxed).min(total);
128        let percent = downloaded * 100 / total;
129        let step = if self.tty { percent } else { percent / 10 };
130        if self.drawn.swap(step, Ordering::Relaxed) == step {
131            return;
132        }
133        let line = progress_line(self.tty, &self.filename, percent, downloaded, total);
134        let mut stderr = std::io::stderr().lock();
135        let _ = stderr.write_all(line.as_bytes());
136        let _ = stderr.flush();
137    }
138}
139
140/// Progress report. In place redraw on a terminal. Whole line elsewhere.
141fn progress_line(tty: bool, filename: &str, percent: u64, downloaded: u64, total: u64) -> String {
142    let (unit, scale) = human_unit(total);
143    let (lead, trail) = if tty { ("\r", "  ") } else { ("", "\n") };
144    format!(
145        "{lead}{filename}: {percent:>3}% ({:.1}/{:.1} {unit}){trail}",
146        downloaded as f64 / scale,
147        total as f64 / scale,
148    )
149}
150
151/// Human readable bytes
152fn human_unit(bytes: u64) -> (&'static str, f64) {
153    const KIB: f64 = 1024.;
154    const MIB: f64 = KIB * 1024.;
155    const GIB: f64 = MIB * 1024.;
156    match bytes as f64 {
157        b if b >= GIB => ("GiB", GIB),
158        b if b >= MIB => ("MiB", MIB),
159        b if b >= KIB => ("KiB", KIB),
160        _ => ("B", 1.),
161    }
162}
163
164impl ProgressHandler for StderrProgress {
165    fn on_progress(&self, event: &ProgressEvent) {
166        let ProgressEvent::Download(event) = event else {
167            return;
168        };
169        match event {
170            DownloadEvent::Start { total_bytes, .. } => {
171                self.total.store(*total_bytes, Ordering::Relaxed);
172            }
173            DownloadEvent::Progress { files } => {
174                if let Some(file) = files.first() {
175                    self.downloaded
176                        .store(file.bytes_completed, Ordering::Relaxed);
177                }
178                self.draw();
179            }
180            DownloadEvent::AggregateProgress {
181                bytes_completed,
182                total_bytes,
183                ..
184            } => {
185                self.downloaded.store(*bytes_completed, Ordering::Relaxed);
186                self.total.store(*total_bytes, Ordering::Relaxed);
187                self.draw();
188            }
189            DownloadEvent::Complete => {
190                if self.tty && self.drawn.load(Ordering::Relaxed) != NOT_DRAWN {
191                    let _ = writeln!(std::io::stderr());
192                }
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{human_unit, progress_line};
201
202    #[test]
203    fn terminal_progress_redraws_in_place() {
204        let line = progress_line(
205            true,
206            "model.safetensors",
207            42,
208            42 * 1024 * 1024,
209            100 * 1024 * 1024,
210        );
211        assert_eq!(line, "\rmodel.safetensors:  42% (42.0/100.0 MiB)  ");
212        assert!(!line.contains('\n'), "a redraw must not end the line");
213    }
214
215    #[test]
216    fn redirected_progress_writes_lines() {
217        let line = progress_line(
218            false,
219            "model.safetensors",
220            42,
221            42 * 1024 * 1024,
222            100 * 1024 * 1024,
223        );
224        assert_eq!(line, "model.safetensors:  42% (42.0/100.0 MiB)\n");
225        assert!(!line.contains('\r'), "a log must not get carriage returns");
226    }
227
228    #[test]
229    fn verify_human_units() {
230        assert_eq!(human_unit(612).0, "B");
231        assert_eq!(human_unit(4 * 1024).0, "KiB");
232        assert_eq!(human_unit(4 * 1024 * 1024).0, "MiB");
233        assert_eq!(human_unit(4 * 1024 * 1024 * 1024).0, "GiB");
234    }
235}