Skip to content

Commit 296979a

Browse files
committed
feat(hf): add support for buckets repository type
1 parent 24a0eed commit 296979a

5 files changed

Lines changed: 319 additions & 62 deletions

File tree

core/services/hf/src/backend.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ impl HfBuilder {
4545
/// - dataset
4646
/// - datasets (alias for dataset)
4747
/// - space
48+
/// - bucket
4849
///
4950
/// [Reference](https://huggingface.co/docs/hub/repositories)
5051
pub fn repo_type(mut self, repo_type: &str) -> Self {
@@ -298,6 +299,13 @@ pub(super) mod test_utils {
298299
(repo_id, token)
299300
}
300301

302+
#[cfg(feature = "xet")]
303+
pub fn testing_bucket_credentials() -> (String, String) {
304+
let repo_id = std::env::var("HF_OPENDAL_BUCKET").expect("HF_OPENDAL_BUCKET must be set");
305+
let token = std::env::var("HF_OPENDAL_TOKEN").expect("HF_OPENDAL_TOKEN must be set");
306+
(repo_id, token)
307+
}
308+
301309
/// Operator for a private dataset requiring HF_OPENDAL_DATASET and HF_OPENDAL_TOKEN.
302310
/// Uses higher max_retries to tolerate concurrent commit conflicts (412).
303311
pub fn testing_operator() -> Operator {
@@ -330,6 +338,24 @@ pub(super) mod test_utils {
330338
finish_operator(op)
331339
}
332340

341+
/// Operator for a bucket requiring HF_OPENDAL_BUCKET and HF_OPENDAL_TOKEN.
342+
/// Buckets always use XET for writes.
343+
#[cfg(feature = "xet")]
344+
pub fn testing_bucket_operator() -> Operator {
345+
let (repo_id, token) = testing_bucket_credentials();
346+
let op = Operator::new(
347+
HfBuilder::default()
348+
.repo_type("bucket")
349+
.repo_id(&repo_id)
350+
.token(&token)
351+
.enable_xet()
352+
.max_retries(10),
353+
)
354+
.unwrap()
355+
.finish();
356+
finish_operator(op)
357+
}
358+
333359
pub fn gpt2_operator() -> Operator {
334360
let op = Operator::new(
335361
HfBuilder::default()

core/services/hf/src/core.rs

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ pub(super) struct DeletedFile {
8686
pub path: String,
8787
}
8888

89+
/// Bucket batch operation payload structures
90+
#[cfg(feature = "xet")]
91+
#[derive(Debug, serde::Serialize)]
92+
#[serde(tag = "type", rename_all = "camelCase")]
93+
pub(super) enum BucketOperation {
94+
#[serde(rename_all = "camelCase")]
95+
AddFile { path: String, xet_hash: String },
96+
#[serde(rename_all = "camelCase")]
97+
#[allow(dead_code)]
98+
DeleteFile { path: String },
99+
}
100+
89101
#[derive(serde::Serialize)]
90102
pub(super) struct MixedCommitPayload {
91103
pub summary: String,
@@ -113,7 +125,8 @@ pub(super) struct CommitResponse {
113125
pub(super) struct PathInfo {
114126
#[serde(rename = "type")]
115127
pub type_: String,
116-
pub oid: String,
128+
#[serde(default)]
129+
pub oid: Option<String>,
117130
pub size: u64,
118131
#[serde(default)]
119132
pub lfs: Option<LfsInfo>,
@@ -141,12 +154,12 @@ impl PathInfo {
141154

142155
if mode == EntryMode::FILE {
143156
meta.set_content_length(self.size);
144-
let etag = if let Some(lfs) = &self.lfs {
145-
&lfs.oid
146-
} else {
147-
&self.oid
148-
};
149-
meta.set_etag(etag);
157+
// For buckets, oid may be None; for regular repos, prefer lfs.oid then oid
158+
if let Some(lfs) = &self.lfs {
159+
meta.set_etag(&lfs.oid);
160+
} else if let Some(oid) = &self.oid {
161+
meta.set_etag(oid);
162+
}
150163
}
151164

152165
Ok(meta)
@@ -511,6 +524,46 @@ impl HfCore {
511524
let (_, resp) = self.send_parse::<CommitResponse>(req).await?;
512525
Ok(resp)
513526
}
527+
528+
/// Upload files to a bucket using the batch API.
529+
///
530+
/// Sends operations as JSON lines (one operation per line).
531+
#[cfg(feature = "xet")]
532+
pub(super) async fn bucket_batch(&self, operations: Vec<BucketOperation>) -> Result<()> {
533+
let _token = self.token.as_deref().ok_or_else(|| {
534+
Error::new(
535+
ErrorKind::PermissionDenied,
536+
"token is required for bucket operations",
537+
)
538+
.with_operation("bucket_batch")
539+
})?;
540+
541+
if operations.is_empty() {
542+
return Err(Error::new(
543+
ErrorKind::Unexpected,
544+
"no operations to perform",
545+
));
546+
}
547+
548+
let url = self.repo.bucket_batch_url(&self.endpoint);
549+
550+
let mut body = String::new();
551+
for op in operations {
552+
let json = serde_json::to_string(&op).map_err(new_json_serialize_error)?;
553+
body.push_str(&json);
554+
body.push('\n');
555+
}
556+
557+
let req = self
558+
.request(http::Method::POST, &url, Operation::Write)
559+
.header(header::CONTENT_TYPE, "application/x-ndjson")
560+
.header(header::CONTENT_LENGTH, body.len())
561+
.body(Buffer::from(Bytes::from(body)))
562+
.map_err(new_request_build_error)?;
563+
564+
self.send(req).await?;
565+
Ok(())
566+
}
514567
}
515568

516569
#[cfg(test)]

core/services/hf/src/reader.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ use futures::StreamExt;
2727
use super::core::HfCore;
2828
#[cfg(feature = "xet")]
2929
use super::core::map_xet_error;
30+
#[cfg(feature = "xet")]
31+
use super::uri::RepoType;
3032
use opendal_core::raw::*;
3133
use opendal_core::*;
3234
#[cfg(feature = "xet")]
@@ -43,12 +45,24 @@ pub enum HfReader {
4345
impl HfReader {
4446
/// Create a reader, automatically choosing between XET and HTTP.
4547
///
46-
/// When XET is enabled a HEAD request probes for the `X-Xet-Hash`
47-
/// header. Files stored on XET are downloaded via the CAS protocol;
48-
/// all others fall back to a regular HTTP GET.
48+
/// Buckets always use XET. For other repo types, when XET is enabled
49+
/// a HEAD request probes for the `X-Xet-Hash` header. Files stored on
50+
/// XET are downloaded via the CAS protocol; all others fall back to HTTP GET.
4951
pub async fn try_new(core: &HfCore, path: &str, range: BytesRange) -> Result<Self> {
5052
#[cfg(feature = "xet")]
5153
if core.xet_enabled {
54+
// Buckets always use XET
55+
if core.repo.repo_type == RepoType::Bucket {
56+
if let Some(xet_file) = core.maybe_xet_file(path).await? {
57+
return Self::try_new_xet(core, &xet_file, range).await;
58+
}
59+
return Err(Error::new(
60+
ErrorKind::Unexpected,
61+
"bucket file is missing XET metadata",
62+
));
63+
}
64+
65+
// For other repos, probe for XET
5266
if let Some(xet_file) = core.maybe_xet_file(path).await? {
5367
return Self::try_new_xet(core, &xet_file, range).await;
5468
}

core/services/hf/src/uri.rs

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use serde::Serialize;
2222
use super::HUGGINGFACE_SCHEME;
2323
use opendal_core::raw::*;
2424

25-
/// Repository type of Huggingface. Supports `model`, `dataset`, and `space`.
25+
/// Repository type of Huggingface. Supports `model`, `dataset`, `space`, and `bucket`.
2626
/// [Reference](https://huggingface.co/docs/hub/repositories)
2727
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2828
#[serde(rename_all = "lowercase")]
@@ -31,6 +31,7 @@ pub enum RepoType {
3131
Model,
3232
Dataset,
3333
Space,
34+
Bucket,
3435
}
3536

3637
impl RepoType {
@@ -39,6 +40,7 @@ impl RepoType {
3940
"model" | "models" => Ok(Self::Model),
4041
"dataset" | "datasets" => Ok(Self::Dataset),
4142
"space" | "spaces" => Ok(Self::Space),
43+
"bucket" | "buckets" => Ok(Self::Bucket),
4244
other => Err(opendal_core::Error::new(
4345
opendal_core::ErrorKind::ConfigInvalid,
4446
format!("unknown repo type: {other}"),
@@ -52,6 +54,7 @@ impl RepoType {
5254
Self::Model => "model",
5355
Self::Dataset => "dataset",
5456
Self::Space => "space",
57+
Self::Bucket => "bucket",
5558
}
5659
}
5760

@@ -60,6 +63,7 @@ impl RepoType {
6063
Self::Model => "models",
6164
Self::Dataset => "datasets",
6265
Self::Space => "spaces",
66+
Self::Bucket => "buckets",
6367
}
6468
}
6569
}
@@ -98,42 +102,49 @@ impl HfRepo {
98102

99103
/// Build the paths-info API URL for this repository.
100104
pub fn paths_info_url(&self, endpoint: &str) -> String {
101-
format!(
102-
"{}/api/{}/{}/paths-info/{}",
103-
endpoint,
104-
self.repo_type.as_plural_str(),
105-
&self.repo_id,
106-
percent_encode_revision(self.revision()),
107-
)
108-
}
109-
110-
/// Build the Git LFS batch API URL for this repository.
111-
///
112-
/// Pattern: `{endpoint}/{type_prefix}{repo_id}.git/info/lfs/objects/batch`
113-
/// where type_prefix is "" for models, "datasets/" for datasets, "spaces/" for spaces.
114-
pub fn lfs_batch_url(&self, endpoint: &str) -> String {
115-
let type_prefix = match self.repo_type {
116-
RepoType::Model => "",
117-
RepoType::Dataset => "datasets/",
118-
RepoType::Space => "spaces/",
119-
};
120-
format!(
121-
"{}/{}{}.git/info/lfs/objects/batch",
122-
endpoint, type_prefix, &self.repo_id,
123-
)
105+
match self.repo_type {
106+
RepoType::Bucket => {
107+
format!("{}/api/buckets/{}/paths-info", endpoint, &self.repo_id)
108+
}
109+
_ => {
110+
format!(
111+
"{}/api/{}/{}/paths-info/{}",
112+
endpoint,
113+
self.repo_type.as_plural_str(),
114+
&self.repo_id,
115+
percent_encode_revision(self.revision()),
116+
)
117+
}
118+
}
124119
}
125120

126121
/// Build the XET token API URL for this repository.
127122
#[cfg(feature = "xet")]
128123
pub fn xet_token_url(&self, endpoint: &str, token_type: &str) -> String {
129-
format!(
130-
"{}/api/{}/{}/xet-{}-token/{}",
131-
endpoint,
132-
self.repo_type.as_plural_str(),
133-
&self.repo_id,
134-
token_type,
135-
self.revision(),
136-
)
124+
match self.repo_type {
125+
RepoType::Bucket => {
126+
format!(
127+
"{}/api/buckets/{}/xet-{}-token",
128+
endpoint, &self.repo_id, token_type
129+
)
130+
}
131+
_ => {
132+
format!(
133+
"{}/api/{}/{}/xet-{}-token/{}",
134+
endpoint,
135+
self.repo_type.as_plural_str(),
136+
&self.repo_id,
137+
token_type,
138+
self.revision(),
139+
)
140+
}
141+
}
142+
}
143+
144+
/// Build the bucket batch API URL for this repository.
145+
#[cfg(feature = "xet")]
146+
pub fn bucket_batch_url(&self, endpoint: &str) -> String {
147+
format!("{}/api/buckets/{}/batch", endpoint, &self.repo_id)
137148
}
138149
}
139150

@@ -275,6 +286,12 @@ impl HfUri {
275286
endpoint, &self.repo.repo_id, revision, path
276287
)
277288
}
289+
RepoType::Bucket => {
290+
format!(
291+
"{}/buckets/{}/resolve/{}",
292+
endpoint, &self.repo.repo_id, path
293+
)
294+
}
278295
}
279296
}
280297

@@ -535,4 +552,56 @@ mod tests {
535552
assert!(p.repo.revision.is_none());
536553
assert_eq!(p.path, "");
537554
}
555+
556+
#[test]
557+
fn resolve_buckets_prefix() {
558+
let p = resolve("buckets/username/my_bucket");
559+
assert_eq!(p.repo.repo_type, RepoType::Bucket);
560+
assert_eq!(p.repo.repo_id, "username/my_bucket");
561+
assert!(p.repo.revision.is_none());
562+
assert_eq!(p.path, "");
563+
}
564+
565+
#[test]
566+
fn resolve_buckets_with_path() {
567+
let p = resolve("buckets/username/my_bucket/data/file.txt");
568+
assert_eq!(p.repo.repo_type, RepoType::Bucket);
569+
assert_eq!(p.repo.repo_id, "username/my_bucket");
570+
assert!(p.repo.revision.is_none());
571+
assert_eq!(p.path, "data/file.txt");
572+
}
573+
574+
#[test]
575+
fn test_bucket_resolve_url() {
576+
let p = resolve("buckets/user/bucket/file.txt");
577+
let url = p.resolve_url("https://huggingface.co");
578+
assert_eq!(
579+
url,
580+
"https://huggingface.co/buckets/user/bucket/resolve/file.txt"
581+
);
582+
}
583+
584+
#[test]
585+
#[cfg(feature = "xet")]
586+
fn test_bucket_xet_token_urls() {
587+
let p = resolve("buckets/user/bucket");
588+
let read_url = p.repo.xet_token_url("https://huggingface.co", "read");
589+
let write_url = p.repo.xet_token_url("https://huggingface.co", "write");
590+
assert_eq!(
591+
read_url,
592+
"https://huggingface.co/api/buckets/user/bucket/xet-read-token"
593+
);
594+
assert_eq!(
595+
write_url,
596+
"https://huggingface.co/api/buckets/user/bucket/xet-write-token"
597+
);
598+
}
599+
600+
#[test]
601+
#[cfg(feature = "xet")]
602+
fn test_bucket_batch_url() {
603+
let p = resolve("buckets/user/bucket");
604+
let url = p.repo.bucket_batch_url("https://huggingface.co");
605+
assert_eq!(url, "https://huggingface.co/api/buckets/user/bucket/batch");
606+
}
538607
}

0 commit comments

Comments
 (0)