# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: OpenMDW-1.1 from __future__ import annotations import io import json import os import pickle from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Optional from urllib.parse import urlparse import boto3 import numpy as np import torch import yaml from botocore.config import Config from PIL import Image import cosmos_framework.utils.easy_io.backends.auto_auth as auto from cosmos_framework.utils import distributed, log from cosmos_framework.utils.easy_io import easy_io GLOBAL_S3_CONFIG = Config( retries={"max_attempts": 20, "mode": "adaptive"}, connect_timeout=10, read_timeout=60, request_checksum_calculation="when_required", response_checksum_validation="when_required", ) Image.MAX_IMAGE_PIXELS = None if TYPE_CHECKING: from cosmos_framework.utils.config import ObjectStoreConfig class ObjectStore: """This is the interface class for object store, used for interacting with PBSS/AWS (S3). **Deprecated**. Use `easy_io` directly instead. Attributes: client (botocore.client.S3): Object store client object. easy_io_backend: easy_io backend. bucket (str): Object store bucket name. """ def __init__(self, config_object_storage: ObjectStoreConfig): # extracts the easy_io backend instead of the boto3 S3 client. with auto.open_auth(config_object_storage.credentials, "r") as file: object_storage_config = auto.json_load_auth(file) self.client = Boto3Wrapper( "s3", **object_storage_config, ) self.easy_io_backend = easy_io.get_file_backend( backend_args={ "backend": "s3", "s3_credential_path": config_object_storage.credentials, "path_mapping": None, } ) self.bucket = config_object_storage.bucket def _translate_key(self, key: str) -> str: """Translate an object key to an S3 URL for easy_io. Args: key (str): The key of the object. Returns: str: The object's S3 URL. """ return f"s3://{self.bucket}/{key}" def load_object( self, key: str, type: str | None = None, load_func: Callable | None = None, encoding: str = "UTF-8", ) -> Any: """Helper function for loading object from storage. Args: key (str): The key of the object. type (str): Specified for some common data types. If not provided, `load_func` should be specified. The predefined types currently supported are: - "torch": PyTorch model checkpoints, opened with torch.load(). - "torch.jit": A JIT-compiled TorchScript model, loaded with torch.jit.load(). - "image": Image objects, opened with PIL.Image.open(). - "json": JSON files, opened with json.load(). - "pickle": Picklable objects, opened with pickle.load(). - "yaml": YAML files, opened with yaml.safe_load(). - "text": Pure text files. - "numpy": Numpy arrays, opened with np.load(). - "bytes": Raw bytes. load_func (Callable): a custom function for reading the buffer if `type` were not provided. encoding (str): Text encoding standard (default: "UTF-8"). Returns: object (Any): The downloaded object. """ assert type is not None or load_func is not None, "Either type or load_func should be specified." buffer = io.BytesIO(self.easy_io_backend.get(filepath=self._translate_key(key=key))) buffer.seek(0) # Read from buffer for common data types. if type == "torch": return torch.load(buffer, map_location=lambda storage, loc: storage, weights_only=False) elif type == "torch.jit": return torch.jit.load(buffer) elif type == "image": image = Image.open(buffer) image.load() return image elif type == "json": return json.load(buffer) elif type == "pickle": return pickle.load(buffer) elif type == "yaml": return yaml.safe_load(buffer) elif type == "text": return buffer.read().decode(encoding) elif type == "numpy": return np.load(buffer, allow_pickle=True) # Read from buffer as raw bytes. elif type == "bytes": return buffer.read() # Customized load_func should be provided. else: return load_func(buffer) def save_object( self, object: Any, key: str, type: str | None = None, save_func: Callable | None = None, encoding: str = "UTF-8" ) -> None: """Helper function for saving object to storage. Args: object (Any): The object to upload. key (str): The key of the object. type (str): Specified for some common data types. If not provided, `save_func` should be specified. The predefined types currently supported are: - "torch": PyTorch model checkpoints, saved with torch.save(). - "torch.jit": A JIT-compiled TorchScript model, exported with torch.jit.save(). - "image": Image objects, saved with PIL.Image.save(). - "json": JSON files, saved with json.dumps(). - "pickle": Picklable objects, saved with pickle.dump(). - "yaml": YAML files, saved with yaml.safe_dump(). - "text": Pure text files. - "numpy": Numpy arrays, saved with np.save(). - "bytes": Raw bytes. save_func (Callable): a custom function for writing the buffer if `type` were not provided. encoding (str): Text encoding standard (default: "UTF-8"). """ assert type is not None or save_func is not None with io.BytesIO() as buffer: # Write to buffer for common data types. if type == "torch": torch.save(object, buffer) elif type == "torch.jit": torch.jit.save(object, buffer) elif type == "image": type = os.path.basename(key).split(".")[-1] object.save(buffer, format=type) elif type == "json": buffer.write(json.dumps(object).encode(encoding)) elif type == "pickle": pickle.dump(object, buffer) elif type == "yaml": buffer.write(yaml.safe_dump(object).encode(encoding)) elif type == "text": buffer.write(object.encode(encoding)) elif type == "numpy": np.save(buffer, object) # Write to buffer as raw bytes. elif type == "bytes": buffer.write(bytes(object)) # Customized save_func should be provided. else: save_func(object, buffer) buffer.seek(0) self.easy_io_backend.put(obj=buffer, filepath=self._translate_key(key=key)) def object_exists(self, key: str) -> bool: """ Check whether an object exists in the storage, with retry logic for transient errors. Args: key (str): The key of the object. Returns: bool: True if the object exists, False if not. """ return self.easy_io_backend.exists(filepath=self._translate_key(key=key)) class Boto3Wrapper: """ This class serves as a wrapper around boto3.client in order to make boto3.client serializable. It's required to use spawn method of creating DataLoader workers, which is in turn required to avoid segfaults when using Triton, e.g. for torch.compile or custom kernels. """ def __init__(self, *args, **kwargs): self._args = args self._kwargs = kwargs self.client = None def __setstate__(self, state): self.__dict__ = state def __getattr__(self, item): is_worker = torch.utils.data.get_worker_info() is not None client = ( boto3.client(*self._args, **self._kwargs, config=GLOBAL_S3_CONFIG) if self.client is None else self.client ) if is_worker: self.client = client return getattr(client, item) def sync_s3_dir_to_local( s3_dir: str, s3_credential_path: str, cache_dir: Optional[str] = None, rank_sync: bool = True, local_rank_sync: bool = False, ) -> str: """ Download an entire directory from S3 to the local cache directory. Args: s3_dir (str): The AWS S3 directory to download. s3_credential_path (str): The path to the AWS S3 credentials file. rank_sync (bool, optional): Whether to synchronize download across ALL distributed workers using `distributed.barrier()`. Defaults to True. cache_dir (str, optional): The cache folder to sync the S3 directory to. If None, the environment variable `IMAGINAIRE_CACHE_DIR` (defaulting to "~/.cache/imaginaire") will be used. local_rank_sync (bool, optional): Whether to synchronize download across workers within the same node using a node-level barrier. This is useful when the cache directory is not shared across nodes. Defaults to False. Note: rank_sync and local_rank_sync cannot both be True. Returns: local_dir (str): The path to the local directory. """ if local_rank_sync and rank_sync: raise ValueError("rank_sync and local_rank_sync cannot be True at the same time.") if not s3_dir.startswith("s3://"): # If the directory exists locally, return the local path assert os.path.exists(s3_dir), f"{s3_dir} is not a S3 path or a local path." return s3_dir # Get local rank for node-level synchronization local_rank = int(os.getenv("LOCAL_RANK", 0)) if local_rank_sync else None easy_io_backend = easy_io.get_file_backend( backend_args={ "backend": "s3", "s3_credential_path": s3_credential_path, "path_mapping": None, } ) # Parse the S3 URL parsed_url = urlparse(s3_dir) obj_prefix = parsed_url.path.lstrip("/") # If the local directory is not specified, use the default cache directory cache_dir = ( os.environ.get("IMAGINAIRE_CACHE_DIR", os.path.expanduser("~/.cache/imaginaire")) if cache_dir is None else cache_dir ) cache_dir = os.path.expanduser(cache_dir) Path(cache_dir).mkdir(parents=True, exist_ok=True) for obj_suffix in easy_io_backend.list_dir_or_file(dir_path=s3_dir, list_dir=False, list_file=True): # Create the full path for the destination file, preserving the directory structure dest_path = os.path.join(cache_dir, obj_prefix, obj_suffix) # Ensure the directory exists os.makedirs(os.path.dirname(dest_path), exist_ok=True) # Check if the file already exists if os.path.exists(dest_path): continue else: s3_obj = f"{s3_dir.removesuffix('/')}/{obj_suffix}" log.info(f"Downloading {s3_obj} to {dest_path}") # Download the file if rank_sync: # Only rank 0 downloads when using global rank sync if distributed.get_rank() == 0: easy_io_backend.copyfile_to_local(src=s3_obj, dst=dest_path, dst_type="file") elif local_rank_sync: # Only local rank 0 (first rank on each node) downloads when using local rank sync if local_rank == 0: easy_io_backend.copyfile_to_local(src=s3_obj, dst=dest_path, dst_type="file") else: # No synchronization - every rank downloads easy_io_backend.copyfile_to_local(src=s3_obj, dst=dest_path, dst_type="file") # Synchronize after downloads complete if rank_sync or local_rank_sync: distributed.barrier() local_dir = os.path.join(cache_dir, obj_prefix) return local_dir def download_from_s3_with_cache( s3_path: str, s3_credential_path: str, cache_fp: Optional[str] = None, cache_dir: Optional[str] = None, rank_sync: bool = True, backend_args: Optional[dict] = None, backend_key: Optional[str] = None, ) -> str: """download data from S3 with optional caching. This function first attempts to load the data from a local cache file. If the cache file doesn't exist, it downloads the data from S3 to the cache location. Caching is performed in a rank-aware manner using `distributed.barrier()` to ensure only one download occurs across distributed workers (if `rank_sync` is True). Args: s3_path (str): The S3 path of the data to load. cache_fp (str, optional): The path to the local cache file. If None, a filename will be generated based on `s3_path` within `cache_dir`. cache_dir (str, optional): The directory to store the cache file. If None, the environment variable `IMAGINAIRE_CACHE_DIR` (defaulting to "/tmp") will be used. rank_sync (bool, optional): Whether to synchronize download across distributed workers using `distributed.barrier()`. Defaults to True. backend_args (dict, optional): The backend arguments passed to easy_io to construct the backend. backend_key (str, optional): The backend key passed to easy_io to registry the backend or retrieve the backend if it is already registered. Returns: cache_fp (str): The path to the local cache file. Raises: FileNotFoundError: If the data cannot be found in S3 or the cache. """ if not s3_path.startswith("s3://"): # If the file exists locally, return the local path assert os.path.exists(s3_path), f"{s3_path} is not a S3 path nor a local path." return s3_path easy_io_backend = easy_io.get_file_backend( backend_args={ "backend": "s3", "s3_credential_path": s3_credential_path, "path_mapping": None, } ) cache_dir = ( os.environ.get("IMAGINAIRE_CACHE_DIR", os.path.expanduser("~/.cache/imaginaire")) if cache_dir is None else cache_dir ) cache_dir = os.path.expanduser(cache_dir) if cache_fp is None: cache_fp = os.path.join(cache_dir, s3_path.replace("s3://", "")) if not cache_fp.startswith("/"): cache_fp = os.path.join(cache_dir, cache_fp) if rank_sync: if distributed.get_rank() == 0: if os.path.exists(cache_fp): # check the size of cache_fp if os.path.getsize(cache_fp) < 1: os.remove(cache_fp) log.warning(f"Removed empty cache file {cache_fp}.") if not os.path.exists(cache_fp): easy_io_backend.copyfile_to_local( s3_path, cache_fp, dst_type="file", backend_args=backend_args, backend_key=backend_key ) log.info(f"Downloaded {s3_path} to {cache_fp}.") else: log.info(f"The cache file {cache_fp} already exists.") distributed.barrier() else: if os.path.exists(cache_fp): # check the size of cache_fp if os.path.getsize(cache_fp) < 1: os.remove(cache_fp) log.warning(f"Removed empty cache file {cache_fp}.") if not os.path.exists(cache_fp): easy_io_backend.copyfile_to_local( s3_path, cache_fp, dst_type="file", backend_args=backend_args, backend_key=backend_key ) log.info(f"Downloaded {s3_path} to {cache_fp}.") else: log.info(f"The cache file {cache_fp} already exists") return cache_fp