Skip to main content

hrx/artifacts/
hf.rs

1//! Hugging Face model files resolved through the standard local cache.
2
3use crate::{Error, Result, bundle};
4use hf_hub::progress::{DownloadEvent, ProgressEvent, ProgressHandler};
5use hf_hub::{HFClientSync, HFError};
6use std::{
7    io::{IsTerminal, Write},
8    path::PathBuf,
9    sync::{Mutex, OnceLock},
10};
11
12/// One model repository and optional immutable revision.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Repository {
15    /// Repository owner or organization.
16    pub owner: String,
17    /// Repository name.
18    pub name: String,
19    /// Commit, tag, or branch; `None` uses the Hub default.
20    pub revision: Option<String>,
21}
22
23impl Repository {
24    /// Select a repository at its default revision.
25    pub fn new(owner: impl Into<String>, name: impl Into<String>) -> Self {
26        Self {
27            owner: owner.into(),
28            name: name.into(),
29            revision: None,
30        }
31    }
32
33    /// Pin a revision.
34    #[must_use]
35    pub fn at(mut self, revision: impl Into<String>) -> Self {
36        self.revision = Some(revision.into());
37        self
38    }
39}
40
41/// A repository-relative file and optional expected SHA-256 digest.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct HubFile {
44    /// Repository-relative filename.
45    pub name: String,
46    /// Expected lowercase SHA-256, when the publisher provides one.
47    pub sha256: Option<String>,
48}
49
50impl HubFile {
51    /// Name a file without an independent checksum.
52    pub fn new(name: impl Into<String>) -> Self {
53        Self {
54            name: name.into(),
55            sha256: None,
56        }
57    }
58
59    /// Require an exact lowercase SHA-256 digest.
60    #[must_use]
61    pub fn sha256(mut self, digest: impl Into<String>) -> Self {
62        self.sha256 = Some(digest.into());
63        self
64    }
65}
66
67/// Cache and download policy for one repository.
68#[derive(Clone, Debug)]
69pub struct Resolver {
70    repository: Repository,
71    offline: bool,
72    progress: Option<bool>,
73}
74
75impl Resolver {
76    /// Resolve files from a repository, downloading cache misses by default.
77    pub fn new(repository: Repository) -> Self {
78        Self {
79            repository,
80            offline: false,
81            progress: None,
82        }
83    }
84
85    /// Restrict resolution to local cache entries.
86    #[must_use]
87    pub fn offline(mut self, offline: bool) -> Self {
88        self.offline = offline;
89        self
90    }
91
92    /// Force progress on or off. The default uses an interactive terminal and
93    /// `HF_HUB_DISABLE_PROGRESS_BARS`.
94    #[must_use]
95    pub fn progress(mut self, progress: bool) -> Self {
96        self.progress = Some(progress);
97        self
98    }
99
100    /// Resolve a required file, checking the cache before any network request.
101    pub fn resolve(&self, file: &HubFile) -> Result<PathBuf> {
102        if let Some(path) = self.local(file)? {
103            return Ok(path);
104        }
105        if self.offline || environment_true("HF_HUB_OFFLINE") {
106            return Err(Error::Message(format!(
107                "{}/{}/{} is not in the Hugging Face cache, and downloading is off",
108                self.repository.owner, self.repository.name, file.name
109            )));
110        }
111        let repository = client()?.model(&self.repository.owner, &self.repository.name);
112        let path = repository
113            .download_file()
114            .filename(&file.name)
115            .maybe_revision(self.repository.revision.clone())
116            .maybe_progress(self.show_progress().then(DownloadBar::default))
117            .send()
118            .map_err(|error| self.fetch_error(file, error))?;
119        self.verify(file, path)
120    }
121
122    /// Return a cached file without contacting the network.
123    pub fn local(&self, file: &HubFile) -> Result<Option<PathBuf>> {
124        let result = client()?
125            .model(&self.repository.owner, &self.repository.name)
126            .download_file()
127            .filename(&file.name)
128            .maybe_revision(self.repository.revision.clone())
129            .local_files_only(true)
130            .send();
131        match result {
132            Ok(path) => self.verify(file, path).map(Some),
133            Err(HFError::LocalEntryNotFound { .. }) => Ok(None),
134            Err(error) => Err(self.fetch_error(file, error)),
135        }
136    }
137
138    /// Resolve required files in request order.
139    pub fn resolve_all(&self, files: &[HubFile]) -> Result<Vec<PathBuf>> {
140        files.iter().map(|file| self.resolve(file)).collect()
141    }
142
143    fn verify(&self, file: &HubFile, path: PathBuf) -> Result<PathBuf> {
144        if let Some(expected) = &file.sha256 {
145            let actual = bundle::file_digest(&path)
146                .map_err(|error| error.context(format!("hashing {}", path.display())))?;
147            if &actual != expected {
148                return Err(Error::Message(format!(
149                    "{} has SHA-256 {actual}, expected {expected}",
150                    path.display()
151                )));
152            }
153        }
154        Ok(path)
155    }
156
157    fn show_progress(&self) -> bool {
158        self.progress.unwrap_or_else(|| {
159            std::io::stderr().is_terminal() && !environment_true("HF_HUB_DISABLE_PROGRESS_BARS")
160        })
161    }
162
163    fn fetch_error(&self, file: &HubFile, error: HFError) -> Error {
164        Error::Message(format!(
165            "cannot fetch {}/{}/{}{}: {error}",
166            self.repository.owner,
167            self.repository.name,
168            file.name,
169            self.repository
170                .revision
171                .as_deref()
172                .map_or(String::new(), |revision| format!(" at {revision}"))
173        ))
174    }
175}
176
177fn client() -> Result<&'static HFClientSync> {
178    static CLIENT: OnceLock<std::result::Result<HFClientSync, String>> = OnceLock::new();
179    CLIENT
180        .get_or_init(|| HFClientSync::new().map_err(|error| error.to_string()))
181        .as_ref()
182        .map_err(|error| Error::Message(format!("cannot initialize Hugging Face client: {error}")))
183}
184
185fn environment_true(name: &str) -> bool {
186    std::env::var(name).is_ok_and(|value| {
187        ["1", "ON", "YES", "TRUE"]
188            .iter()
189            .any(|truth| value.eq_ignore_ascii_case(truth))
190    })
191}
192
193#[derive(Default)]
194struct DownloadBar {
195    total: Mutex<u64>,
196}
197
198impl ProgressHandler for DownloadBar {
199    fn on_progress(&self, event: &ProgressEvent) {
200        let ProgressEvent::Download(event) = event else {
201            return;
202        };
203        let mut stderr = std::io::stderr().lock();
204        match event {
205            DownloadEvent::Start { total_bytes, .. } => {
206                *self.total.lock().unwrap_or_else(|error| error.into_inner()) = *total_bytes;
207            }
208            DownloadEvent::AggregateProgress {
209                bytes_completed,
210                total_bytes,
211                ..
212            } => {
213                let total = if *total_bytes == 0 {
214                    *self.total.lock().unwrap_or_else(|error| error.into_inner())
215                } else {
216                    *total_bytes
217                };
218                if total > 0 {
219                    let _ = write!(
220                        stderr,
221                        "\rfetching {:5.1}% of {:.2} GB",
222                        100.0 * *bytes_completed as f64 / total as f64,
223                        total as f64 / 1e9
224                    );
225                    let _ = stderr.flush();
226                }
227            }
228            DownloadEvent::Complete => {
229                let _ = writeln!(stderr, "\rfetched                    ");
230            }
231            DownloadEvent::Progress { .. } => {}
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    #[test]
239    fn documented_boolean_values_are_recognized() {
240        for value in ["1", "ON", "on", "YES", "yes", "TRUE", "true"] {
241            assert!(
242                ["1", "ON", "YES", "TRUE"]
243                    .iter()
244                    .any(|truth| value.eq_ignore_ascii_case(truth))
245            );
246        }
247    }
248}