Skip to content

Commit c915e1e

Browse files
authored
Merge pull request #71 from chrishayuk/hf-model-repo-pull-rebased
fix: allow pulling vindexes from HF model repos (rebased from #59)
2 parents 4d7ca8f + d5bc694 commit c915e1e

1 file changed

Lines changed: 143 additions & 101 deletions

File tree

  • crates/larql-vindex/src/format/huggingface/download

crates/larql-vindex/src/format/huggingface/download/mod.rs

Lines changed: 143 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,41 @@ impl RepoKind {
4242
RepoKind::Model => "models--",
4343
}
4444
}
45+
46+
fn to_hub_type(self) -> hf_hub::RepoType {
47+
match self {
48+
RepoKind::Dataset => hf_hub::RepoType::Dataset,
49+
RepoKind::Model => hf_hub::RepoType::Model,
50+
}
51+
}
52+
}
53+
54+
/// Order in which `larql pull` probes HF for an `hf://owner/name` path.
55+
/// `larql publish` defaults to `repo_type = "model"`, so model is tried
56+
/// first; dataset stays as the fallback for older vindexes that were
57+
/// uploaded before the publish default flipped (and for docs examples
58+
/// that pin `--repo-type dataset`).
59+
const HF_PULL_REPO_KINDS: [RepoKind; 2] = [RepoKind::Model, RepoKind::Dataset];
60+
61+
/// Build a typed `ApiRepo` handle for a given `(repo_id, revision, kind)`.
62+
/// Centralised so the three pull entry points share one constructor and
63+
/// the with/without-revision branching lives in one place.
64+
fn hf_repo(
65+
api: &hf_hub::api::sync::Api,
66+
repo_id: &str,
67+
revision: Option<&str>,
68+
kind: RepoKind,
69+
) -> hf_hub::api::sync::ApiRepo {
70+
let repo_type = kind.to_hub_type();
71+
if let Some(rev) = revision {
72+
api.repo(hf_hub::Repo::with_revision(
73+
repo_id.to_string(),
74+
repo_type,
75+
rev.to_string(),
76+
))
77+
} else {
78+
api.repo(hf_hub::Repo::new(repo_id.to_string(), repo_type))
79+
}
4580
}
4681

4782
/// Resolve an `hf://` path to a local directory, downloading if needed.
@@ -68,26 +103,32 @@ pub fn resolve_hf_vindex(hf_path: &str) -> Result<PathBuf, VindexError> {
68103
let api = hf_hub::api::sync::Api::new()
69104
.map_err(|e| VindexError::Parse(format!("HuggingFace API init failed: {e}")))?;
70105

71-
let repo = if let Some(ref rev) = revision {
72-
api.repo(hf_hub::Repo::with_revision(
73-
repo_id.clone(),
74-
hf_hub::RepoType::Dataset,
75-
rev.clone(),
76-
))
77-
} else {
78-
api.repo(hf_hub::Repo::new(
79-
repo_id.clone(),
80-
hf_hub::RepoType::Dataset,
81-
))
82-
};
83-
84-
// Download index.json first (small, tells us what we need)
85-
let index_path = repo.get(INDEX_JSON).map_err(|e| {
86-
VindexError::Parse(format!(
87-
"failed to download index.json from hf://{}: {e}",
88-
repo_id
89-
))
90-
})?;
106+
// `larql publish` defaults to model repos, but older vindexes and
107+
// some docs examples live as dataset repos. Probe in publish-default
108+
// order; the first kind that yields index.json wins, the rest are
109+
// skipped.
110+
let mut last_err: Option<String> = None;
111+
let (repo, index_path) = HF_PULL_REPO_KINDS
112+
.into_iter()
113+
.find_map(|kind| {
114+
let repo = hf_repo(&api, &repo_id, revision.as_deref(), kind);
115+
match repo.get(INDEX_JSON) {
116+
Ok(path) => Some((repo, path)),
117+
Err(e) => {
118+
last_err = Some(e.to_string());
119+
None
120+
}
121+
}
122+
})
123+
.ok_or_else(|| {
124+
let suffix = last_err
125+
.as_deref()
126+
.map(|e| format!(": {e}"))
127+
.unwrap_or_default();
128+
VindexError::Parse(format!(
129+
"failed to download index.json from hf://{repo_id}{suffix}"
130+
))
131+
})?;
91132

92133
let vindex_dir = index_path
93134
.parent()
@@ -121,24 +162,24 @@ pub fn download_hf_weights(hf_path: &str) -> Result<(), VindexError> {
121162
let api = hf_hub::api::sync::Api::new()
122163
.map_err(|e| VindexError::Parse(format!("HuggingFace API init failed: {e}")))?;
123164

124-
let repo = if let Some(ref rev) = revision {
125-
api.repo(hf_hub::Repo::with_revision(
126-
repo_id.clone(),
127-
hf_hub::RepoType::Dataset,
128-
rev.clone(),
129-
))
130-
} else {
131-
api.repo(hf_hub::Repo::new(
132-
repo_id.clone(),
133-
hf_hub::RepoType::Dataset,
134-
))
135-
};
136-
137-
for filename in VINDEX_WEIGHT_FILES {
138-
let _ = repo.get(filename); // optional, skip if not in repo
165+
// Same model-first-then-dataset probe order as `resolve_hf_vindex`.
166+
// We use index.json as the "does this repo type exist?" probe so we
167+
// don't accidentally fetch weight files from a stale dataset repo
168+
// when the live vindex lives on the model side.
169+
for kind in HF_PULL_REPO_KINDS {
170+
let repo = hf_repo(&api, &repo_id, revision.as_deref(), kind);
171+
if repo.get(INDEX_JSON).is_err() {
172+
continue;
173+
}
174+
for filename in VINDEX_WEIGHT_FILES {
175+
let _ = repo.get(filename); // optional, skip if not in repo
176+
}
177+
return Ok(());
139178
}
140179

141-
Ok(())
180+
Err(VindexError::Parse(format!(
181+
"failed to fetch index.json from hf://{repo_id}"
182+
)))
142183
}
143184

144185
/// Re-exported from hf-hub 0.5 so callers don't have to depend on
@@ -306,58 +347,57 @@ where
306347
let api = hf_hub::api::sync::Api::new()
307348
.map_err(|e| VindexError::Parse(format!("HuggingFace API init failed: {e}")))?;
308349

309-
let repo = if let Some(ref rev) = revision {
310-
api.repo(hf_hub::Repo::with_revision(
311-
repo_id.clone(),
312-
hf_hub::RepoType::Dataset,
313-
rev.clone(),
314-
))
315-
} else {
316-
api.repo(hf_hub::Repo::new(
317-
repo_id.clone(),
318-
hf_hub::RepoType::Dataset,
319-
))
320-
};
321-
322-
// Helper: one file, with cache short-circuit. Returns the resolved
323-
// on-disk path. The cache check fires the progress reporter so the
324-
// bar shows a filled-to-100% track tagged with the filename — users
325-
// see that the file was served from cache, not re-downloaded.
326-
let mut fetch = |filename: &str, label: &str| -> Option<PathBuf> {
327-
if let Some((cached_path, size)) =
328-
cached_snapshot_file(RepoKind::Dataset, &repo_id, revision.as_deref(), filename)
329-
{
330-
// Tag the progress message so the bar visibly distinguishes
331-
// "cached" from "just downloaded very fast". Callers rendering
332-
// the bar see the prefix at init time and can restyle.
333-
let mut p = progress(label);
334-
let tagged = format!("{filename} [cached]");
335-
p.init(size as usize, &tagged);
336-
p.update(size as usize);
337-
p.finish();
338-
return Some(cached_path);
339-
}
340-
repo.download_with_progress(filename, progress(label)).ok()
341-
};
342-
343-
// index.json drives everything — we need its snapshot dir to know
344-
// where the rest of the files live. Cache-hit or download.
345-
let index_path = fetch(INDEX_JSON, INDEX_JSON).ok_or_else(|| {
346-
VindexError::Parse(format!("failed to fetch index.json from hf://{repo_id}"))
347-
})?;
348-
let vindex_dir = index_path
349-
.parent()
350-
.ok_or_else(|| VindexError::Parse("cannot determine vindex directory".into()))?
351-
.to_path_buf();
350+
// Probe each repo kind in publish-default order. The first kind that
351+
// returns index.json (cache hit or download) is the winner; we then
352+
// fetch the rest of `VINDEX_CORE_FILES` from that same handle.
353+
for kind in HF_PULL_REPO_KINDS {
354+
let repo = hf_repo(&api, &repo_id, revision.as_deref(), kind);
355+
356+
// Helper: one file, with cache short-circuit. Returns the resolved
357+
// on-disk path. The cache check fires the progress reporter so the
358+
// bar shows a filled-to-100% track tagged with the filename — users
359+
// see that the file was served from cache, not re-downloaded.
360+
let mut fetch = |filename: &str, label: &str| -> Option<PathBuf> {
361+
if let Some((cached_path, size)) =
362+
cached_snapshot_file(kind, &repo_id, revision.as_deref(), filename)
363+
{
364+
// Tag the progress message so the bar visibly distinguishes
365+
// "cached" from "just downloaded very fast". Callers rendering
366+
// the bar see the prefix at init time and can restyle.
367+
let mut p = progress(label);
368+
let tagged = format!("{filename} [cached]");
369+
p.init(size as usize, &tagged);
370+
p.update(size as usize);
371+
p.finish();
372+
return Some(cached_path);
373+
}
374+
repo.download_with_progress(filename, progress(label)).ok()
375+
};
352376

353-
for filename in VINDEX_CORE_FILES {
354-
if *filename == INDEX_JSON {
377+
// index.json drives everything — we need its snapshot dir to know
378+
// where the rest of the files live. If this kind doesn't have it,
379+
// try the next kind.
380+
let Some(index_path) = fetch(INDEX_JSON, INDEX_JSON) else {
355381
continue;
382+
};
383+
let vindex_dir = index_path
384+
.parent()
385+
.ok_or_else(|| VindexError::Parse("cannot determine vindex directory".into()))?
386+
.to_path_buf();
387+
388+
for filename in VINDEX_CORE_FILES {
389+
if *filename == INDEX_JSON {
390+
continue;
391+
}
392+
// Optional files — ignore failures (missing from repo is fine).
393+
let _ = fetch(filename, filename);
356394
}
357-
// Optional files — ignore failures (missing from repo is fine).
358-
let _ = fetch(filename, filename);
395+
return Ok(vindex_dir);
359396
}
360-
Ok(vindex_dir)
397+
398+
Err(VindexError::Parse(format!(
399+
"failed to fetch index.json from hf://{repo_id}"
400+
)))
361401
}
362402

363403
/// Resolve an `hf://` model repo path to a local snapshot directory,
@@ -571,18 +611,15 @@ mod tests {
571611

572612
#[test]
573613
#[serial]
574-
fn resolve_hf_vindex_errors_on_404_index_json() {
575-
// mockito returns 404 for /datasets/owner/repo/resolve/main/index.json
576-
// → repo.get(INDEX_JSON) errors → resolve_hf_vindex returns
577-
// the wrapped "failed to download index.json" error. Exercises:
578-
// hf:// strip, no-revision branch, Api::new(), repo.get error path.
614+
fn resolve_hf_vindex_errors_when_both_repo_kinds_404() {
615+
// mockito returns 404 for every URL → both Model and Dataset
616+
// probes fail in turn → resolve_hf_vindex returns the wrapped
617+
// "failed to download index.json" error. Exercises: hf:// strip,
618+
// no-revision branch, Api::new(), full HF_PULL_REPO_KINDS loop.
579619
let mut server = mockito::Server::new();
580620
let _g = HfTestEnv::new(&server.url());
581621
let _m = server
582-
.mock(
583-
"GET",
584-
mockito::Matcher::Regex(r"/datasets/owner/repo/resolve/.*/index\.json".into()),
585-
)
622+
.mock("GET", mockito::Matcher::Any)
586623
.with_status(404)
587624
.create();
588625

@@ -597,14 +634,14 @@ mod tests {
597634
#[serial]
598635
fn resolve_hf_vindex_errors_with_revision_pinned() {
599636
// Same as above but with `@v2.0` revision. The split path takes
600-
// a different `repo` constructor (with_revision) — verify both
601-
// branches by exercising them with the same 404 mock.
637+
// a different `repo` constructor (with_revision) — verify the
638+
// revision-bearing branch with the same all-404 mock.
602639
let mut server = mockito::Server::new();
603640
let _g = HfTestEnv::new(&server.url());
604641
let _m = server
605642
.mock(
606643
"GET",
607-
mockito::Matcher::Regex(r"/datasets/owner/repo/resolve/v2\.0/index\.json".into()),
644+
mockito::Matcher::Regex(r"/resolve/v2\.0/index\.json".into()),
608645
)
609646
.with_status(404)
610647
.create();
@@ -618,18 +655,23 @@ mod tests {
618655

619656
#[test]
620657
#[serial]
621-
fn download_hf_weights_silently_skips_missing_files() {
622-
// download_hf_weights iterates VINDEX_WEIGHT_FILES with `let _ =
623-
// repo.get(filename)` — every miss is silenced. Pin that contract:
624-
// even when every file 404s, the function returns Ok(()).
658+
fn download_hf_weights_errors_when_no_repo_kind_has_index_json() {
659+
// `download_hf_weights` now uses index.json as the "does this repo
660+
// type exist?" probe. When both Model and Dataset 404 on
661+
// index.json, the function returns the "failed to fetch
662+
// index.json" error rather than silently succeeding.
625663
let mut server = mockito::Server::new();
626664
let _g = HfTestEnv::new(&server.url());
627665
let _m = server
628666
.mock("GET", mockito::Matcher::Any)
629667
.with_status(404)
630668
.create();
631669

632-
download_hf_weights("hf://owner/repo").expect("missing files are non-fatal");
670+
let err = download_hf_weights("hf://owner/repo").expect_err("no index.json on either side");
671+
assert!(
672+
err.to_string().contains("failed to fetch index.json"),
673+
"got: {err}"
674+
);
633675
}
634676

635677
#[test]

0 commit comments

Comments
 (0)