# 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 numpy as np import torch import yaml from PIL import Image from cosmos_framework.utils import distributed, log from cosmos_framework.utils.easy_io import easy_io from cosmos_framework.utils.easy_io.transient_retry import retry_on_transient_error 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: easy_io_backend: easy_io backend. bucket (str): Object store bucket name. """ def __init__(self, config_object_storage: ObjectStoreConfig): self.easy_io_backend = easy_io.get_file_backend( backend_args={ "backend": "s3", "s3_credential_path": config_object_storage.credentials, "path_mapping": None, }, enable_singleton=True, ) 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." path = self._translate_key(key=key) buffer = io.BytesIO( retry_on_transient_error( lambda: self.easy_io_backend.get(filepath=path), operation=f"load_object({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. """ path = self._translate_key(key=key) return retry_on_transient_error( lambda: self.easy_io_backend.exists(filepath=path), operation=f"object_exists({key})", ) 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/cosmos_framework") 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/cosmos_framework")) 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/cosmos_framework")) 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