1use std::path::PathBuf;
19use std::sync::Arc;
20
21use log::debug;
22
23use super::HF_SCHEME;
24use super::config::HfConfig;
25use super::core::HfCore;
26use super::core::HfDownloadMode;
27use super::core::{HfRepo, HfRepoType};
28use super::deleter::HfDeleter;
29use super::lister::HfLister;
30use super::reader::*;
31use super::writer::HfLazyWriter;
32use opendal_core::raw::*;
33use opendal_core::*;
34
35#[doc = include_str!("docs.md")]
37#[derive(Debug, Default)]
38pub struct HfBuilder {
39 pub(super) config: HfConfig,
40}
41
42impl HfBuilder {
43 pub fn repo_type(mut self, repo_type: &str) -> Self {
54 if !repo_type.is_empty()
55 && let Ok(rt) = HfRepoType::parse(repo_type)
56 {
57 self.config.repo_type = Some(rt);
58 }
59 self
60 }
61
62 pub fn repo_id(mut self, repo_id: &str) -> Self {
72 if !repo_id.is_empty() {
73 self.config.repo_id = Some(repo_id.to_string());
74 }
75 self
76 }
77
78 pub fn revision(mut self, revision: &str) -> Self {
86 if !revision.is_empty() {
87 self.config.revision = Some(revision.to_string());
88 }
89 self
90 }
91
92 pub fn root(mut self, root: &str) -> Self {
96 self.config.root = if root.is_empty() {
97 None
98 } else {
99 Some(root.to_string())
100 };
101
102 self
103 }
104
105 pub fn token(mut self, token: &str) -> Self {
109 if !token.is_empty() {
110 self.config.token = Some(token.to_string());
111 }
112 self
113 }
114
115 pub fn download_mode(mut self, mode: &str) -> Self {
128 if !mode.is_empty()
129 && let Ok(m) = HfDownloadMode::parse(mode)
130 {
131 self.config.download_mode = Some(m);
132 }
133 self
134 }
135
136 pub fn enable_resolve_cache(mut self, enabled: bool) -> Self {
153 self.config.enable_resolve_cache = enabled;
154 self
155 }
156
157 pub fn endpoint(mut self, endpoint: &str) -> Self {
164 if !endpoint.is_empty() {
165 self.config.endpoint = Some(endpoint.to_string());
166 }
167 self
168 }
169
170 fn hf_endpoint(&self) -> String {
175 self.config
176 .endpoint
177 .clone()
178 .or_else(|| std::env::var("HF_ENDPOINT").ok())
179 .unwrap_or_else(|| "https://huggingface.co".to_string())
180 .trim_end_matches('/')
181 .to_string()
182 }
183
184 fn hf_download_mode(&self) -> HfDownloadMode {
187 if let Some(mode) = self.config.download_mode {
188 return mode;
189 }
190 if let Ok(val) = std::env::var("HF_HUB_DISABLE_XET")
191 && !val.is_empty()
192 {
193 return HfDownloadMode::Http;
194 }
195 HfDownloadMode::default()
196 }
197
198 fn hf_home() -> Option<PathBuf> {
199 if let Ok(h) = std::env::var("HF_HOME") {
200 return Some(PathBuf::from(h));
201 }
202 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
203 return Some(PathBuf::from(xdg).join("huggingface"));
204 }
205 let home = std::env::var("HOME").ok()?;
206 Some(PathBuf::from(home).join(".cache/huggingface"))
207 }
208
209 fn hf_token(&self) -> Option<String> {
212 if let Some(t) = self.config.token.clone() {
213 return Some(t);
214 }
215 if let Ok(val) = std::env::var("HF_HUB_DISABLE_IMPLICIT_TOKEN")
216 && !val.is_empty()
217 {
218 return None;
219 }
220 if let Ok(t) = std::env::var("HF_TOKEN")
221 && !t.is_empty()
222 {
223 return Some(t);
224 }
225 let token_path = if let Ok(p) = std::env::var("HF_TOKEN_PATH") {
226 Some(PathBuf::from(p))
227 } else {
228 Self::hf_home().map(|h| h.join("token"))
229 };
230 token_path
231 .and_then(|p| std::fs::read_to_string(p).ok())
232 .map(|s| s.trim().to_string())
233 .filter(|s| !s.is_empty())
234 }
235}
236
237impl Builder for HfBuilder {
238 type Config = HfConfig;
239
240 fn build(self) -> Result<impl Service> {
241 debug!("backend build started: {:?}", self);
242
243 let token = self.hf_token();
244 let endpoint = self.hf_endpoint();
245 let download_mode = self.hf_download_mode();
246
247 let repo_type = self.config.repo_type.ok_or_else(|| {
248 Error::new(ErrorKind::ConfigInvalid, "repo_type is required")
249 .with_operation("Builder::build")
250 .with_context("service", HF_SCHEME)
251 })?;
252 debug!("backend use repo_type: {:?}", repo_type);
253
254 let repo_id = self.config.repo_id.ok_or_else(|| {
255 Error::new(ErrorKind::ConfigInvalid, "repo_id is required")
256 .with_operation("Builder::build")
257 .with_context("service", HF_SCHEME)
258 })?;
259 debug!("backend use repo_id: {}", repo_id);
260
261 let revision = match &self.config.revision {
262 Some(revision) => revision.clone(),
263 None => "main".to_string(),
264 };
265 debug!("backend use revision: {}", revision);
266
267 let root = normalize_root(&self.config.root.unwrap_or_default());
268 debug!("backend use root: {}", root);
269 debug!("backend use token: {}", token.is_some());
270 debug!("backend use endpoint: {}", endpoint);
271 debug!("backend use download_mode: {:?}", download_mode);
272
273 let info = ServiceInfo::new(HF_SCHEME, "", "");
274 let capability = Capability {
275 stat: true,
276 read: true,
277 write: token.is_some(),
278 write_can_multi: token.is_some(),
279 delete: token.is_some(),
280 delete_max_size: Some(100),
281 list: true,
282 list_with_recursive: true,
283 shared: true,
284 ..Default::default()
285 };
286
287 let repo = HfRepo::new(repo_type, repo_id, Some(revision.clone()));
288 debug!("backend repo uri: {:?}", repo.uri(&root, ""));
289
290 let mut core = HfCore::build(info, capability, repo, root, token, endpoint, download_mode)?;
291 core.enable_resolve_cache = self.config.enable_resolve_cache;
292 Ok(HfBackend {
293 core: Arc::new(core),
294 })
295 }
296}
297
298#[derive(Debug, Clone)]
300pub struct HfBackend {
301 pub(crate) core: Arc<HfCore>,
302}
303
304impl Service for HfBackend {
305 type Reader = oio::StreamReader<HfReader>;
306 type Writer = HfLazyWriter;
307 type Lister = oio::PageLister<HfLister>;
308 type Deleter = oio::BatchDeleter<HfDeleter>;
309 type Copier = ();
310 type Composer = ();
311
312 fn info(&self) -> ServiceInfo {
313 self.core.info.clone()
314 }
315
316 fn capability(&self) -> Capability {
317 self.core.capability
318 }
319
320 async fn create_dir(
321 &self,
322 _ctx: &OperationContext,
323 _path: &str,
324 _args: OpCreateDir,
325 ) -> Result<RpCreateDir> {
326 Err(Error::new(
327 ErrorKind::Unsupported,
328 "operation is not supported",
329 ))
330 }
331
332 async fn stat(&self, ctx: &OperationContext, path: &str, _: OpStat) -> Result<RpStat> {
333 if path == "/" {
335 return Ok(RpStat::new(MetadataBuilder::dir().build()));
336 }
337
338 if self.core.repo.is_bucket() && path.ends_with('/') {
340 return Ok(RpStat::new(MetadataBuilder::dir().build()));
341 }
342
343 let info = self.core.path_info(ctx, path).await?;
344 Ok(RpStat::new(info.metadata()?))
345 }
346 fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
347 let output: oio::StreamReader<HfReader> = {
348 Ok(oio::StreamReader::new(HfReader::new(
349 self.clone(),
350 ctx.clone(),
351 path,
352 args,
353 )))
354 }?;
355
356 Ok(output)
357 }
358
359 fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
360 let output: oio::PageLister<HfLister> = {
361 let lister = HfLister::new(
362 self.core.clone(),
363 ctx.clone(),
364 path.to_string(),
365 args.recursive(),
366 );
367 Ok(oio::PageLister::new(lister))
368 }?;
369
370 Ok(output)
371 }
372
373 fn write(&self, ctx: &OperationContext, path: &str, _args: OpWrite) -> Result<Self::Writer> {
374 Ok(HfLazyWriter::new(
375 self.core.clone(),
376 ctx.clone(),
377 path.to_string(),
378 ))
379 }
380
381 fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
382 let output: oio::BatchDeleter<HfDeleter> = {
383 let deleter = HfDeleter::new(self.core.clone(), ctx.clone());
384 let max_batch_size = self.core.capability.delete_max_size;
385 Ok(oio::BatchDeleter::new(deleter, max_batch_size))
386 }?;
387
388 Ok(output)
389 }
390
391 fn copy(
392 &self,
393 _ctx: &OperationContext,
394 _from: &str,
395 _to: &str,
396 _args: OpCopy,
397 ) -> Result<Self::Copier> {
398 Err(Error::new(
399 ErrorKind::Unsupported,
400 "operation is not supported",
401 ))
402 }
403
404 async fn rename(
405 &self,
406 _ctx: &OperationContext,
407 _from: &str,
408 _to: &str,
409 _args: OpRename,
410 ) -> Result<RpRename> {
411 Err(Error::new(
412 ErrorKind::Unsupported,
413 "operation is not supported",
414 ))
415 }
416
417 async fn presign(
418 &self,
419 _ctx: &OperationContext,
420 _path: &str,
421 _args: OpPresign,
422 ) -> Result<RpPresign> {
423 Err(Error::new(
424 ErrorKind::Unsupported,
425 "operation is not supported",
426 ))
427 }
428}
429
430#[cfg(test)]
431pub(super) mod test_utils {
432 use std::sync::Arc;
433
434 use super::super::core::{HfCore, HfDownloadMode};
435 use super::super::core::{HfRepo, HfRepoType};
436 use super::HfBuilder;
437 use opendal_core::Capability;
438 use opendal_core::HttpTransporter;
439 use opendal_core::OperationContext;
440 use opendal_core::Operator;
441 use opendal_core::raw::ServiceInfo;
442
443 fn finish_operator(op: Operator) -> Operator {
444 let transport =
445 HttpTransporter::new(opendal_http_transport_reqwest::ReqwestTransport::default());
446 op.with_context(OperationContext::new().with_http_transport(transport))
447 }
448
449 pub fn mbpp_operator() -> Operator {
450 let op = Operator::new(
451 HfBuilder::default()
452 .repo_type("dataset")
453 .repo_id("google-research-datasets/mbpp"),
454 )
455 .unwrap();
456 finish_operator(op)
457 }
458
459 pub fn miscased_mbpp_operator() -> Operator {
463 let op = Operator::new(
464 HfBuilder::default()
465 .repo_type("dataset")
466 .repo_id("Google-Research-Datasets/MBPP"),
467 )
468 .unwrap();
469 finish_operator(op)
470 }
471
472 pub fn testing_dataset_core() -> Arc<HfCore> {
473 let repo_id = std::env::var("HF_OPENDAL_DATASET").expect("HF_OPENDAL_DATASET must be set");
474 let token = std::env::var("HF_OPENDAL_TOKEN").expect("HF_OPENDAL_TOKEN must be set");
475
476 let info = ServiceInfo::new("hf", "", "");
477 let capability = Capability {
478 read: true,
479 write: true,
480 delete: true,
481 ..Default::default()
482 };
483
484 let repo = HfRepo::new(HfRepoType::Dataset, repo_id, Some("main".to_string()));
485
486 Arc::new(
487 HfCore::build(
488 info,
489 capability,
490 repo,
491 "/".to_string(),
492 Some(token),
493 "https://huggingface.co".to_string(),
494 HfDownloadMode::Xet,
495 )
496 .expect("failed to build HfCore"),
497 )
498 }
499
500 pub fn testing_bucket_operator() -> Operator {
501 let repo_id = std::env::var("HF_OPENDAL_BUCKET").expect("HF_OPENDAL_BUCKET must be set");
502 let token = std::env::var("HF_OPENDAL_TOKEN").expect("HF_OPENDAL_TOKEN must be set");
503 let op = Operator::new(
504 HfBuilder::default()
505 .repo_type("bucket")
506 .repo_id(&repo_id)
507 .token(&token),
508 )
509 .unwrap();
510 finish_operator(op)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use std::sync::Mutex;
518
519 static ENV_LOCK: Mutex<()> = Mutex::new(());
521
522 fn builder_with_token(token: &str) -> HfBuilder {
523 HfBuilder::default().token(token)
524 }
525
526 fn builder_no_token() -> HfBuilder {
527 HfBuilder::default()
528 }
529
530 #[test]
531 fn hf_token_config_takes_priority_over_env() {
532 let _guard = ENV_LOCK.lock().unwrap();
533 unsafe { std::env::set_var("HF_TOKEN", "env-token") };
534 let result = builder_with_token("config-token").hf_token();
535 unsafe { std::env::remove_var("HF_TOKEN") };
536 assert_eq!(result.as_deref(), Some("config-token"));
537 }
538
539 #[test]
540 fn hf_token_reads_hf_token_env_var() {
541 let _guard = ENV_LOCK.lock().unwrap();
542 unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
543 unsafe { std::env::remove_var("HF_TOKEN_PATH") };
544 unsafe { std::env::set_var("HF_TOKEN", "my-env-token") };
545 let result = builder_no_token().hf_token();
546 unsafe { std::env::remove_var("HF_TOKEN") };
547 assert_eq!(result.as_deref(), Some("my-env-token"));
548 }
549
550 #[test]
551 fn hf_token_disable_flag_suppresses_discovery() {
552 let _guard = ENV_LOCK.lock().unwrap();
553 unsafe { std::env::set_var("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1") };
554 unsafe { std::env::set_var("HF_TOKEN", "my-env-token") };
555 let result = builder_no_token().hf_token();
556 unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
557 unsafe { std::env::remove_var("HF_TOKEN") };
558 assert_eq!(result, None);
559 }
560
561 #[test]
562 fn hf_token_reads_from_file_via_hf_token_path() {
563 let _guard = ENV_LOCK.lock().unwrap();
564 let token_file = std::env::temp_dir().join("opendal-hf-token-test");
565 std::fs::write(&token_file, "file-token\n").unwrap();
566 unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
567 unsafe { std::env::remove_var("HF_TOKEN") };
568 unsafe { std::env::set_var("HF_TOKEN_PATH", &token_file) };
569 let result = builder_no_token().hf_token();
570 unsafe { std::env::remove_var("HF_TOKEN_PATH") };
571 std::fs::remove_file(&token_file).ok();
572 assert_eq!(result.as_deref(), Some("file-token"));
573 }
574
575 #[test]
576 fn hf_endpoint_trims_trailing_slash() {
577 let _guard = ENV_LOCK.lock().unwrap();
578 unsafe { std::env::remove_var("HF_ENDPOINT") };
579 assert_eq!(
580 HfBuilder::default()
581 .endpoint("https://hub.example.com/")
582 .hf_endpoint(),
583 "https://hub.example.com"
584 );
585 }
586
587 #[test]
588 fn hf_endpoint_returns_default() {
589 let _guard = ENV_LOCK.lock().unwrap();
590 unsafe { std::env::remove_var("HF_ENDPOINT") };
591 let result = HfBuilder::default().hf_endpoint();
592 assert_eq!(result, "https://huggingface.co");
593 }
594
595 #[test]
596 fn hf_endpoint_config_takes_priority_over_env() {
597 let _guard = ENV_LOCK.lock().unwrap();
598 unsafe { std::env::set_var("HF_ENDPOINT", "https://env.example.com") };
599 let result = HfBuilder::default()
600 .endpoint("https://config.example.com")
601 .hf_endpoint();
602 unsafe { std::env::remove_var("HF_ENDPOINT") };
603 assert_eq!(result, "https://config.example.com");
604 }
605
606 #[test]
607 fn hf_endpoint_reads_hf_endpoint_env_var() {
608 let _guard = ENV_LOCK.lock().unwrap();
609 unsafe { std::env::set_var("HF_ENDPOINT", "https://env.example.com") };
610 let result = HfBuilder::default().hf_endpoint();
611 unsafe { std::env::remove_var("HF_ENDPOINT") };
612 assert_eq!(result, "https://env.example.com");
613 }
614
615 #[test]
616 fn hf_download_mode_defaults_to_xet() {
617 let _guard = ENV_LOCK.lock().unwrap();
618 unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
619 assert_eq!(HfBuilder::default().hf_download_mode(), HfDownloadMode::Xet);
620 }
621
622 #[test]
623 fn hf_download_mode_disable_xet_env_forces_http() {
624 let _guard = ENV_LOCK.lock().unwrap();
625 unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "1") };
626 let mode = HfBuilder::default().hf_download_mode();
627 unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
628 assert_eq!(mode, HfDownloadMode::Http);
629 }
630
631 #[test]
632 fn hf_download_mode_config_takes_priority_over_env() {
633 let _guard = ENV_LOCK.lock().unwrap();
634 unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "1") };
635 let mode = HfBuilder::default().download_mode("xet").hf_download_mode();
636 unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
637 assert_eq!(mode, HfDownloadMode::Xet);
638 }
639
640 #[test]
641 fn hf_download_mode_empty_env_keeps_xet() {
642 let _guard = ENV_LOCK.lock().unwrap();
643 unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "") };
644 let mode = HfBuilder::default().hf_download_mode();
645 unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
646 assert_eq!(mode, HfDownloadMode::Xet);
647 }
648
649 #[tokio::test]
650 async fn build_resolve_cache_requires_opt_in() -> Result<()> {
651 use super::super::core::test_utils::MockHttpTransport;
652
653 for enabled in [None, Some(false), Some(true)] {
654 let mut builder = HfBuilder::default()
655 .repo_type("model")
656 .repo_id("org/repo")
657 .download_mode("xet");
658 if let Some(enabled) = enabled {
659 builder = builder.enable_resolve_cache(enabled);
660 }
661 let transport = MockHttpTransport::new();
662 let ctx = OperationContext::new()
663 .with_http_transport(HttpTransporter::new(transport.clone()));
664 let op = Operator::new(builder)?.with_context(ctx);
665 for reads in 1..=2 {
666 assert_eq!(op.read("plain.txt").await?.to_vec(), b"hello");
667 assert_eq!(
668 transport.request_count(),
669 reads + usize::from(enabled == Some(true))
670 );
671 }
672 }
673 Ok(())
674 }
675
676 #[test]
677 fn build_accepts_datasets_alias() {
678 HfBuilder::default()
679 .repo_id("org/repo")
680 .repo_type("datasets")
681 .build()
682 .expect("builder should accept datasets alias");
683 }
684
685 #[test]
686 fn build_accepts_space_repo_type() {
687 HfBuilder::default()
688 .repo_id("org/space")
689 .repo_type("space")
690 .build()
691 .expect("builder should accept space repo type");
692 }
693
694 #[test]
695 fn test_both_schemes_are_supported() {
696 use opendal_core::OperatorRegistry;
697
698 let registry = OperatorRegistry::get();
699 super::super::register_hf_service(registry);
700
701 let op = registry
703 .load("hf://user/repo")
704 .expect("short scheme should be registered and work");
705 assert_eq!(op.info().scheme(), "hf");
706
707 let op = registry
709 .load("huggingface://user/repo")
710 .expect("long scheme should be registered and work");
711 assert_eq!(op.info().scheme(), "hf");
712 }
713}