HuggingFace glob expansion: research notes
Goal: support SELECT * FROM 'hf://datasets/foo/bar@~parquet/**/*.parquet' so DuckDB-style glob URLs work end-to-end through SQE.
What blocks globs today
After V12.1 (this MR), the SQL rewriter resolves the hf:// URL to its HTTPS form and DataFusion’s enable_url_table() builds a ListingTable against that URL. For globs to expand, two layers must agree:
-
object_store::list(prefix)on the underlying store. DataFusion’sListingTableUrl::list_all_filescallslistto enumerate files matching the glob. The defaultHttpStorefromobject_store::httpcannot enumerate; HTTP has no standard directory-listing protocol. The store returns an empty iterator (or an error) and DataFusion sees “no files match”. -
Glob parsing. DataFusion accepts
**/*.extsyntax inListingTableUrland dispatches tolist_all_files. That part already works; it’s the upstreamlistthat returns nothing.
So the gap is: HuggingFace HTTPS URLs need a working list() implementation. HuggingFace’s tree API gives us exactly that, just not through WebDAV.
HuggingFace tree API
GET https://huggingface.co/api/datasets/<owner>/<name>/tree/<branch>?recursive=true
Returns JSON:
[
{"type": "file", "path": "default/train/0000.parquet", "size": 12345, "oid": "sha"},
{"type": "file", "path": "default/train/0001.parquet", "size": 67890, "oid": "sha"},
{"type": "directory", "path": "default/test", "oid": null}
]
For models and spaces the prefix changes: /api/models/<owner>/<name>/tree/<branch> and /api/spaces/<owner>/<name>/tree/<branch>. The <branch> segment accepts URL-encoded refs, including refs%2Fconvert%2Fparquet for the auto-generated parquet view.
Auth: anonymous for public datasets. Private datasets need Authorization: Bearer $HF_TOKEN.
Rate limits: HuggingFace publishes 1000 requests / 5 minutes per IP for the API. Tree calls are cached server-side; consecutive calls for the same dataset are cheap.
Three approaches
Option A: SQL pre-rewriter expands globs
Extend rewrite_hf_urls_in_sql to detect glob characters and replace the query with a UNION of resolved URLs.
-- Input
SELECT col FROM 'hf://datasets/foo/bar@~parquet/**/*.parquet';
-- Rewritten
SELECT col FROM (
SELECT * FROM 'https://huggingface.co/datasets/foo/bar/resolve/refs%2Fconvert%2Fparquet/default/train/0000.parquet'
UNION ALL
SELECT * FROM 'https://huggingface.co/datasets/foo/bar/resolve/refs%2Fconvert%2Fparquet/default/train/0001.parquet'
);
Pros:
- No new TableProvider; reuses the same V12 SQL-rewrite hook.
- Works for both URL-table auto-detect (
SELECT * FROM 'hf://...') and TVFs (read_parquet('hf://...')). - Easy to test deterministically: fake the HTTP client.
Cons:
- N HTTPS calls become N ListingTable entries. Each opens a separate Parquet reader. DataFusion’s ParquetExec already handles multi-file scans, but the SQL rewrite introduces them as a UNION instead, which is less efficient (the planner sees them as separate sources, not one table).
- Glob expansion happens at SQL parse time, which means the file list gets baked into the query text. Subsequent runs that re-fetch a stale list would not see new files. Cache invalidation left to the user.
- The rewritten SQL gets very long (a 1000-file dataset becomes 1000 UNION arms).
Verdict: workable for small datasets, brittle at scale. Not recommended as the primary path.
Option B: Custom HfObjectStore with working list()
Wrap HuggingFace’s tree API in an object_store::ObjectStore implementation. DataFusion’s ListingTable then uses the default glob path with no other changes.
#![allow(unused)]
fn main() {
pub struct HfObjectStore {
inner_http: Arc<dyn ObjectStore>, // for actual file reads
api_client: reqwest::Client, // for tree API listing
base: String, // "https://huggingface.co"
}
#[async_trait]
impl ObjectStore for HfObjectStore {
async fn list(&self, prefix: Option<&Path>) -> ... {
// 1. Parse `prefix` -> (owner, name, branch, in-repo path)
// 2. GET /api/<kind>/<owner>/<name>/tree/<branch>?recursive=true
// 3. Filter results by in-repo path prefix
// 4. Yield ObjectMeta entries
}
async fn get(&self, location: &Path) -> ... {
// Delegate to inner_http after rewriting hf:// to https://
self.inner_http.get(location).await
}
// ... head, list_with_delimiter, etc.
}
}
Pros:
- Single TableProvider handles arbitrary globs at any depth.
- No SQL surgery. The user’s exact URL flows through unchanged.
- Lists once per query, not once per parse. Stat caching can live in the store.
- Works for
read_parquet,read_csv,read_json, and URL-table auto-detect uniformly.
Cons:
- More code: ~400 lines for the store impl, plus tests.
- Needs registration with the lazy registry:
LazyHttpObjectStoreRegistrywould gain a branch forhf://schemes that builds anHfObjectStoreinstead of a plainHttpStore. - Has to handle pagination if HF caps results (the
treeendpoint returns up to 1000 entries per call; pagination via?cursor=per HF Hub API docs).
Verdict: the right architecture. Aligns with how V10 handles HTTPS via a lazy-built store. Worth building.
Option C: DataFusion TableFactory for hf://
Register a custom TableFactory that DataFusion’s DynamicFileCatalog calls when it sees 'hf://...'. The factory:
- Parses the URL
- Calls HF tree API directly
- Builds a
ListingTablewith the expanded file list
Pros:
- Cleaner separation: hf:// handling lives in one place.
- No object_store gymnastics.
Cons:
- DynamicFileCatalog’s extension API (per DataFusion 53) is private.
register_factoryis not public. Would require either a fork patch or upstreaming. - Doesn’t help the TVF path (
read_parquet('hf://**/*.parquet')); that goes through different machinery.
Verdict: blocked on upstream API exposure. Skip.
Recommended path
Implement Option B. Concretely:
- New module
sqe-catalog/src/hf_object_store.rs(~300-400 lines). - Implements
ObjectStoretrait. Constructor:HfObjectStore::new(repo_kind, owner, name, branch, http_client). get,get_range,head: rewritePathagainst the resolved HTTPS form, delegate to innerHttpStore.list,list_with_delimiter: call HF tree API, parse JSON, filter by prefix, yieldObjectMeta.LazyHttpObjectStoreRegistry::get_storelearns to detecthf://scheme and build anHfObjectStoreinstead of anHttpStore. Cache per(repo_kind, owner, name, branch).- Tests:
- Unit tests with
wiremockfaking the tree API: glob expansion, prefix filtering, pagination, branch refs with slashes (refs/convert/parquet). - Integration test against a known small HuggingFace dataset (
#[ignore]so CI does not hit network unprompted).
- Unit tests with
SQL changes after Option B lands
The V12 SQL rewriter (this MR) translates hf:// to https://huggingface.co/... so DataFusion sees an https URL. With Option B, we want DataFusion to see the original hf:// URL so it picks the HfObjectStore from the registry.
Two cleanup options:
- Stop rewriting hf:// in SQL when Option B lands. The HfObjectStore handles the URL natively. Remove
rewrite_hf_urls_in_sql. The TVFs keep their internalrewrite_hf_path_in_place(they need the resolved URL because they manually callregister_http_store_if_needed). - Keep both paths. SQL rewrite stays as a fallback when
LazyHttpObjectStoreRegistryis not active (e.g., a harness-only test context).
Recommendation: drop the SQL rewrite once Option B lands. The lazy registry is the natural integration point; keeping both is dual maintenance.
Effort estimate
| Task | Lines | Effort |
|---|---|---|
hf_object_store.rs | ~350 | M |
LazyHttpObjectStoreRegistry integration | ~50 | S |
| Tests (wiremock + ignored network) | ~250 | M |
rewrite_hf_urls_in_sql removal | ~30 | S |
| Docs | ~50 | S |
Roughly a 1-2 day MR. Slot as V12.2 once V12 (this MR) and V12.1 (the @~parquet extension landing in this same commit) merge.
What we are NOT doing
- Caching the tree response across queries. First query pays the API call; if the user runs the same glob twice in the same session, the second query hits the server again. Adding a TTL cache is a small follow-up.
- HF_TOKEN env var pickup for private datasets. V10 documented this as deferred. Public datasets work today; private datasets need an explicit auth provider, which lands separately.
- DuckDB’s
?ext=parquetshortcut. Their httpfs acceptshf://...?ext=parquetto skip the glob and let HF route to whichever file matches. We do not implement this; the auto-generated~parquetview + glob is the equivalent path.
References
- HuggingFace Hub API: https://huggingface.co/docs/hub/api
- DuckDB
hf://extension blog: https://duckdb.org/2024/05/29/access-150k-plus-datasets-from-hugging-face-with-duckdb - DataFusion ListingTableUrl: https://datafusion.apache.org/library-user-guide/working-with-data-sources/datasource.html
- object_store ObjectStore trait: https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html