#!/usr/bin/env python3 """Build and publish a Neo4j dump from the public cfahlgren1/hub-stats dataset.""" from __future__ import annotations import argparse import json import os import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path import duckdb from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download SOURCE_REPO = "cfahlgren1/hub-stats" DEFAULT_DUMP_REPO = "cnil/genmod-dump-neo4j" PARQUET_REVISION = "refs/convert/parquet" def log(message: str) -> None: print(f"[genmod-refresh] {message}", flush=True) def download_sources(work_dir: Path) -> tuple[Path, Path]: cache_dir = work_dir / "hf-cache" log(f"Downloading the model snapshot from {SOURCE_REPO}") models = Path( hf_hub_download( repo_id=SOURCE_REPO, repo_type="dataset", revision=PARQUET_REVISION, filename="models/train/0000.parquet", cache_dir=cache_dir, ) ) log(f"Downloading the dataset snapshot from {SOURCE_REPO}") datasets = Path( hf_hub_download( repo_id=SOURCE_REPO, repo_type="dataset", revision=PARQUET_REVISION, filename="datasets/train/0000.parquet", cache_dir=cache_dir, ) ) return models, datasets def sql_path(path: Path) -> str: return str(path).replace("'", "''") def create_views( connection: duckdb.DuckDBPyConnection, models_path: Path, datasets_path: Path, max_models: int | None, max_datasets: int | None, ) -> None: model_limit = f" LIMIT {max_models}" if max_models else "" dataset_limit = f" LIMIT {max_datasets}" if max_datasets else "" connection.execute( f""" CREATE VIEW source_models AS SELECT * EXCLUDE (_dedupe_rank) FROM ( SELECT *, row_number() OVER ( PARTITION BY id ORDER BY lastModified DESC NULLS LAST, _id DESC NULLS LAST ) AS _dedupe_rank FROM read_parquet('{sql_path(models_path)}') WHERE id IS NOT NULL AND trim(id) <> '' ) WHERE _dedupe_rank = 1 {model_limit} """ ) connection.execute( f""" CREATE VIEW source_datasets AS SELECT * EXCLUDE (_dedupe_rank) FROM ( SELECT *, row_number() OVER ( PARTITION BY id ORDER BY lastModified DESC NULLS LAST, _id DESC NULLS LAST ) AS _dedupe_rank FROM read_parquet('{sql_path(datasets_path)}') WHERE id IS NOT NULL AND trim(id) <> '' ) WHERE _dedupe_rank = 1 {dataset_limit} """ ) connection.execute( """ CREATE VIEW base_model_edges AS SELECT DISTINCT base.id AS parent_id, child.id AS child_id, COALESCE(child.baseModels.relation, 'derived') AS relation_name FROM source_models AS child, UNNEST(child.baseModels.models) AS nested(base) WHERE child.baseModels IS NOT NULL AND base.id IS NOT NULL AND trim(base.id) <> '' AND child.id IS NOT NULL """ ) connection.execute( """ CREATE VIEW model_dataset_edges AS SELECT DISTINCT substr(tag, 9) AS dataset_id, model.id AS model_id FROM source_models AS model, UNNEST(model.tags) AS nested(tag) WHERE starts_with(tag, 'dataset:') AND length(trim(substr(tag, 9))) > 0 AND model.id IS NOT NULL """ ) def export_csv( connection: duckdb.DuckDBPyConnection, output_dir: Path, filename: str, header: str, query: str, ) -> Path: path = output_dir / filename header_path = output_dir / filename.replace(".csv", "-header.csv") header_path.write_text(header + "\n", encoding="utf-8") connection.execute( f""" COPY ({query}) TO '{sql_path(path)}' (FORMAT CSV, HEADER false, DELIMITER ',', QUOTE '"', ESCAPE '"') """ ) log(f"Created {filename}") return path def prepare_csv_files( models_path: Path, datasets_path: Path, output_dir: Path, max_models: int | None = None, max_datasets: int | None = None, ) -> dict[str, Path]: output_dir.mkdir(parents=True, exist_ok=True) database_path = output_dir / "refresh.duckdb" connection = duckdb.connect(str(database_path)) connection.execute("SET preserve_insertion_order = false") connection.execute("SET threads = 2") create_views(connection, models_path, datasets_path, max_models, max_datasets) files: dict[str, Path] = {} files["models"] = export_csv( connection, output_dir, "models.csv", "modelId:ID(Model),name,downloads:long,task,createdAt,parameters,likes:long,license", """ WITH actual_models AS ( SELECT id, id AS name, downloadsAllTime AS downloads, pipeline_tag AS task, CAST(createdAt AS VARCHAR) AS created_at, CASE WHEN safetensors.total >= 1000000000 THEN printf('%.1fB', safetensors.total / 1000000000.0) WHEN safetensors.total >= 1000000 THEN printf('%.1fM', safetensors.total / 1000000.0) WHEN safetensors.total >= 1000 THEN printf('%.1fK', safetensors.total / 1000.0) WHEN safetensors.total IS NOT NULL THEN CAST(safetensors.total AS VARCHAR) END AS parameters, likes, json_extract_string(cardData, '$.license') AS license FROM source_models WHERE id IS NOT NULL AND trim(id) <> '' ), missing_parents AS ( SELECT DISTINCT parent_id AS id FROM base_model_edges WHERE parent_id NOT IN (SELECT id FROM actual_models) ) SELECT id, name, downloads, task, created_at, parameters, likes, license FROM actual_models UNION ALL SELECT id, id, NULL, NULL, NULL, NULL, NULL, NULL FROM missing_parents """, ) files["datasets"] = export_csv( connection, output_dir, "datasets.csv", "datasetId:ID(Dataset),name,downloads:long,createdAt_dataset", """ WITH actual_datasets AS ( SELECT id, id AS name, downloadsAllTime AS downloads, CAST(createdAt AS VARCHAR) AS created_at FROM source_datasets WHERE id IS NOT NULL AND trim(id) <> '' ), missing_datasets AS ( SELECT DISTINCT dataset_id AS id FROM model_dataset_edges WHERE dataset_id NOT IN (SELECT id FROM actual_datasets) ) SELECT id, name, downloads, created_at FROM actual_datasets UNION ALL SELECT id, id, NULL, NULL FROM missing_datasets """, ) files["authors"] = export_csv( connection, output_dir, "authors.csv", "authorId:ID(Author),name,type,followers:long", """ SELECT author, author, 'unknown', NULL FROM ( SELECT author FROM source_models UNION SELECT author FROM source_datasets ) WHERE author IS NOT NULL AND trim(author) <> '' """, ) files["base_model_edges"] = export_csv( connection, output_dir, "base-model-edges.csv", ":START_ID(Model),:END_ID(Model),name", "SELECT parent_id, child_id, relation_name FROM base_model_edges", ) files["model_dataset_edges"] = export_csv( connection, output_dir, "model-dataset-edges.csv", ":START_ID(Dataset),:END_ID(Model),name", """ SELECT dataset_id, model_id, 'A été utilisé dans ce modèle' FROM model_dataset_edges """, ) files["author_model_edges"] = export_csv( connection, output_dir, "author-model-edges.csv", ":START_ID(Author),:END_ID(Model),name", """ SELECT DISTINCT author, id, 'A publié' FROM source_models WHERE author IS NOT NULL AND trim(author) <> '' AND id IS NOT NULL """, ) files["author_dataset_edges"] = export_csv( connection, output_dir, "author-dataset-edges.csv", ":START_ID(Author),:END_ID(Dataset),name", """ SELECT DISTINCT author, id, 'A publié' FROM source_datasets WHERE author IS NOT NULL AND trim(author) <> '' AND id IS NOT NULL """, ) connection.close() database_path.unlink(missing_ok=True) return files def header_for(path: Path) -> Path: return path.with_name(path.name.replace(".csv", "-header.csv")) def build_dump(files: dict[str, Path], output_dir: Path, neo4j_admin: str) -> Path: def group(name: str) -> str: return f"{header_for(files[name])},{files[name]}" command = [ neo4j_admin, "database", "import", "full", "neo4j", "--overwrite-destination=true", "--id-type=string", "--threads=2", "--verbose", f"--nodes=Model={group('models')}", f"--nodes=Dataset={group('datasets')}", f"--nodes=Author={group('authors')}", f"--relationships=USED_IN={group('base_model_edges')}", f"--relationships=USED_IN={group('model_dataset_edges')}", f"--relationships=POSTED={group('author_model_edges')}", f"--relationships=POSTED={group('author_dataset_edges')}", ] log("Building the offline Neo4j database") subprocess.run(command, check=True) dump_dir = output_dir / "dump" dump_dir.mkdir(exist_ok=True) log("Creating neo4j.dump") subprocess.run( [ neo4j_admin, "database", "dump", "neo4j", f"--to-path={dump_dir}", "--overwrite-destination=true", ], check=True, ) return dump_dir / "neo4j.dump" def write_metadata( output_dir: Path, source_revision: str, model_count: int, dataset_count: int, ) -> Path: metadata = { "built_at": datetime.now(timezone.utc).isoformat(), "source_repo": SOURCE_REPO, "source_revision": source_revision, "model_count": model_count, "dataset_count": dataset_count, } path = output_dir / "database_metadata.json" path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") return path def parquet_unique_id_count(path: Path) -> int: connection = duckdb.connect() count = connection.execute( f""" SELECT count(DISTINCT id) FROM read_parquet('{sql_path(path)}') WHERE id IS NOT NULL AND trim(id) <> '' """ ).fetchone()[0] connection.close() return int(count) def publish_dump( api: HfApi, dump_path: Path, metadata_path: Path, repo_id: str, revision: str, ) -> None: if revision != "main": api.create_branch( repo_id=repo_id, repo_type="dataset", branch=revision, exist_ok=True, ) log(f"Publishing the dump to {repo_id}@{revision}") api.create_commit( repo_id=repo_id, repo_type="dataset", revision=revision, operations=[ CommitOperationAdd( path_in_repo="neo4j.dump", path_or_fileobj=str(dump_path), ), CommitOperationAdd( path_in_repo="database_metadata.json", path_or_fileobj=str(metadata_path), ), ], commit_message="Refresh Neo4j graph from cfahlgren1/hub-stats", ) def restart_spaces(api: HfApi, space_ids: list[str]) -> None: for space_id in space_ids: log(f"Restarting Space {space_id}") api.restart_space(repo_id=space_id) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--work-dir", type=Path, default=Path("/tmp/genmod-refresh")) parser.add_argument("--dump-repo", default=os.getenv("NEO4J_DUMP_REPO", DEFAULT_DUMP_REPO)) parser.add_argument("--dump-revision", default=os.getenv("NEO4J_DUMP_REVISION", "main")) parser.add_argument("--neo4j-admin", default=os.getenv("NEO4J_ADMIN", "neo4j-admin")) parser.add_argument("--models-parquet", type=Path) parser.add_argument("--datasets-parquet", type=Path) parser.add_argument("--max-models", type=int) parser.add_argument("--max-datasets", type=int) parser.add_argument("--prepare-only", action="store_true") parser.add_argument("--no-upload", action="store_true") parser.add_argument("--keep-work-dir", action="store_true") parser.add_argument("--restart-space", action="append", default=[]) return parser.parse_args() def main() -> int: args = parse_args() if args.work_dir.exists() and not args.keep_work_dir: shutil.rmtree(args.work_dir) args.work_dir.mkdir(parents=True, exist_ok=True) api = HfApi() source_revision = api.dataset_info(SOURCE_REPO).sha if bool(args.models_parquet) != bool(args.datasets_parquet): raise SystemExit("Provide both --models-parquet and --datasets-parquet.") if args.models_parquet: models_path, datasets_path = args.models_parquet, args.datasets_parquet else: models_path, datasets_path = download_sources(args.work_dir) csv_dir = args.work_dir / "csv" files = prepare_csv_files( models_path, datasets_path, csv_dir, max_models=args.max_models, max_datasets=args.max_datasets, ) if args.prepare_only: log(f"CSV preparation completed in {csv_dir}") return 0 dump_path = build_dump(files, args.work_dir, args.neo4j_admin) model_count = args.max_models or parquet_unique_id_count(models_path) dataset_count = args.max_datasets or parquet_unique_id_count(datasets_path) metadata_path = write_metadata( args.work_dir, source_revision, model_count, dataset_count, ) if not args.no_upload: if not os.getenv("HF_TOKEN"): raise SystemExit("HF_TOKEN is required to upload the refreshed dump.") publish_dump( api, dump_path, metadata_path, args.dump_repo, args.dump_revision, ) configured_spaces = [ value.strip() for value in os.getenv("SPACES_TO_RESTART", "").split(",") if value.strip() ] restart_spaces(api, list(dict.fromkeys(configured_spaces + args.restart_space))) log("Refresh completed successfully") return 0 if __name__ == "__main__": sys.exit(main())