Source code for cursus.steps.utils.s3_utils

from pathlib import PurePosixPath
from typing import Tuple
import logging

logger = logging.getLogger(__name__)


[docs] class S3PathHandler: """ Utility class for handling S3 URIs and paths. Provides methods to properly manipulate S3 paths while avoiding common issues like double slashes or incorrect path handling that can occur when using standard os.path functions with S3 URIs. """
[docs] @staticmethod def parse_uri(uri: str) -> Tuple[str, str]: """ Parse an S3 URI into components. Args: uri: S3 URI to parse Returns: tuple: (bucket, key) Raises: ValueError: If not a valid S3 URI """ if not uri or not isinstance(uri, str) or not uri.startswith("s3://"): raise ValueError(f"Not a valid S3 URI: {uri}") parts = uri[5:].split("/", 1) bucket = parts[0] key = parts[1] if len(parts) > 1 else "" return bucket, key
[docs] @classmethod def normalize(cls, uri: str, description: str = "S3 URI") -> str: """ Normalize an S3 URI by ensuring no trailing slashes or double slashes. Args: uri: S3 URI to normalize description: Description for logging Returns: Normalized S3 URI """ if not uri or not isinstance(uri, str) or not uri.startswith("s3://"): return uri try: bucket, key = cls.parse_uri(uri) except ValueError: return uri # Clean up path segments if key: segments = [seg for seg in key.split("/") if seg] normalized_key = str(PurePosixPath(*segments)) if segments else "" normalized = f"s3://{bucket}/{normalized_key}" else: normalized = f"s3://{bucket}" # Log if changed if normalized != uri: logger.info(f"Normalized {description}: '{uri}' -> '{normalized}'") return normalized
[docs] @classmethod def join(cls, base_uri: str, *paths: str) -> str: """ Join path components with an S3 URI base. Args: base_uri: Base S3 URI *paths: Path components to join Returns: Joined S3 URI """ if ( not base_uri or not isinstance(base_uri, str) or not base_uri.startswith("s3://") ): return base_uri try: bucket, key = cls.parse_uri(base_uri) except ValueError: return base_uri # Collect path parts parts = [] if key: parts.append(key) # Add additional parts parts.extend(p.lstrip("/") for p in paths if p) # Generate path using PurePosixPath if parts: path = str(PurePosixPath(*parts)) return f"s3://{bucket}/{path}" else: return f"s3://{bucket}"
[docs] @classmethod def get_parent(cls, uri: str) -> str: """ Get the parent directory of an S3 URI. Args: uri: S3 URI Returns: Parent S3 URI """ if not uri or not isinstance(uri, str) or not uri.startswith("s3://"): return uri try: bucket, key = cls.parse_uri(uri) except ValueError: return uri if not key: # At bucket root already return f"s3://{bucket}" # Use PurePosixPath for parent parent_key = str(PurePosixPath(key).parent) if parent_key == ".": return f"s3://{bucket}" return f"s3://{bucket}/{parent_key}"
[docs] @classmethod def get_name(cls, uri: str) -> str: """ Get the basename (filename) of an S3 URI. Args: uri: S3 URI Returns: Basename of the URI """ if not uri or not isinstance(uri, str) or not uri.startswith("s3://"): return uri.split("/")[-1] if "/" in uri else uri try: bucket, key = cls.parse_uri(uri) except ValueError: return uri.split("/")[-1] if "/" in uri else uri if not key: return bucket return PurePosixPath(key).name
[docs] @classmethod def ensure_directory(cls, uri: str, filename: str = None) -> str: """ Ensure URI represents a directory path (without filename). Args: uri: S3 URI filename: Optional filename to check for at end Returns: Directory path without trailing filename """ # First normalize uri = cls.normalize(uri) if not filename: return uri # Check if path ends with filename basename = cls.get_name(uri) if basename == filename: return cls.get_parent(uri) return uri
[docs] @classmethod def is_valid(cls, uri: str) -> bool: """ Check if a string is a valid S3 URI. Args: uri: URI to validate Returns: True if valid S3 URI """ if not uri or not isinstance(uri, str) or not uri.startswith("s3://"): return False try: cls.parse_uri(uri) return True except ValueError: return False