Skip to main content

provenant/parsers/
huggingface.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Hugging Face model and dataset repository metadata.
5//!
6//! Hugging Face repositories are git repositories whose tracked files describe a
7//! model or dataset. This parser statically extracts what those files prove:
8//!
9//! - **Model card `README.md`** — YAML frontmatter carrying `license`,
10//!   `tags`, `language`, `base_model`, and `datasets` (`library_name` and
11//!   `pipeline_tag` are used only as detection signals, not stored in output).
12//! - **`config.json`** — Transformers model configuration (`model_type`,
13//!   `architectures`, `_name_or_path`).
14//! - **`model_index.json`** — Diffusers pipeline configuration (`_class_name`,
15//!   `_name_or_path`).
16//!
17//! ## Identity is an honest unknown
18//!
19//! The purl-spec `huggingface` type is `pkg:huggingface/<namespace>/<name>@<revision>`,
20//! where `<namespace>/<name>` is the repository id and `<revision>` is the git
21//! commit hash. Neither is reliably stored in tracked files: the repo id lives in
22//! the remote URL / `.git` config and the revision is git state, not a checked-in
23//! artifact. The only checked-in identity hint is `_name_or_path`, which
24//! `save_pretrained` writes as the `<namespace>/<name>` the weights were loaded
25//! from — usually, but not guaranteed to be, the repository's own id.
26//!
27//! Following the project's honest-unknown guidance, this parser emits a
28//! `pkg:huggingface/<namespace>/<name>` purl only when `_name_or_path` has the
29//! unambiguous `<namespace>/<name>` shape. Otherwise it omits the purl and still
30//! reports the provable facts (declared license, base-model and dataset
31//! dependencies, architecture metadata). The revision qualifier is always omitted
32//! because no tracked file proves it.
33
34use std::collections::HashMap;
35use std::path::Path;
36
37use packageurl::PackageUrl;
38use serde_json::Value as JsonValue;
39
40use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
41use crate::parser_warn as warn;
42
43use super::PackageParser;
44use super::license_normalization::normalize_spdx_declared_license;
45use super::utils::{CappedIterExt, read_file_to_string, truncate_field};
46
47/// Parser for a Hugging Face model-card `README.md` (YAML frontmatter).
48pub struct HuggingfaceModelCardParser;
49
50/// Parser for a Hugging Face / Transformers `config.json`.
51pub struct HuggingfaceConfigParser;
52
53/// Parser for a Hugging Face / Diffusers `model_index.json`.
54pub struct HuggingfaceModelIndexParser;
55
56impl PackageParser for HuggingfaceModelCardParser {
57    const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
58
59    fn is_match(path: &Path) -> bool {
60        path.file_name()
61            .and_then(|name| name.to_str())
62            .is_some_and(|name| name.eq_ignore_ascii_case("README.md"))
63    }
64
65    fn extract_packages(path: &Path) -> Vec<PackageData> {
66        let content = match read_file_to_string(path, None) {
67            Ok(content) => content,
68            Err(error) => {
69                warn!(
70                    "Failed to read Hugging Face model card at {:?}: {}",
71                    path, error
72                );
73                return Vec::new();
74            }
75        };
76
77        let Some(frontmatter) = extract_frontmatter(&content) else {
78            return Vec::new();
79        };
80
81        let yaml: yaml_serde::Value = match yaml_serde::from_str(frontmatter) {
82            Ok(yaml) => yaml,
83            Err(error) => {
84                warn!(
85                    "Failed to parse Hugging Face model-card frontmatter at {:?}: {}",
86                    path, error
87                );
88                return Vec::new();
89            }
90        };
91
92        // A generic README with frontmatter (e.g. a static-site post) is not a
93        // model card. Require at least one Hugging Face-specific key before
94        // claiming this file.
95        if !looks_like_model_card(&yaml) {
96            return Vec::new();
97        }
98
99        vec![parse_model_card(&yaml)]
100    }
101
102    fn metadata() -> Vec<super::metadata::ParserMetadata> {
103        vec![super::metadata::ParserMetadata {
104            description: "Hugging Face model-card README frontmatter",
105            file_patterns: &["**/README.md"],
106            package_type: "huggingface",
107            primary_language: "Python",
108            documentation_url: Some(
109                "https://huggingface.co/docs/hub/model-cards#model-card-metadata",
110            ),
111        }]
112    }
113}
114
115impl PackageParser for HuggingfaceConfigParser {
116    const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
117
118    fn is_match(path: &Path) -> bool {
119        path.file_name()
120            .and_then(|name| name.to_str())
121            .is_some_and(|name| name == "config.json")
122    }
123
124    fn extract_packages(path: &Path) -> Vec<PackageData> {
125        let Some(json) = read_json(path, "config.json") else {
126            return Vec::new();
127        };
128
129        if !looks_like_transformers_config(&json) {
130            return Vec::new();
131        }
132
133        vec![parse_config(&json, DatasourceId::HuggingfaceConfigJson)]
134    }
135
136    fn metadata() -> Vec<super::metadata::ParserMetadata> {
137        vec![super::metadata::ParserMetadata {
138            description: "Hugging Face Transformers config.json",
139            file_patterns: &["**/config.json"],
140            package_type: "huggingface",
141            primary_language: "Python",
142            documentation_url: Some(
143                "https://huggingface.co/docs/transformers/main_classes/configuration",
144            ),
145        }]
146    }
147}
148
149impl PackageParser for HuggingfaceModelIndexParser {
150    const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
151
152    fn is_match(path: &Path) -> bool {
153        path.file_name()
154            .and_then(|name| name.to_str())
155            .is_some_and(|name| name == "model_index.json")
156    }
157
158    fn extract_packages(path: &Path) -> Vec<PackageData> {
159        let Some(json) = read_json(path, "model_index.json") else {
160            return Vec::new();
161        };
162
163        if !looks_like_diffusers_index(&json) {
164            return Vec::new();
165        }
166
167        vec![parse_config(&json, DatasourceId::HuggingfaceModelIndexJson)]
168    }
169
170    fn metadata() -> Vec<super::metadata::ParserMetadata> {
171        vec![super::metadata::ParserMetadata {
172            description: "Hugging Face Diffusers model_index.json",
173            file_patterns: &["**/model_index.json"],
174            package_type: "huggingface",
175            primary_language: "Python",
176            documentation_url: Some(
177                "https://huggingface.co/docs/diffusers/using-diffusers/loading#diffusion-pipeline",
178            ),
179        }]
180    }
181}
182
183/// Reads and parses a JSON file, returning `None` on any read/parse failure.
184fn read_json(path: &Path, label: &str) -> Option<JsonValue> {
185    let content = match read_file_to_string(path, None) {
186        Ok(content) => content,
187        Err(error) => {
188            warn!(
189                "Failed to read Hugging Face {} at {:?}: {}",
190                label, path, error
191            );
192            return None;
193        }
194    };
195
196    match serde_json::from_str::<JsonValue>(&content) {
197        Ok(json) => Some(json),
198        Err(error) => {
199            warn!(
200                "Failed to parse Hugging Face {} at {:?}: {}",
201                label, path, error
202            );
203            None
204        }
205    }
206}
207
208/// Extracts the YAML frontmatter block delimited by leading `---` fences.
209///
210/// Hugging Face model cards open with a `---` line, the YAML body, then a closing
211/// `---` line. Returns the body between the fences, or `None` when the document
212/// does not start with a frontmatter block.
213fn extract_frontmatter(content: &str) -> Option<&str> {
214    let trimmed = content.strip_prefix('\u{feff}').unwrap_or(content);
215    let after_open = trimmed
216        .strip_prefix("---\n")
217        .or_else(|| trimmed.strip_prefix("---\r\n"))?;
218
219    // Find the closing fence: a line containing only `---`.
220    let mut search_start = 0;
221    while let Some(rel) = after_open[search_start..].find("---") {
222        let idx = search_start + rel;
223        let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
224        let after = &after_open[idx + 3..];
225        let line_ends = after.is_empty() || after.starts_with('\n') || after.starts_with('\r');
226        if at_line_start && line_ends {
227            return Some(&after_open[..idx]);
228        }
229        search_start = idx + 3;
230    }
231    None
232}
233
234/// Hugging Face model-card frontmatter keys that are distinctive to the
235/// documented model-card metadata spec. These keys do not appear in ordinary
236/// static-site / docs front matter, so any one of them is a sufficient signal
237/// that a `README.md` is a Hugging Face model/dataset card.
238const STRONG_MODEL_CARD_KEYS: &[&str] = &[
239    "library_name",
240    "pipeline_tag",
241    "base_model",
242    "datasets",
243    "model-index",
244    "license_name",
245    "license_link",
246    "model_name",
247    "widget",
248    "co2_eq_emissions",
249];
250
251/// Model-card keys that also occur in generic front matter (Jekyll/Hugo posts,
252/// docs pages). Each alone is too weak to claim a Hugging Face card, but the
253/// combination of several is characteristic of one, so two or more are required.
254const WEAK_MODEL_CARD_KEYS: &[&str] = &[
255    "license",
256    "tags",
257    "language",
258    "metrics",
259    "inference",
260    "thumbnail",
261];
262
263/// Decide whether a `README.md` frontmatter mapping is a Hugging Face model
264/// card. A single strong key is decisive; otherwise at least two weak keys must
265/// be present together so an arbitrary post carrying just `license` or just
266/// `tags` is not over-claimed, while a minimal real card
267/// (e.g. `license` + `tags`) is still recognized.
268fn looks_like_model_card(yaml: &yaml_serde::Value) -> bool {
269    if yaml.as_mapping().is_none() {
270        return false;
271    }
272
273    if STRONG_MODEL_CARD_KEYS
274        .iter()
275        .any(|key| yaml.get(*key).is_some())
276    {
277        return true;
278    }
279
280    let weak_hits = WEAK_MODEL_CARD_KEYS
281        .iter()
282        .filter(|key| yaml.get(**key).is_some())
283        .count();
284    weak_hits >= 2
285}
286
287/// Transformers `config.json` signal keys. Newer configs carry `model_type` /
288/// `architectures` / `transformers_version`; older configs may carry only the
289/// architecture hyperparameters, so accept those too. The combination of these
290/// keys is highly specific to a model configuration.
291const CONFIG_KEYS: &[&str] = &[
292    "model_type",
293    "architectures",
294    "transformers_version",
295    "hidden_size",
296    "num_attention_heads",
297    "num_hidden_layers",
298    "vocab_size",
299    "max_position_embeddings",
300    "intermediate_size",
301];
302
303fn looks_like_transformers_config(json: &JsonValue) -> bool {
304    if !json.is_object() {
305        return false;
306    }
307    CONFIG_KEYS.iter().any(|key| json.get(*key).is_some())
308}
309
310fn looks_like_diffusers_index(json: &JsonValue) -> bool {
311    json.get("_class_name").is_some() || json.get("_diffusers_version").is_some()
312}
313
314fn default_package(datasource_id: DatasourceId) -> PackageData {
315    PackageData {
316        package_type: Some(PackageType::Huggingface),
317        datasource_id: Some(datasource_id),
318        primary_language: Some("Python".to_string()),
319        ..Default::default()
320    }
321}
322
323/// Builds a `pkg:huggingface/<namespace>/<name>` purl when `repo_id` has the
324/// unambiguous `<namespace>/<name>` shape. Returns `(namespace, name, purl)`.
325fn identity_from_repo_id(repo_id: &str) -> Option<(String, String, String)> {
326    let repo_id = repo_id.trim();
327    let mut parts = repo_id.splitn(2, '/');
328    let namespace = parts.next()?.trim();
329    let name = parts.next()?.trim();
330    if namespace.is_empty() || name.is_empty() || name.contains('/') {
331        return None;
332    }
333
334    let mut purl = match PackageUrl::new(PackageType::Huggingface.as_str(), name) {
335        Ok(purl) => purl,
336        Err(error) => {
337            warn!(
338                "Failed to build Hugging Face purl for '{}': {}",
339                repo_id, error
340            );
341            return None;
342        }
343    };
344    if let Err(error) = purl.with_namespace(namespace) {
345        warn!(
346            "Failed to set namespace '{}' on Hugging Face purl: {}",
347            namespace, error
348        );
349        return None;
350    }
351
352    Some((namespace.to_string(), name.to_string(), purl.to_string()))
353}
354
355/// Builds a bare `pkg:huggingface/<namespace>/<name>` dependency purl, or a
356/// single-segment `pkg:huggingface/<name>` purl when no namespace is present.
357fn dependency_purl(reference: &str) -> Option<String> {
358    let reference = reference.trim();
359    if reference.is_empty() {
360        return None;
361    }
362    if let Some((_, _, purl)) = identity_from_repo_id(reference) {
363        return Some(purl);
364    }
365    match PackageUrl::new(PackageType::Huggingface.as_str(), reference) {
366        Ok(purl) => Some(purl.to_string()),
367        Err(error) => {
368            warn!(
369                "Failed to build Hugging Face dependency purl for '{}': {}",
370                reference, error
371            );
372            None
373        }
374    }
375}
376
377fn parse_model_card(yaml: &yaml_serde::Value) -> PackageData {
378    let mut package = default_package(DatasourceId::HuggingfaceModelCard);
379
380    if let Some((namespace, name, purl)) = yaml
381        .get("model_name")
382        .and_then(yaml_serde::Value::as_str)
383        .and_then(identity_from_repo_id)
384    {
385        package.namespace = Some(truncate_field(namespace));
386        package.name = Some(truncate_field(name));
387        package.purl = Some(purl);
388    }
389
390    package.keywords = collect_string_seq(yaml.get("tags"));
391
392    let languages = collect_string_seq(yaml.get("language"));
393    if !languages.is_empty() {
394        let mut extra = HashMap::new();
395        extra.insert(
396            "language".to_string(),
397            serde_json::Value::Array(
398                languages
399                    .into_iter()
400                    .map(serde_json::Value::String)
401                    .collect(),
402            ),
403        );
404        package.extra_data = Some(extra);
405    }
406
407    apply_declared_license(&mut package, model_card_license(yaml));
408    package.dependencies = model_card_dependencies(yaml);
409
410    package
411}
412
413/// Resolves the declared license from a model card. Hugging Face uses `license`
414/// for SPDX-style identifiers and `license_name` for custom licenses. The
415/// `license` field is occasionally written as a single-element list, so accept a
416/// scalar or the first sequence entry.
417fn model_card_license(yaml: &yaml_serde::Value) -> Option<String> {
418    first_scalar_or_seq(yaml.get("license"))
419        .or_else(|| first_scalar_or_seq(yaml.get("license_name")))
420}
421
422/// Returns a scalar string value, or the first string of a sequence value.
423fn first_scalar_or_seq(value: Option<&yaml_serde::Value>) -> Option<String> {
424    collect_string_seq(value).into_iter().next()
425}
426
427fn model_card_dependencies(yaml: &yaml_serde::Value) -> Vec<Dependency> {
428    let mut dependencies = Vec::new();
429
430    for base_model in collect_string_seq(yaml.get("base_model")) {
431        if let Some(purl) = dependency_purl(&base_model) {
432            dependencies.push(Dependency {
433                purl: Some(purl),
434                extracted_requirement: Some(truncate_field(base_model)),
435                scope: Some("base_model".to_string()),
436                is_runtime: None,
437                is_optional: None,
438                is_pinned: None,
439                is_direct: Some(true),
440                resolved_package: None,
441                extra_data: None,
442            });
443        }
444    }
445
446    for dataset in collect_string_seq(yaml.get("datasets")) {
447        if let Some(purl) = dependency_purl(&dataset) {
448            dependencies.push(Dependency {
449                purl: Some(purl),
450                extracted_requirement: Some(truncate_field(dataset)),
451                scope: Some("dataset".to_string()),
452                is_runtime: None,
453                is_optional: None,
454                is_pinned: None,
455                is_direct: Some(true),
456                resolved_package: None,
457                extra_data: None,
458            });
459        }
460    }
461
462    dependencies
463}
464
465fn parse_config(json: &JsonValue, datasource_id: DatasourceId) -> PackageData {
466    let mut package = default_package(datasource_id);
467
468    if let Some((namespace, name, purl)) = json
469        .get("_name_or_path")
470        .and_then(JsonValue::as_str)
471        .filter(|value| !value.is_empty())
472        .and_then(identity_from_repo_id)
473    {
474        package.namespace = Some(truncate_field(namespace));
475        package.name = Some(truncate_field(name));
476        package.purl = Some(purl);
477    }
478
479    let mut extra = HashMap::new();
480    for key in [
481        "model_type",
482        "transformers_version",
483        "_class_name",
484        "_diffusers_version",
485    ] {
486        if let Some(value) = json.get(key).and_then(JsonValue::as_str) {
487            extra.insert(
488                key.to_string(),
489                serde_json::Value::String(truncate_field(value.to_string())),
490            );
491        }
492    }
493    if let Some(architectures) = json.get("architectures").and_then(JsonValue::as_array) {
494        let values: Vec<serde_json::Value> = architectures
495            .iter()
496            .capped("config.json architectures")
497            .filter_map(|value| value.as_str())
498            .map(|value| serde_json::Value::String(truncate_field(value.to_string())))
499            .collect();
500        if !values.is_empty() {
501            extra.insert(
502                "architectures".to_string(),
503                serde_json::Value::Array(values),
504            );
505        }
506    }
507    if !extra.is_empty() {
508        package.extra_data = Some(extra);
509    }
510
511    package
512}
513
514fn apply_declared_license(package: &mut PackageData, license: Option<String>) {
515    let Some(license) = license else {
516        return;
517    };
518    let license = truncate_field(license);
519    if license.is_empty() {
520        return;
521    }
522    package.extracted_license_statement = Some(license.clone());
523    let (declared, declared_spdx, detections) = normalize_spdx_declared_license(Some(&license));
524    package.declared_license_expression = declared;
525    package.declared_license_expression_spdx = declared_spdx;
526    package.license_detections = detections;
527}
528
529/// Collects a YAML value that may be a single string or a sequence of strings
530/// into a `Vec<String>`, skipping non-string and empty entries.
531fn collect_string_seq(value: Option<&yaml_serde::Value>) -> Vec<String> {
532    match value {
533        Some(yaml_serde::Value::String(single)) => {
534            let single = single.trim();
535            if single.is_empty() {
536                Vec::new()
537            } else {
538                vec![truncate_field(single.to_string())]
539            }
540        }
541        Some(seq) => seq
542            .as_sequence()
543            .into_iter()
544            .flatten()
545            .capped("model card string sequence")
546            .filter_map(yaml_serde::Value::as_str)
547            .map(str::trim)
548            .filter(|entry| !entry.is_empty())
549            .map(|entry| truncate_field(entry.to_string()))
550            .collect(),
551        None => Vec::new(),
552    }
553}