Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python | |
| """Hugging Face data sync helpers for BrainRL. | |
| The environment is intentionally lightweight: it needs configs, the frozen | |
| parcel manifest, participant metadata, and optional word annotations. This | |
| module keeps those artifacts versioned in a HF Dataset repo so Colab, HF Jobs, | |
| and HF Spaces can all run from the same data revision. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import os | |
| import shutil | |
| from pathlib import Path | |
| from typing import Any | |
| PROJECT_ROOT = Path(__file__).resolve().parent | |
| DEFAULT_EXPORT_DIR = PROJECT_ROOT / "hf_data_bundle" | |
| DEFAULT_CACHE_DIR = Path(os.getenv("BRAINRL_DATA_DIR", "/tmp/brainrl-data")).expanduser() | |
| REQUIRED_CONFIG_FILES = ( | |
| "subset_config.yaml", | |
| "region_priors.json", | |
| "participant_run_info.json", | |
| ) | |
| OPTIONAL_CONFIG_FILES = ( | |
| "parcel_candidates.json", | |
| ) | |
| def _resolve_token(raw_token: str | None = None) -> str | None: | |
| return raw_token or os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") | |
| def _copy_file(src: Path, dst: Path, *, required: bool) -> bool: | |
| if not src.exists(): | |
| if required: | |
| raise FileNotFoundError(f"Required BrainRL data file is missing: {src}") | |
| return False | |
| dst.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(src, dst) | |
| return True | |
| def export_data_bundle( | |
| output_dir: Path, | |
| *, | |
| config_dir: Path | None = None, | |
| annotation_dir: Path | None = None, | |
| include_annotations: bool = True, | |
| ) -> dict[str, Any]: | |
| """Copy the portable BrainRL data subset into ``output_dir``.""" | |
| config_root = (config_dir or PROJECT_ROOT / "configs").expanduser() | |
| annotation_root = ( | |
| annotation_dir | |
| or Path(os.getenv("BRAINRL_STIMULUS_DIR", PROJECT_ROOT.parent / "data" / "annotation")) | |
| ).expanduser() | |
| output_dir = output_dir.expanduser() | |
| if output_dir.exists(): | |
| shutil.rmtree(output_dir) | |
| (output_dir / "configs").mkdir(parents=True, exist_ok=True) | |
| copied: list[str] = [] | |
| for name in REQUIRED_CONFIG_FILES: | |
| if _copy_file(config_root / name, output_dir / "configs" / name, required=True): | |
| copied.append(f"configs/{name}") | |
| for name in OPTIONAL_CONFIG_FILES: | |
| if _copy_file(config_root / name, output_dir / "configs" / name, required=False): | |
| copied.append(f"configs/{name}") | |
| annotation_count = 0 | |
| if include_annotations and annotation_root.exists(): | |
| out_annotation = output_dir / "annotation" | |
| out_annotation.mkdir(parents=True, exist_ok=True) | |
| for csv_path in sorted(annotation_root.glob("*.csv")): | |
| shutil.copy2(csv_path, out_annotation / csv_path.name) | |
| annotation_count += 1 | |
| copied.append(f"annotation/{csv_path.name}") | |
| metadata = { | |
| "format": "brainrl-hf-data-v1", | |
| "config_files": copied, | |
| "annotation_csv_count": annotation_count, | |
| "source_config_dir": str(config_root), | |
| "source_annotation_dir": str(annotation_root) if annotation_root.exists() else None, | |
| } | |
| with (output_dir / "metadata.json").open("w", encoding="utf-8") as handle: | |
| json.dump(metadata, handle, indent=2) | |
| return metadata | |
| def _file_sha256(path: Path) -> str: | |
| """Stream-hash a file with sha256 so we can pin a parcel manifest revision.""" | |
| digest = hashlib.sha256() | |
| with path.open("rb") as handle: | |
| for chunk in iter(lambda: handle.read(1 << 20), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def parcel_manifest_summary(parcel_manifest: Path) -> dict[str, Any]: | |
| """Extract checksum + budget metadata from the parcel manifest, if present. | |
| Used by the Space ``/health`` endpoint so callers can confirm the running | |
| Space is using the exact same manifest revision as the trainer. | |
| """ | |
| if not parcel_manifest.exists(): | |
| return {"present": False} | |
| payload = json.loads(parcel_manifest.read_text(encoding="utf-8")) | |
| candidates = payload.get("candidates") | |
| return { | |
| "present": True, | |
| "path": str(parcel_manifest), | |
| "sha256": _file_sha256(parcel_manifest), | |
| "size_bytes": parcel_manifest.stat().st_size, | |
| "selection_budget": payload.get("selection_budget"), | |
| "max_candidates": payload.get("max_candidates"), | |
| "candidate_count": len(candidates) if isinstance(candidates, list) else None, | |
| } | |
| def validate_data_root(root: Path) -> dict[str, Any]: | |
| """Validate a downloaded/exported HF data root and return a summary. | |
| The summary is consumed by the Space's ``/brainrl/data_status`` and | |
| ``/health`` endpoints so a notebook caller can confirm the running Space | |
| matches the data revision the trainer just pushed. | |
| """ | |
| root = root.expanduser() | |
| config_dir = root / "configs" | |
| missing = [name for name in REQUIRED_CONFIG_FILES if not (config_dir / name).exists()] | |
| if missing: | |
| raise FileNotFoundError( | |
| f"BrainRL data root {root} is missing required config files: {missing}" | |
| ) | |
| json_files = [config_dir / "region_priors.json", config_dir / "participant_run_info.json"] | |
| parcel_manifest = config_dir / "parcel_candidates.json" | |
| if parcel_manifest.exists(): | |
| json_files.append(parcel_manifest) | |
| for json_path in json_files: | |
| with json_path.open("r", encoding="utf-8") as handle: | |
| json.load(handle) | |
| annotation_dir = root / "annotation" | |
| return { | |
| "root": str(root), | |
| "config_dir": str(config_dir), | |
| "has_parcel_manifest": parcel_manifest.exists(), | |
| "parcel_manifest": parcel_manifest_summary(parcel_manifest), | |
| "annotation_csv_count": len(list(annotation_dir.glob("*.csv"))) | |
| if annotation_dir.exists() | |
| else 0, | |
| "data_repo": os.getenv("BRAINRL_DATA_REPO"), | |
| "data_revision": os.getenv("BRAINRL_DATA_REVISION") or None, | |
| } | |
| def download_dataset_repo( | |
| repo_id: str, | |
| *, | |
| output_dir: Path = DEFAULT_CACHE_DIR, | |
| revision: str | None = None, | |
| token: str | None = None, | |
| ) -> dict[str, Any]: | |
| """Download a HF Dataset repo into ``output_dir`` and validate it.""" | |
| try: | |
| from huggingface_hub import snapshot_download | |
| except ImportError as exc: # pragma: no cover | |
| raise SystemExit( | |
| "huggingface_hub is required for HF data sync. Install with " | |
| "`pip install -e .[deploy]` or `pip install huggingface_hub`." | |
| ) from exc | |
| output_dir = output_dir.expanduser() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| snapshot_download( | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| revision=revision, | |
| local_dir=str(output_dir), | |
| token=_resolve_token(token), | |
| allow_patterns=["configs/**", "annotation/**", "metadata.json", "README.md"], | |
| ) | |
| return validate_data_root(output_dir) | |
| def upload_dataset_repo( | |
| repo_id: str, | |
| *, | |
| bundle_dir: Path, | |
| private: bool, | |
| token: str | None = None, | |
| commit_message: str = "Upload BrainRL config data", | |
| ) -> None: | |
| """Create/update the HF Dataset repo with a prepared data bundle.""" | |
| try: | |
| from huggingface_hub import create_repo, upload_folder | |
| except ImportError as exc: # pragma: no cover | |
| raise SystemExit( | |
| "huggingface_hub is required for HF data upload. Install with " | |
| "`pip install -e .[deploy]` or `pip install huggingface_hub`." | |
| ) from exc | |
| token = _resolve_token(token) | |
| create_repo( | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| private=private, | |
| exist_ok=True, | |
| token=token, | |
| ) | |
| upload_folder( | |
| folder_path=str(bundle_dir.expanduser()), | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| token=token, | |
| commit_message=commit_message, | |
| ) | |
| def sync_data_from_env() -> dict[str, Any] | None: | |
| """Download HF data when ``BRAINRL_DATA_REPO`` is set. | |
| The function also sets ``BRAINRL_CONFIG_DIR`` and ``BRAINRL_STIMULUS_DIR`` | |
| for downstream loaders if the downloaded files are present. | |
| """ | |
| repo_id = os.getenv("BRAINRL_DATA_REPO") | |
| if not repo_id: | |
| return None | |
| revision = os.getenv("BRAINRL_DATA_REVISION") or None | |
| output_dir = Path(os.getenv("BRAINRL_DATA_DIR", str(DEFAULT_CACHE_DIR))).expanduser() | |
| summary = download_dataset_repo(repo_id, output_dir=output_dir, revision=revision) | |
| os.environ.setdefault("BRAINRL_CONFIG_DIR", summary["config_dir"]) | |
| annotation_dir = output_dir / "annotation" | |
| if annotation_dir.exists(): | |
| os.environ.setdefault("BRAINRL_STIMULUS_DIR", str(annotation_dir)) | |
| return summary | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser(description="Manage BrainRL HF Dataset artifacts") | |
| sub = parser.add_subparsers(dest="command", required=True) | |
| export = sub.add_parser("export", help="Export local configs/annotations to a bundle") | |
| export.add_argument("--output-dir", type=str, default=str(DEFAULT_EXPORT_DIR)) | |
| export.add_argument("--config-dir", type=str, default=None) | |
| export.add_argument("--annotation-dir", type=str, default=None) | |
| export.add_argument("--no-annotations", action="store_true") | |
| upload = sub.add_parser("upload", help="Export then upload to a HF Dataset repo") | |
| upload.add_argument("--repo-id", type=str, required=True) | |
| upload.add_argument("--bundle-dir", type=str, default=str(DEFAULT_EXPORT_DIR)) | |
| upload.add_argument("--config-dir", type=str, default=None) | |
| upload.add_argument("--annotation-dir", type=str, default=None) | |
| upload.add_argument("--no-annotations", action="store_true") | |
| upload.add_argument("--public", action="store_true") | |
| upload.add_argument("--token", type=str, default=None) | |
| upload.add_argument("--commit-message", type=str, default="Upload BrainRL config data") | |
| download = sub.add_parser("download", help="Download/validate a HF Dataset repo") | |
| download.add_argument("--repo-id", type=str, required=True) | |
| download.add_argument("--output-dir", type=str, default=str(DEFAULT_CACHE_DIR)) | |
| download.add_argument("--revision", type=str, default=None) | |
| download.add_argument("--token", type=str, default=None) | |
| validate = sub.add_parser("validate", help="Validate a local data root") | |
| validate.add_argument("--data-root", type=str, required=True) | |
| return parser | |
| def main() -> None: | |
| args = build_parser().parse_args() | |
| if args.command == "export": | |
| summary = export_data_bundle( | |
| Path(args.output_dir), | |
| config_dir=Path(args.config_dir) if args.config_dir else None, | |
| annotation_dir=Path(args.annotation_dir) if args.annotation_dir else None, | |
| include_annotations=not bool(args.no_annotations), | |
| ) | |
| print(json.dumps(summary, indent=2)) | |
| return | |
| if args.command == "upload": | |
| bundle_dir = Path(args.bundle_dir) | |
| export_data_bundle( | |
| bundle_dir, | |
| config_dir=Path(args.config_dir) if args.config_dir else None, | |
| annotation_dir=Path(args.annotation_dir) if args.annotation_dir else None, | |
| include_annotations=not bool(args.no_annotations), | |
| ) | |
| upload_dataset_repo( | |
| args.repo_id, | |
| bundle_dir=bundle_dir, | |
| private=not bool(args.public), | |
| token=args.token, | |
| commit_message=args.commit_message, | |
| ) | |
| print(f"Uploaded BrainRL data bundle to dataset repo {args.repo_id}") | |
| return | |
| if args.command == "download": | |
| summary = download_dataset_repo( | |
| args.repo_id, | |
| output_dir=Path(args.output_dir), | |
| revision=args.revision, | |
| token=args.token, | |
| ) | |
| print(json.dumps(summary, indent=2)) | |
| return | |
| if args.command == "validate": | |
| print(json.dumps(validate_data_root(Path(args.data_root)), indent=2)) | |
| return | |
| if __name__ == "__main__": | |
| main() | |