Source code for multistorageclient.config

   1# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
   2# SPDX-License-Identifier: Apache-2.0
   3#
   4# Licensed under the Apache License, Version 2.0 (the "License");
   5# you may not use this file except in compliance with the License.
   6# You may obtain a copy of the License at
   7#
   8# http://www.apache.org/licenses/LICENSE-2.0
   9#
  10# Unless required by applicable law or agreed to in writing, software
  11# distributed under the License is distributed on an "AS IS" BASIS,
  12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13# See the License for the specific language governing permissions and
  14# limitations under the License.
  15
  16import copy
  17import json
  18import logging
  19import os
  20import tempfile
  21from collections import defaultdict
  22from collections.abc import Callable
  23from pathlib import Path
  24from typing import Any, Iterable, Optional, Union
  25from urllib.parse import urlparse
  26
  27import yaml
  28
  29from .cache import DEFAULT_CACHE_LINE_SIZE, DEFAULT_CACHE_SIZE, CacheManager
  30from .caching.cache_config import CacheConfig, EvictionPolicyConfig
  31from .providers.manifest_metadata import ManifestMetadataProvider
  32from .rclone import read_rclone_config
  33from .schema import validate_config
  34from .telemetry import Telemetry
  35from .telemetry import init as telemetry_init
  36from .types import (
  37    DEFAULT_RETRY_ATTEMPTS,
  38    DEFAULT_RETRY_BACKOFF_MULTIPLIER,
  39    DEFAULT_RETRY_DELAY,
  40    MSC_PROTOCOL,
  41    AutoCommitConfig,
  42    CredentialsProvider,
  43    MetadataProvider,
  44    ProviderBundle,
  45    ProviderBundleV2,
  46    Replica,
  47    RetryConfig,
  48    StorageBackend,
  49    StorageProvider,
  50    StorageProviderConfig,
  51)
  52from .utils import expand_env_vars, import_class, merge_dictionaries_no_overwrite
  53
  54# Constants related to implicit profiles
  55SUPPORTED_IMPLICIT_PROFILE_PROTOCOLS = ("s3", "gs", "ais", "file")
  56PROTOCOL_TO_PROVIDER_TYPE_MAPPING = {
  57    "s3": "s3",
  58    "gs": "gcs",
  59    "ais": "ais",
  60    "file": "file",
  61}
  62
  63
  64# Template for creating implicit profile configurations
  65def create_implicit_profile_config(profile_name: str, protocol: str, base_path: str) -> dict:
  66    """
  67    Create a configuration dictionary for an implicit profile.
  68
  69    :param profile_name: The name of the profile (e.g., "_s3-bucket1")
  70    :param protocol: The storage protocol (e.g., "s3", "gs", "ais")
  71    :param base_path: The base path (e.g., bucket name) for the storage provider
  72
  73    :return: A configuration dictionary for the implicit profile
  74    """
  75    provider_type = PROTOCOL_TO_PROVIDER_TYPE_MAPPING[protocol]
  76    return {
  77        "profiles": {profile_name: {"storage_provider": {"type": provider_type, "options": {"base_path": base_path}}}}
  78    }
  79
  80
  81RESERVED_POSIX_PROFILE_NAME = "__filesystem__"
  82DEFAULT_POSIX_PROFILE = create_implicit_profile_config(RESERVED_POSIX_PROFILE_NAME, "file", "/")
  83
  84STORAGE_PROVIDER_MAPPING = {
  85    "file": "PosixFileStorageProvider",
  86    "s3": "S3StorageProvider",
  87    "gcs": "GoogleStorageProvider",
  88    "oci": "OracleStorageProvider",
  89    "azure": "AzureBlobStorageProvider",
  90    "ais": "AIStoreStorageProvider",
  91    "ais_s3": "AIStoreS3StorageProvider",
  92    "s8k": "S8KStorageProvider",
  93    "gcs_s3": "GoogleS3StorageProvider",
  94    "huggingface": "HuggingFaceStorageProvider",
  95}
  96
  97CREDENTIALS_PROVIDER_MAPPING = {
  98    "S3Credentials": "StaticS3CredentialsProvider",
  99    "AzureCredentials": "StaticAzureCredentialsProvider",
 100    "DefaultAzureCredentials": "DefaultAzureCredentialsProvider",
 101    "AISCredentials": "StaticAISCredentialProvider",
 102    "GoogleIdentityPoolCredentialsProvider": "GoogleIdentityPoolCredentialsProvider",
 103    "GoogleServiceAccountCredentialsProvider": "GoogleServiceAccountCredentialsProvider",
 104    "HuggingFaceCredentials": "HuggingFaceCredentialsProvider",
 105    "FileBasedCredentials": "FileBasedCredentialsProvider",
 106}
 107
 108
 109def _resolve_include_path(include_path: str, parent_config_path: str) -> str:
 110    """
 111    Resolve include path (absolute or relative to parent config file).
 112
 113    :param include_path: Path from include keyword (can be absolute or relative)
 114    :param parent_config_path: Absolute path of the config file containing the include
 115    :return: Absolute, normalized path to the included config file
 116    """
 117    if os.path.isabs(include_path):
 118        return os.path.abspath(include_path)
 119    else:
 120        parent_dir = os.path.dirname(parent_config_path)
 121        return os.path.abspath(os.path.join(parent_dir, include_path))
 122
 123
 124def _merge_profiles(
 125    base_profiles: dict[str, Any],
 126    new_profiles: dict[str, Any],
 127    base_path: str,
 128    new_path: str,
 129) -> dict[str, Any]:
 130    """
 131    Merge profiles from two config files with conflict detection.
 132
 133    Profiles with the same name are allowed if their definitions are identical (idempotent).
 134    If a profile name exists in both configs with different definitions, an error is raised.
 135
 136    NOTE: This function performs SHALLOW merging - each profile is treated as a complete unit.
 137    We cannot use merge_dictionaries_no_overwrite() here because it would recursively merge
 138    profile fields, which could lead to unintended behavior. For example:
 139
 140    base_config:
 141        profiles:
 142            my-profile:
 143                storage_provider:
 144                    type: s3
 145                    options:
 146                        base_path: bucket
 147
 148    new_config:
 149        profiles:
 150            my-profile:
 151                credentials_provider:
 152                    type: S3Credentials
 153                    options:
 154                        access_key: foo
 155                        secret_key: bar
 156
 157    If we used merge_dictionaries_no_overwrite(), the result would be a profile with BOTH storage_provider
 158    and credentials_provider, which might not be the intended configuration.
 159
 160    :param base_profiles: Profiles from the base config
 161    :param new_profiles: Profiles from the new config to merge
 162    :param base_path: Path to the base config file (for error messages)
 163    :param new_path: Path to the new config file (for error messages)
 164    :return: Merged profiles dictionary
 165    :raises ValueError: If any profile name exists in both configs with different definitions
 166    """
 167    result = base_profiles.copy()
 168    conflicting_profiles = []
 169
 170    for profile_name, new_profile_def in new_profiles.items():
 171        if profile_name in result:
 172            if result[profile_name] != new_profile_def:
 173                conflicting_profiles.append(profile_name)
 174        else:
 175            result[profile_name] = new_profile_def
 176
 177    if conflicting_profiles:
 178        conflicts_list = ", ".join(f"'{p}'" for p in sorted(conflicting_profiles))
 179        raise ValueError(f"Profile conflict: {conflicts_list} defined differently in {base_path} and {new_path}")
 180
 181    return result
 182
 183
 184def _merge_opentelemetry(
 185    base_otel: dict[str, Any],
 186    new_otel: dict[str, Any],
 187    base_path: str,
 188    new_path: str,
 189) -> dict[str, Any]:
 190    """
 191    Merge opentelemetry configurations with special handling for metrics.attributes.
 192
 193    Merge strategy:
 194    - metrics.attributes: Concatenate arrays (preserve order for fallback mechanism)
 195    - Other fields: Use idempotent check (must be identical)
 196
 197    Attributes providers are concatenated to support fallback scenarios where multiple
 198    providers of the same type supply the same attribute key with different sources.
 199    The order matters - later providers override earlier ones.
 200
 201    :param base_otel: Base opentelemetry config
 202    :param new_otel: New opentelemetry config to merge
 203    :param base_path: Path to base config file (for error messages)
 204    :param new_path: Path to new config file (for error messages)
 205    :return: Merged opentelemetry configuration
 206    :raises ValueError: If conflicts are detected
 207    """
 208    # Merge metrics.attributes
 209    base_attrs = base_otel.get("metrics", {}).get("attributes", [])
 210    new_attrs = new_otel.get("metrics", {}).get("attributes", [])
 211    merged_attrs = base_attrs + new_attrs
 212
 213    # Merge other opentelemetry fields
 214    base_otel_without_attrs = copy.deepcopy(base_otel)
 215    if "metrics" in base_otel_without_attrs and "attributes" in base_otel_without_attrs["metrics"]:
 216        del base_otel_without_attrs["metrics"]["attributes"]
 217
 218    new_otel_without_attrs = copy.deepcopy(new_otel)
 219    if "metrics" in new_otel_without_attrs and "attributes" in new_otel_without_attrs["metrics"]:
 220        del new_otel_without_attrs["metrics"]["attributes"]
 221
 222    merged, conflicts = merge_dictionaries_no_overwrite(
 223        base_otel_without_attrs, new_otel_without_attrs, allow_idempotent=True
 224    )
 225
 226    if conflicts:
 227        conflicts_list = ", ".join(f"'{k}'" for k in sorted(conflicts))
 228        raise ValueError(
 229            f"opentelemetry config conflict: {conflicts_list} defined differently in {base_path} and {new_path}"
 230        )
 231
 232    if "metrics" in merged:
 233        merged["metrics"]["attributes"] = merged_attrs
 234    elif merged_attrs:
 235        merged["metrics"] = {"attributes": merged_attrs}
 236
 237    return merged
 238
 239
 240def _merge_configs(
 241    base_config: dict[str, Any],
 242    new_config: dict[str, Any],
 243    base_path: str,
 244    new_path: str,
 245) -> dict[str, Any]:
 246    """
 247    Merge two config dictionaries with field-specific strategies.
 248
 249    Different config fields have different merge strategies:
 250    - profiles: Shallow merge with idempotent check (uses _merge_profiles)
 251    - path_mapping: Flat dict merge with idempotent check
 252    - experimental_features: Flat dict merge with idempotent check
 253    - opentelemetry: Hybrid merge (attributes concatenate, others idempotent)
 254    - cache, posix: Global configs, idempotent if identical, error if different
 255
 256    :param base_config: Base configuration dictionary
 257    :param new_config: New configuration to merge
 258    :param base_path: Path to base config file (for error messages)
 259    :param new_path: Path to new config file (for error messages)
 260    :return: Merged configuration dictionary
 261    :raises ValueError: If conflicts are detected
 262    """
 263    result = {}
 264
 265    all_keys = set(base_config.keys()) | set(new_config.keys())
 266
 267    for key in all_keys:
 268        base_value = base_config.get(key)
 269        new_value = new_config.get(key)
 270
 271        # Key only in base
 272        if key not in new_config:
 273            result[key] = base_value
 274            continue
 275
 276        # Key only in new
 277        if key not in base_config:
 278            result[key] = new_value
 279            continue
 280
 281        # Key in both - need to merge or detect conflict
 282        if key == "profiles":
 283            result["profiles"] = _merge_profiles(base_value or {}, new_value or {}, base_path, new_path)
 284
 285        elif key in ("path_mapping", "experimental_features"):
 286            merged, conflicts = merge_dictionaries_no_overwrite(
 287                (base_value or {}).copy(), new_value or {}, allow_idempotent=True
 288            )
 289            if conflicts:
 290                conflicts_list = ", ".join(f"'{k}'" for k in sorted(conflicts))
 291                raise ValueError(
 292                    f"Config merge conflict: {conflicts_list} have different values in {base_path} and {new_path}"
 293                )
 294            result[key] = merged
 295
 296        elif key == "opentelemetry":
 297            result["opentelemetry"] = _merge_opentelemetry(base_value or {}, new_value or {}, base_path, new_path)
 298
 299        elif key in ("cache", "posix"):
 300            if base_value != new_value:
 301                raise ValueError(f"'{key}' defined differently in {base_path} and {new_path}")
 302            result[key] = base_value
 303
 304        elif key == "include":
 305            # 'include' is processed by _load_and_merge_includes, not part of final config
 306            pass
 307
 308        else:
 309            # This should never happen and all top level fields must have explicit handling above
 310            raise ValueError(f"Unknown field '{key}' in config file.")
 311
 312    return result
 313
 314
 315def _load_and_merge_includes(
 316    main_config_path: str,
 317    main_config_dict: dict[str, Any],
 318) -> dict[str, Any]:
 319    """
 320    Load and merge included config files.
 321
 322    Processes the 'include' directive in the main config, loading and merging all
 323    specified config files. Only supports one level of includes - included files
 324    cannot themselves have 'include' directives.
 325
 326    :param main_config_path: Absolute path to the main config file
 327    :param main_config_dict: Dictionary loaded from the main config file
 328    :return: Merged configuration dictionary (without 'include' field)
 329    :raises ValueError: If include file not found, malformed, or contains nested includes
 330    """
 331    include_paths = main_config_dict.get("include", [])
 332
 333    if not include_paths:
 334        return {k: v for k, v in main_config_dict.items() if k != "include"}
 335
 336    merged_config = {k: v for k, v in main_config_dict.items() if k != "include"}
 337
 338    for include_path in include_paths:
 339        resolved_path = _resolve_include_path(include_path, main_config_path)
 340
 341        if not os.path.exists(resolved_path):
 342            raise ValueError(f"Included config file not found: {resolved_path} (from {main_config_path})")
 343
 344        try:
 345            with open(resolved_path) as f:
 346                if resolved_path.endswith(".json"):
 347                    included_config = json.load(f)
 348                else:
 349                    included_config = yaml.safe_load(f)
 350        except Exception as e:
 351            raise ValueError(f"Failed to load included config {resolved_path}: {e}")
 352
 353        validate_config(included_config)
 354        if "include" in included_config:
 355            raise ValueError(f"Nested includes not allowed: {resolved_path} contains 'include' directive")
 356
 357        merged_config = _merge_configs(merged_config, included_config, main_config_path, resolved_path)
 358
 359    return merged_config
 360
 361
 362def _find_config_file_paths() -> tuple[str]:
 363    """
 364    Get configuration file search paths.
 365
 366    Returns paths in order of precedence:
 367
 368    1. User-specific config (${XDG_CONFIG_HOME}/msc/, ${HOME}/, ${HOME}/.config/msc/)
 369    2. System-wide configs (${XDG_CONFIG_DIRS}/msc/, /etc/xdg, /etc/)
 370    """
 371    paths = []
 372
 373    # 1. User-specific configuration directory
 374    xdg_config_home = os.getenv("XDG_CONFIG_HOME")
 375
 376    if xdg_config_home:
 377        paths.extend(
 378            [
 379                os.path.join(xdg_config_home, "msc", "config.yaml"),
 380                os.path.join(xdg_config_home, "msc", "config.json"),
 381            ]
 382        )
 383
 384    user_home = os.getenv("HOME")
 385
 386    if user_home:
 387        paths.extend(
 388            [
 389                os.path.join(user_home, ".msc_config.yaml"),
 390                os.path.join(user_home, ".msc_config.json"),
 391                os.path.join(user_home, ".config", "msc", "config.yaml"),
 392                os.path.join(user_home, ".config", "msc", "config.json"),
 393            ]
 394        )
 395
 396    # 2. System-wide configuration directories
 397    xdg_config_dirs = os.getenv("XDG_CONFIG_DIRS")
 398    if not xdg_config_dirs:
 399        xdg_config_dirs = "/etc/xdg"
 400
 401    for config_dir in xdg_config_dirs.split(":"):
 402        config_dir = config_dir.strip()
 403        if config_dir:
 404            paths.extend(
 405                [
 406                    os.path.join(config_dir, "msc", "config.yaml"),
 407                    os.path.join(config_dir, "msc", "config.json"),
 408                ]
 409            )
 410
 411    paths.extend(
 412        [
 413            "/etc/msc_config.yaml",
 414            "/etc/msc_config.json",
 415        ]
 416    )
 417
 418    return tuple(paths)
 419
 420
 421PACKAGE_NAME = "multistorageclient"
 422
 423logger = logging.getLogger(__name__)
 424
 425
 426class ImmutableDict(dict):
 427    """
 428    Immutable dictionary that raises an error when attempting to modify it.
 429    """
 430
 431    def __init__(self, *args, **kwargs):
 432        super().__init__(*args, **kwargs)
 433
 434        # Recursively freeze nested structures
 435        for key, value in list(super().items()):
 436            if isinstance(value, dict) and not isinstance(value, ImmutableDict):
 437                super().__setitem__(key, ImmutableDict(value))
 438            elif isinstance(value, list):
 439                super().__setitem__(key, self._freeze_list(value))
 440
 441    @staticmethod
 442    def _freeze_list(lst):
 443        """
 444        Convert list to tuple, freezing nested dicts recursively.
 445        """
 446        frozen = []
 447        for item in lst:
 448            if isinstance(item, dict):
 449                frozen.append(ImmutableDict(item))
 450            elif isinstance(item, list):
 451                frozen.append(ImmutableDict._freeze_list(item))
 452            else:
 453                frozen.append(item)
 454        return tuple(frozen)
 455
 456    def __deepcopy__(self, memo):
 457        """
 458        Return a regular mutable dict when deepcopy is called.
 459        """
 460        return copy.deepcopy(dict(self), memo)
 461
 462    def __reduce__(self):
 463        """
 464        Support for pickle serialization.
 465        """
 466        return (self.__class__, (dict(self),))
 467
 468    def _copy_value(self, value):
 469        """
 470        Convert frozen structures back to mutable equivalents.
 471        """
 472        if isinstance(value, ImmutableDict):
 473            return {k: self._copy_value(v) for k, v in value.items()}
 474        elif isinstance(value, tuple):
 475            # Check if it was originally a list (frozen by _freeze_list)
 476            return [self._copy_value(item) for item in value]
 477        else:
 478            return value
 479
 480    def __getitem__(self, key):
 481        """
 482        Return a mutable copy of the value.
 483        """
 484        value = super().__getitem__(key)
 485        return self._copy_value(value)
 486
 487    def get(self, key, default=None):
 488        """
 489        Return a mutable copy of the value.
 490        """
 491        return self[key] if key in self else default
 492
 493    def __setitem__(self, key, value):
 494        raise TypeError("ImmutableDict is immutable")
 495
 496    def __delitem__(self, key):
 497        raise TypeError("ImmutableDict is immutable")
 498
 499    def clear(self):
 500        raise TypeError("ImmutableDict is immutable")
 501
 502    def pop(self, *args):
 503        raise TypeError("ImmutableDict is immutable")
 504
 505    def popitem(self):
 506        raise TypeError("ImmutableDict is immutable")
 507
 508    def setdefault(self, key, default=None):
 509        raise TypeError("ImmutableDict is immutable")
 510
 511    def update(self, *args, **kwargs):
 512        raise TypeError("ImmutableDict is immutable")
 513
 514
 515class SimpleProviderBundle(ProviderBundle):
 516    def __init__(
 517        self,
 518        storage_provider_config: StorageProviderConfig,
 519        credentials_provider: Optional[CredentialsProvider] = None,
 520        metadata_provider: Optional[MetadataProvider] = None,
 521        replicas: Optional[list[Replica]] = None,
 522    ):
 523        if replicas is None:
 524            replicas = []
 525
 526        self._storage_provider_config = storage_provider_config
 527        self._credentials_provider = credentials_provider
 528        self._metadata_provider = metadata_provider
 529        self._replicas = replicas
 530
 531    @property
 532    def storage_provider_config(self) -> StorageProviderConfig:
 533        return self._storage_provider_config
 534
 535    @property
 536    def credentials_provider(self) -> Optional[CredentialsProvider]:
 537        return self._credentials_provider
 538
 539    @property
 540    def metadata_provider(self) -> Optional[MetadataProvider]:
 541        return self._metadata_provider
 542
 543    @property
 544    def replicas(self) -> list[Replica]:
 545        return self._replicas
 546
 547
 548class SimpleProviderBundleV2(ProviderBundleV2):
 549    def __init__(
 550        self, storage_backends: dict[str, StorageBackend], metadata_provider: Optional[MetadataProvider] = None
 551    ):
 552        self._storage_backends = storage_backends
 553        self._metadata_provider = metadata_provider
 554
 555    @staticmethod
 556    def from_v1_bundle(profile_name: str, v1_bundle: ProviderBundle) -> "SimpleProviderBundleV2":
 557        backend = StorageBackend(
 558            storage_provider_config=v1_bundle.storage_provider_config,
 559            credentials_provider=v1_bundle.credentials_provider,
 560            replicas=v1_bundle.replicas,
 561        )
 562
 563        return SimpleProviderBundleV2(
 564            storage_backends={profile_name: backend},
 565            metadata_provider=v1_bundle.metadata_provider,
 566        )
 567
 568    @property
 569    def storage_backends(self) -> dict[str, StorageBackend]:
 570        return self._storage_backends
 571
 572    @property
 573    def metadata_provider(self) -> Optional[MetadataProvider]:
 574        return self._metadata_provider
 575
 576
 577DEFAULT_CACHE_REFRESH_INTERVAL = 300
 578
 579
 580class StorageClientConfigLoader:
 581    _provider_bundle: ProviderBundleV2
 582    _resolved_config_dict: dict[str, Any]
 583    _profiles: dict[str, Any]
 584    _profile: str
 585    _profile_dict: dict[str, Any]
 586    _opentelemetry_dict: Optional[dict[str, Any]]
 587    _telemetry_provider: Optional[Callable[[], Telemetry]]
 588    _cache_dict: Optional[dict[str, Any]]
 589
 590    def __init__(
 591        self,
 592        config_dict: dict[str, Any],
 593        profile: str = RESERVED_POSIX_PROFILE_NAME,
 594        provider_bundle: Optional[Union[ProviderBundle, ProviderBundleV2]] = None,
 595        telemetry_provider: Optional[Callable[[], Telemetry]] = None,
 596    ) -> None:
 597        """
 598        Initializes a :py:class:`StorageClientConfigLoader` to create a
 599        StorageClientConfig. Components are built using the ``config_dict`` and
 600        profile, but a pre-built provider_bundle takes precedence.
 601
 602        :param config_dict: Dictionary of configuration options.
 603        :param profile: Name of profile in ``config_dict`` to use to build configuration.
 604        :param provider_bundle: Optional pre-built :py:class:`multistorageclient.types.ProviderBundle` or :py:class:`multistorageclient.types.ProviderBundleV2`, takes precedence over ``config_dict``.
 605        :param telemetry_provider: A function that provides a telemetry instance. The function must be defined at the top level of a module to work with pickling.
 606        """
 607        # Interpolates all environment variables into actual values.
 608        config_dict = expand_env_vars(config_dict)
 609        self._resolved_config_dict = ImmutableDict(config_dict)
 610
 611        self._profiles = config_dict.get("profiles", {})
 612
 613        if RESERVED_POSIX_PROFILE_NAME not in self._profiles:
 614            # Assign the default POSIX profile
 615            self._profiles[RESERVED_POSIX_PROFILE_NAME] = DEFAULT_POSIX_PROFILE["profiles"][RESERVED_POSIX_PROFILE_NAME]
 616        else:
 617            # Cannot override default POSIX profile
 618            if (
 619                self._profiles[RESERVED_POSIX_PROFILE_NAME]
 620                != DEFAULT_POSIX_PROFILE["profiles"][RESERVED_POSIX_PROFILE_NAME]
 621            ):
 622                raise ValueError(f'Cannot override "{RESERVED_POSIX_PROFILE_NAME}" profile with different settings.')
 623
 624        profile_dict = self._profiles.get(profile)
 625
 626        if not profile_dict:
 627            raise ValueError(f"Profile {profile} not found; available profiles: {list(self._profiles.keys())}")
 628
 629        self._profile = profile
 630        self._profile_dict = ImmutableDict(profile_dict)
 631
 632        self._opentelemetry_dict = config_dict.get("opentelemetry", None)
 633        # Multiprocessing unpickles during the Python interpreter's bootstrap phase for new processes.
 634        # New processes (e.g. multiprocessing manager server) can't be created during this phase.
 635        #
 636        # Pass thunks everywhere instead for lazy telemetry initialization.
 637        self._telemetry_provider = telemetry_provider or telemetry_init
 638
 639        self._cache_dict = config_dict.get("cache", None)
 640
 641        self._provider_bundle = self._build_provider_bundle(provider_bundle)
 642        self._inject_profiles_from_bundle()
 643
 644    def _build_storage_provider(
 645        self,
 646        storage_provider_name: str,
 647        storage_options: Optional[dict[str, Any]] = None,
 648        credentials_provider: Optional[CredentialsProvider] = None,
 649    ) -> StorageProvider:
 650        if storage_options is None:
 651            storage_options = {}
 652        if storage_provider_name not in STORAGE_PROVIDER_MAPPING:
 653            raise ValueError(
 654                f"Storage provider {storage_provider_name} is not supported. "
 655                f"Supported providers are: {list(STORAGE_PROVIDER_MAPPING.keys())}"
 656            )
 657        if credentials_provider:
 658            storage_options["credentials_provider"] = credentials_provider
 659        if self._resolved_config_dict is not None:
 660            # Make a deep copy to drop any external references which may be mutated or cause infinite recursion.
 661            storage_options["config_dict"] = copy.deepcopy(self._resolved_config_dict)
 662        if self._telemetry_provider is not None:
 663            storage_options["telemetry_provider"] = self._telemetry_provider
 664        class_name = STORAGE_PROVIDER_MAPPING[storage_provider_name]
 665        module_name = ".providers"
 666        cls = import_class(class_name, module_name, PACKAGE_NAME)
 667        return cls(**storage_options)
 668
 669    def _build_storage_provider_from_profile(self, storage_provider_profile: str):
 670        storage_profile_dict = self._profiles.get(storage_provider_profile)
 671        if not storage_profile_dict:
 672            raise ValueError(
 673                f"Profile '{storage_provider_profile}' referenced by storage_provider_profile does not exist."
 674            )
 675
 676        # Check if metadata provider is configured for this profile
 677        # NOTE: The storage profile for manifests does not support metadata provider (at the moment).
 678        local_metadata_provider_dict = storage_profile_dict.get("metadata_provider", None)
 679        if local_metadata_provider_dict:
 680            raise ValueError(
 681                f"Profile '{storage_provider_profile}' cannot have a metadata provider when used for manifests"
 682            )
 683
 684        # Initialize CredentialsProvider
 685        local_creds_provider_dict = storage_profile_dict.get("credentials_provider", None)
 686        local_creds_provider = self._build_credentials_provider(credentials_provider_dict=local_creds_provider_dict)
 687
 688        # Initialize StorageProvider
 689        local_storage_provider_dict = storage_profile_dict.get("storage_provider", None)
 690        if local_storage_provider_dict:
 691            local_name = local_storage_provider_dict["type"]
 692            local_storage_options = local_storage_provider_dict.get("options", {})
 693        else:
 694            raise ValueError(f"Missing storage_provider in the config for profile {storage_provider_profile}.")
 695
 696        storage_provider = self._build_storage_provider(local_name, local_storage_options, local_creds_provider)
 697        return storage_provider
 698
 699    def _build_credentials_provider(
 700        self,
 701        credentials_provider_dict: Optional[dict[str, Any]],
 702        storage_options: Optional[dict[str, Any]] = None,
 703    ) -> Optional[CredentialsProvider]:
 704        """
 705        Initializes the CredentialsProvider based on the provided dictionary.
 706
 707        Args:
 708            credentials_provider_dict: Dictionary containing credentials provider configuration
 709            storage_options: Storage provider options required by some credentials providers to scope the credentials.
 710        """
 711        if not credentials_provider_dict:
 712            return None
 713
 714        if credentials_provider_dict["type"] not in CREDENTIALS_PROVIDER_MAPPING:
 715            # Fully qualified class path case
 716            class_type = credentials_provider_dict["type"]
 717            module_name, class_name = class_type.rsplit(".", 1)
 718            cls = import_class(class_name, module_name)
 719        else:
 720            # Mapped class name case
 721            class_name = CREDENTIALS_PROVIDER_MAPPING[credentials_provider_dict["type"]]
 722            module_name = ".providers"
 723            cls = import_class(class_name, module_name, PACKAGE_NAME)
 724
 725        # Propagate storage provider options to credentials provider since they may be
 726        # required by some credentials providers to scope the credentials.
 727        import inspect
 728
 729        init_params = list(inspect.signature(cls.__init__).parameters)[1:]  # skip 'self'
 730        options = credentials_provider_dict.get("options", {})
 731        if storage_options:
 732            for storage_provider_option in storage_options.keys():
 733                if storage_provider_option in init_params and storage_provider_option not in options:
 734                    options[storage_provider_option] = storage_options[storage_provider_option]
 735
 736        return cls(**options)
 737
 738    def _build_provider_bundle_from_config(self, profile_dict: dict[str, Any]) -> ProviderBundle:
 739        # Initialize StorageProvider
 740        storage_provider_dict = profile_dict.get("storage_provider", None)
 741        if storage_provider_dict:
 742            storage_provider_name = storage_provider_dict["type"]
 743            storage_options = storage_provider_dict.get("options", {})
 744        else:
 745            raise ValueError("Missing storage_provider in the config.")
 746
 747        # Initialize CredentialsProvider
 748        # It is prudent to assume that in some cases, the credentials provider
 749        # will provide credentials scoped to specific base_path.
 750        # So we need to pass the storage_options to the credentials provider.
 751        credentials_provider_dict = profile_dict.get("credentials_provider", None)
 752        credentials_provider = self._build_credentials_provider(
 753            credentials_provider_dict=credentials_provider_dict,
 754            storage_options=storage_options,
 755        )
 756
 757        # Initialize MetadataProvider
 758        metadata_provider_dict = profile_dict.get("metadata_provider", None)
 759        metadata_provider = None
 760        if metadata_provider_dict:
 761            if metadata_provider_dict["type"] == "manifest":
 762                metadata_options = metadata_provider_dict.get("options", {})
 763                # If MetadataProvider has a reference to a different storage provider profile
 764                storage_provider_profile = metadata_options.pop("storage_provider_profile", None)
 765                if storage_provider_profile:
 766                    storage_provider = self._build_storage_provider_from_profile(storage_provider_profile)
 767                else:
 768                    storage_provider = self._build_storage_provider(
 769                        storage_provider_name, storage_options, credentials_provider
 770                    )
 771
 772                metadata_provider = ManifestMetadataProvider(storage_provider, **metadata_options)
 773            else:
 774                class_type = metadata_provider_dict["type"]
 775                if "." not in class_type:
 776                    raise ValueError(
 777                        f"Expected a fully qualified class name (e.g., 'module.ClassName'); got '{class_type}'."
 778                    )
 779                module_name, class_name = class_type.rsplit(".", 1)
 780                cls = import_class(class_name, module_name)
 781                options = metadata_provider_dict.get("options", {})
 782                metadata_provider = cls(**options)
 783
 784        # Build replicas if configured
 785        replicas_config = profile_dict.get("replicas", [])
 786        replicas = []
 787        if replicas_config:
 788            for replica_dict in replicas_config:
 789                replicas.append(
 790                    Replica(
 791                        replica_profile=replica_dict["replica_profile"],
 792                        read_priority=replica_dict["read_priority"],
 793                    )
 794                )
 795
 796            # Sort replicas by read_priority
 797            replicas.sort(key=lambda r: r.read_priority)
 798
 799        return SimpleProviderBundle(
 800            storage_provider_config=StorageProviderConfig(storage_provider_name, storage_options),
 801            credentials_provider=credentials_provider,
 802            metadata_provider=metadata_provider,
 803            replicas=replicas,
 804        )
 805
 806    def _build_provider_bundle_from_extension(self, provider_bundle_dict: dict[str, Any]) -> ProviderBundle:
 807        class_type = provider_bundle_dict["type"]
 808        module_name, class_name = class_type.rsplit(".", 1)
 809        cls = import_class(class_name, module_name)
 810        options = provider_bundle_dict.get("options", {})
 811        return cls(**options)
 812
 813    def _build_provider_bundle(
 814        self, provider_bundle: Optional[Union[ProviderBundle, ProviderBundleV2]]
 815    ) -> ProviderBundleV2:
 816        if provider_bundle:
 817            bundle = provider_bundle
 818        else:
 819            provider_bundle_dict = self._profile_dict.get("provider_bundle", None)
 820            if provider_bundle_dict:
 821                bundle = self._build_provider_bundle_from_extension(provider_bundle_dict)
 822            else:
 823                bundle = self._build_provider_bundle_from_config(self._profile_dict)
 824
 825        if isinstance(bundle, ProviderBundle) and not isinstance(bundle, ProviderBundleV2):
 826            bundle = SimpleProviderBundleV2.from_v1_bundle(self._profile, bundle)
 827
 828        return bundle
 829
 830    def _inject_profiles_from_bundle(self) -> None:
 831        """
 832        Inject child profiles and build child configs for multi-backend configurations.
 833
 834        For ProviderBundleV2 with multiple backends, this method:
 835        1. Injects child profiles into config dict (needed for replica initialization)
 836        2. Pre-builds child configs so CompositeStorageClient can use them directly
 837
 838        Profile injection is required because SingleStorageClient._initialize_replicas()
 839        uses StorageClientConfig.from_dict() to create replica clients, which looks up
 840        profiles in the config dict.
 841        """
 842        self._child_configs: Optional[dict[str, StorageClientConfig]] = None
 843
 844        backends = self._provider_bundle.storage_backends
 845        if len(backends) > 1:
 846            # First, inject all child profiles into config dict (needed for replica lookup)
 847            profiles = copy.deepcopy(self._profiles)
 848            for child_name, backend in backends.items():
 849                child_profile_dict = {
 850                    "storage_provider": {
 851                        "type": backend.storage_provider_config.type,
 852                        "options": backend.storage_provider_config.options,
 853                    }
 854                }
 855
 856                if child_name in profiles:
 857                    # Profile already exists - check if it matches what we would inject
 858                    existing = profiles[child_name]
 859                    if existing.get("storage_provider") != child_profile_dict["storage_provider"]:
 860                        raise ValueError(
 861                            f"Profile '{child_name}' already exists in configuration with different settings."
 862                        )
 863                else:
 864                    profiles[child_name] = child_profile_dict
 865
 866            # Update config dict BEFORE building child configs
 867            resolved_config_dict = copy.deepcopy(self._resolved_config_dict)
 868            resolved_config_dict["profiles"] = profiles
 869            self._profiles = ImmutableDict(profiles)
 870            self._resolved_config_dict = ImmutableDict(resolved_config_dict)
 871
 872            # Now build child configs (they will get the updated config dict)
 873            retry_config = self._build_retry_config()
 874            child_configs: dict[str, StorageClientConfig] = {}
 875            for child_name, backend in backends.items():
 876                storage_provider = self._build_storage_provider(
 877                    backend.storage_provider_config.type,
 878                    backend.storage_provider_config.options,
 879                    backend.credentials_provider,
 880                )
 881
 882                child_config = StorageClientConfig(
 883                    profile=child_name,
 884                    storage_provider=storage_provider,
 885                    credentials_provider=backend.credentials_provider,
 886                    storage_provider_profiles=None,
 887                    child_configs=None,
 888                    metadata_provider=None,
 889                    cache_config=None,
 890                    cache_manager=None,
 891                    retry_config=retry_config,
 892                    telemetry_provider=self._telemetry_provider,
 893                    replicas=backend.replicas,
 894                    autocommit_config=None,
 895                )
 896                child_config._config_dict = self._resolved_config_dict
 897                child_configs[child_name] = child_config
 898
 899            self._child_configs = child_configs
 900
 901    def _build_retry_config(self) -> RetryConfig:
 902        """Build retry config from profile dict."""
 903        retry_config_dict = self._profile_dict.get("retry", None)
 904        if retry_config_dict:
 905            attempts = retry_config_dict.get("attempts", DEFAULT_RETRY_ATTEMPTS)
 906            delay = retry_config_dict.get("delay", DEFAULT_RETRY_DELAY)
 907            backoff_multiplier = retry_config_dict.get("backoff_multiplier", DEFAULT_RETRY_BACKOFF_MULTIPLIER)
 908            return RetryConfig(attempts=attempts, delay=delay, backoff_multiplier=backoff_multiplier)
 909        else:
 910            return RetryConfig(
 911                attempts=DEFAULT_RETRY_ATTEMPTS,
 912                delay=DEFAULT_RETRY_DELAY,
 913                backoff_multiplier=DEFAULT_RETRY_BACKOFF_MULTIPLIER,
 914            )
 915
 916    def _build_autocommit_config(self) -> AutoCommitConfig:
 917        """Build autocommit config from profile dict."""
 918        autocommit_dict = self._profile_dict.get("autocommit", None)
 919        if autocommit_dict:
 920            interval_minutes = autocommit_dict.get("interval_minutes", None)
 921            at_exit = autocommit_dict.get("at_exit", False)
 922            return AutoCommitConfig(interval_minutes=interval_minutes, at_exit=at_exit)
 923        return AutoCommitConfig()
 924
 925    def _verify_cache_config(self, cache_dict: dict[str, Any]) -> None:
 926        if "size_mb" in cache_dict:
 927            raise ValueError(
 928                "The 'size_mb' property is no longer supported. \n"
 929                "Please use 'size' with a unit suffix (M, G, T) instead of size_mb.\n"
 930                "Example configuration:\n"
 931                "cache:\n"
 932                "  size: 500G                    # Optional: Maximum cache size (default: 10G)\n"
 933                "  cache_line_size: 64M          # Optional: Chunk size for partial file caching (default: 64M)\n"
 934                "  check_source_version: true    # Optional: Use ETag for cache validation (default: true)\n"
 935                "  location: /tmp/msc_cache      # Optional: Cache directory path (default: system tempdir + '/msc_cache')\n"
 936                "  eviction_policy:               # Optional: Cache eviction policy\n"
 937                "    policy: fifo                 # Optional: Policy type: lru, mru, fifo, random, no_eviction (default: fifo)\n"
 938                "    refresh_interval: 300        # Optional: Cache refresh interval in seconds (default: 300)\n"
 939            )
 940
 941        # Validate that cache_line_size doesn't exceed cache size
 942        cache_size_str = cache_dict.get("size", DEFAULT_CACHE_SIZE)
 943        cache_line_size_str = cache_dict.get("cache_line_size", DEFAULT_CACHE_LINE_SIZE)
 944
 945        # Use CacheConfig to convert sizes to bytes for comparison
 946        temp_cache_config = CacheConfig(
 947            size=cache_size_str,
 948            cache_line_size=cache_line_size_str,
 949        )
 950        cache_size_bytes = temp_cache_config.size_bytes()
 951        cache_line_size_bytes = temp_cache_config.cache_line_size_bytes()
 952
 953        if cache_line_size_bytes > cache_size_bytes:
 954            raise ValueError(
 955                f"cache_line_size ({cache_line_size_str}) exceeds cache size ({cache_size_str}). "
 956                f"cache_line_size must be less than or equal to cache size. "
 957                f"Consider increasing cache size or decreasing cache_line_size."
 958            )
 959
 960    def _validate_replicas(self, replicas: list[Replica]) -> None:
 961        """
 962        Validates that replica profiles do not have their own replicas configuration.
 963
 964        This prevents circular references where a replica profile could reference
 965        another profile that also has replicas, creating an infinite loop.
 966
 967        :param replicas: The list of Replica objects to validate
 968        :raises ValueError: If any replica profile has its own replicas configuration
 969        """
 970        for replica in replicas:
 971            replica_profile_name = replica.replica_profile
 972
 973            # Check that replica profile is not the same as the current profile
 974            if replica_profile_name == self._profile:
 975                raise ValueError(
 976                    f"Replica profile {replica_profile_name} cannot be the same as the profile {self._profile}."
 977                )
 978
 979            # Check if the replica profile exists in the configuration
 980            if replica_profile_name not in self._profiles:
 981                raise ValueError(f"Replica profile '{replica_profile_name}' not found in configuration")
 982
 983            # Get the replica profile configuration
 984            replica_profile_dict = self._profiles[replica_profile_name]
 985
 986            # Check if the replica profile has its own replicas configuration
 987            if "replicas" in replica_profile_dict and replica_profile_dict["replicas"]:
 988                raise ValueError(
 989                    f"Invalid replica configuration: profile '{replica_profile_name}' has its own replicas. "
 990                    f"This creates a circular reference which is not allowed."
 991                )
 992
 993    def build_config(self) -> "StorageClientConfig":
 994        bundle = self._provider_bundle
 995        backends = bundle.storage_backends
 996
 997        if len(backends) > 1:
 998            if not bundle.metadata_provider:
 999                raise ValueError(
1000                    f"Multi-backend configuration for profile '{self._profile}' requires metadata_provider "
1001                    "for routing between storage locations."
1002                )
1003
1004            child_profile_names = list(backends.keys())
1005            retry_config = self._build_retry_config()
1006            autocommit_config = self._build_autocommit_config()
1007
1008            # config for Composite StorageClient
1009            config = StorageClientConfig(
1010                profile=self._profile,
1011                storage_provider=None,
1012                credentials_provider=None,
1013                storage_provider_profiles=child_profile_names,
1014                child_configs=self._child_configs,
1015                metadata_provider=bundle.metadata_provider,
1016                cache_config=None,
1017                cache_manager=None,
1018                retry_config=retry_config,
1019                telemetry_provider=self._telemetry_provider,
1020                replicas=[],
1021                autocommit_config=autocommit_config,
1022            )
1023
1024            config._config_dict = self._resolved_config_dict
1025            return config
1026
1027        # Single-backend (len == 1)
1028        _, backend = list(backends.items())[0]
1029        storage_provider = self._build_storage_provider(
1030            backend.storage_provider_config.type,
1031            backend.storage_provider_config.options,
1032            backend.credentials_provider,
1033        )
1034        credentials_provider = backend.credentials_provider
1035        metadata_provider = bundle.metadata_provider
1036        replicas = backend.replicas
1037
1038        # Validate replicas to prevent circular references
1039        if replicas:
1040            self._validate_replicas(replicas)
1041
1042        cache_config: Optional[CacheConfig] = None
1043        cache_manager: Optional[CacheManager] = None
1044
1045        # Check if caching is enabled for this profile
1046        caching_enabled = self._profile_dict.get("caching_enabled", False)
1047
1048        if self._cache_dict is not None and caching_enabled:
1049            tempdir = tempfile.gettempdir()
1050            default_location = os.path.join(tempdir, "msc_cache")
1051            location = self._cache_dict.get("location", default_location)
1052
1053            # Check if cache_backend.cache_path is defined
1054            cache_backend = self._cache_dict.get("cache_backend", {})
1055            cache_backend_path = cache_backend.get("cache_path") if cache_backend else None
1056
1057            # Warn if both location and cache_backend.cache_path are defined
1058            if cache_backend_path and self._cache_dict.get("location") is not None:
1059                logger.warning(
1060                    f"Both 'location' and 'cache_backend.cache_path' are defined in cache config. "
1061                    f"Using 'location' ({location}) and ignoring 'cache_backend.cache_path' ({cache_backend_path})."
1062                )
1063            elif cache_backend_path:
1064                # Use cache_backend.cache_path only if location is not explicitly defined
1065                location = cache_backend_path
1066
1067            check_source_version = self._cache_dict.get("check_source_version", True)
1068
1069            if not Path(location).is_absolute():
1070                raise ValueError(f"Cache location must be an absolute path: {location}")
1071
1072            # Initialize cache_dict with default values
1073            cache_dict = self._cache_dict
1074
1075            # Verify cache config
1076            self._verify_cache_config(cache_dict)
1077
1078            # Initialize eviction policy
1079            if "eviction_policy" in cache_dict:
1080                policy = cache_dict["eviction_policy"]["policy"].lower()
1081                purge_factor = cache_dict["eviction_policy"].get("purge_factor", 0)
1082                eviction_policy = EvictionPolicyConfig(
1083                    policy=policy,
1084                    refresh_interval=cache_dict["eviction_policy"].get(
1085                        "refresh_interval", DEFAULT_CACHE_REFRESH_INTERVAL
1086                    ),
1087                    purge_factor=purge_factor,
1088                )
1089            else:
1090                eviction_policy = EvictionPolicyConfig(policy="fifo", refresh_interval=DEFAULT_CACHE_REFRESH_INTERVAL)
1091
1092            # Create cache config from the standardized format
1093            cache_config = CacheConfig(
1094                size=cache_dict.get("size", DEFAULT_CACHE_SIZE),
1095                location=cache_dict.get("location", location),
1096                check_source_version=check_source_version,
1097                prefetch_file=cache_dict.get("prefetch_file", True),
1098                eviction_policy=eviction_policy,
1099                cache_line_size=cache_dict.get("cache_line_size", DEFAULT_CACHE_LINE_SIZE),
1100            )
1101
1102            cache_manager = CacheManager(profile=self._profile, cache_config=cache_config)
1103        elif self._cache_dict is not None and not caching_enabled:
1104            logger.debug(f"Caching is disabled for profile '{self._profile}'")
1105        elif self._cache_dict is None and caching_enabled:
1106            logger.warning(f"Caching is enabled for profile '{self._profile}' but no cache configuration is provided")
1107
1108        retry_config = self._build_retry_config()
1109        autocommit_config = self._build_autocommit_config()
1110
1111        config = StorageClientConfig(
1112            profile=self._profile,
1113            storage_provider=storage_provider,
1114            credentials_provider=credentials_provider,
1115            storage_provider_profiles=None,
1116            child_configs=None,
1117            metadata_provider=metadata_provider,
1118            cache_config=cache_config,
1119            cache_manager=cache_manager,
1120            retry_config=retry_config,
1121            telemetry_provider=self._telemetry_provider,
1122            replicas=replicas,
1123            autocommit_config=autocommit_config,
1124        )
1125
1126        config._config_dict = self._resolved_config_dict
1127        return config
1128
1129
1130class PathMapping:
1131    """
1132    Class to handle path mappings defined in the MSC configuration.
1133
1134    Path mappings create a nested structure of protocol -> bucket -> [(prefix, profile)]
1135    where entries are sorted by prefix length (longest first) for optimal matching.
1136    Longer paths take precedence when matching.
1137    """
1138
1139    def __init__(self):
1140        """Initialize an empty PathMapping."""
1141        self._mapping = defaultdict(lambda: defaultdict(list))
1142
1143    @classmethod
1144    def from_config(cls, config_dict: Optional[dict[str, Any]] = None) -> "PathMapping":
1145        """
1146        Create a PathMapping instance from configuration dictionary.
1147
1148        :param config_dict: Configuration dictionary, if None the config will be loaded
1149        :return: A PathMapping instance with processed mappings
1150        """
1151        if config_dict is None:
1152            # Import locally to avoid circular imports
1153            from multistorageclient.config import StorageClientConfig
1154
1155            config_dict, _ = StorageClientConfig.read_msc_config()
1156
1157        if not config_dict:
1158            return cls()
1159
1160        instance = cls()
1161        instance._load_mapping(config_dict)
1162        return instance
1163
1164    def _load_mapping(self, config_dict: dict[str, Any]) -> None:
1165        """
1166        Load path mapping from a configuration dictionary.
1167
1168        :param config_dict: Configuration dictionary containing path mapping
1169        """
1170        # Get the path_mapping section
1171        path_mapping = config_dict.get("path_mapping", {})
1172        if path_mapping is None:
1173            return
1174
1175        # Process each mapping
1176        for source_path, dest_path in path_mapping.items():
1177            # Validate format
1178            if not source_path.endswith("/"):
1179                continue
1180            if not dest_path.startswith(MSC_PROTOCOL):
1181                continue
1182            if not dest_path.endswith("/"):
1183                continue
1184
1185            # Extract the destination profile
1186            pr_dest = urlparse(dest_path)
1187            dest_profile = pr_dest.netloc
1188
1189            # Parse the source path
1190            pr = urlparse(source_path)
1191            protocol = pr.scheme.lower() if pr.scheme else "file"
1192
1193            if protocol == "file" or source_path.startswith("/"):
1194                # For file or absolute paths, use the whole path as the prefix
1195                # and leave bucket empty
1196                bucket = ""
1197                prefix = source_path if source_path.startswith("/") else pr.path
1198            else:
1199                # For object storage, extract bucket and prefix
1200                bucket = pr.netloc
1201                prefix = pr.path
1202                if prefix.startswith("/"):
1203                    prefix = prefix[1:]
1204
1205            # Add the mapping to the nested dict
1206            self._mapping[protocol][bucket].append((prefix, dest_profile))
1207
1208        # Sort each bucket's prefixes by length (longest first) for optimal matching
1209        for protocol, buckets in self._mapping.items():
1210            for bucket, prefixes in buckets.items():
1211                self._mapping[protocol][bucket] = sorted(prefixes, key=lambda x: len(x[0]), reverse=True)
1212
1213    def find_mapping(self, url: str) -> Optional[tuple[str, str]]:
1214        """
1215        Find the best matching mapping for the given URL.
1216
1217        :param url: URL to find matching mapping for
1218        :return: Tuple of (profile_name, translated_path) if a match is found, None otherwise
1219        """
1220        # Parse the URL
1221        pr = urlparse(url)
1222        protocol = pr.scheme.lower() if pr.scheme else "file"
1223
1224        # For file paths or absolute paths
1225        if protocol == "file" or url.startswith("/"):
1226            path = url if url.startswith("/") else pr.path
1227
1228            possible_mapping = self._mapping[protocol][""] if protocol in self._mapping else []
1229
1230            # Check each prefix (already sorted by length, longest first)
1231            for prefix, profile in possible_mapping:
1232                if path.startswith(prefix):
1233                    # Calculate the relative path
1234                    rel_path = path[len(prefix) :]
1235                    if not rel_path.startswith("/"):
1236                        rel_path = "/" + rel_path
1237                    return profile, rel_path
1238
1239            return None
1240
1241        # For object storage
1242        bucket = pr.netloc
1243        path = pr.path
1244        if path.startswith("/"):
1245            path = path[1:]
1246
1247        # Check bucket-specific mapping
1248        possible_mapping = (
1249            self._mapping[protocol][bucket] if (protocol in self._mapping and bucket in self._mapping[protocol]) else []
1250        )
1251
1252        # Check each prefix (already sorted by length, longest first)
1253        for prefix, profile in possible_mapping:
1254            # matching prefix
1255            if path.startswith(prefix):
1256                rel_path = path[len(prefix) :]
1257                # Remove leading slash if present
1258                if rel_path.startswith("/"):
1259                    rel_path = rel_path[1:]
1260
1261                return profile, rel_path
1262
1263        return None
1264
1265
[docs] 1266class StorageClientConfig: 1267 """ 1268 Configuration class for the :py:class:`multistorageclient.StorageClient`. 1269 """ 1270 1271 profile: str 1272 storage_provider: Optional[StorageProvider] 1273 credentials_provider: Optional[CredentialsProvider] 1274 storage_provider_profiles: Optional[list[str]] 1275 child_configs: Optional[dict[str, "StorageClientConfig"]] 1276 metadata_provider: Optional[MetadataProvider] 1277 cache_config: Optional[CacheConfig] 1278 cache_manager: Optional[CacheManager] 1279 retry_config: Optional[RetryConfig] 1280 telemetry_provider: Optional[Callable[[], Telemetry]] 1281 replicas: list[Replica] 1282 autocommit_config: Optional[AutoCommitConfig] 1283 1284 _config_dict: Optional[dict[str, Any]] 1285 1286 def __init__( 1287 self, 1288 profile: str, 1289 storage_provider: Optional[StorageProvider] = None, 1290 credentials_provider: Optional[CredentialsProvider] = None, 1291 storage_provider_profiles: Optional[list[str]] = None, 1292 child_configs: Optional[dict[str, "StorageClientConfig"]] = None, 1293 metadata_provider: Optional[MetadataProvider] = None, 1294 cache_config: Optional[CacheConfig] = None, 1295 cache_manager: Optional[CacheManager] = None, 1296 retry_config: Optional[RetryConfig] = None, 1297 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1298 replicas: Optional[list[Replica]] = None, 1299 autocommit_config: Optional[AutoCommitConfig] = None, 1300 ): 1301 # exactly one of storage_provider or storage_provider_profiles must be set 1302 if storage_provider and storage_provider_profiles: 1303 raise ValueError( 1304 "Cannot specify both storage_provider and storage_provider_profiles. " 1305 "Use storage_provider for SingleStorageClient or storage_provider_profiles for CompositeStorageClient." 1306 ) 1307 if not storage_provider and not storage_provider_profiles: 1308 raise ValueError("Must specify either storage_provider or storage_provider_profiles.") 1309 1310 if replicas is None: 1311 replicas = [] 1312 self.profile = profile 1313 self.storage_provider = storage_provider 1314 self.credentials_provider = credentials_provider 1315 self.storage_provider_profiles = storage_provider_profiles 1316 self.child_configs = child_configs 1317 self.metadata_provider = metadata_provider 1318 self.cache_config = cache_config 1319 self.cache_manager = cache_manager 1320 self.retry_config = retry_config 1321 self.telemetry_provider = telemetry_provider 1322 self.replicas = replicas 1323 self.autocommit_config = autocommit_config 1324
[docs] 1325 @staticmethod 1326 def from_json( 1327 config_json: str, 1328 profile: str = RESERVED_POSIX_PROFILE_NAME, 1329 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1330 ) -> "StorageClientConfig": 1331 """ 1332 Load a storage client configuration from a JSON string. 1333 1334 :param config_json: Configuration JSON string. 1335 :param profile: Profile to use. 1336 :param telemetry_provider: A function that provides a telemetry instance. The function must be defined at the top level of a module to work with pickling. 1337 """ 1338 config_dict = json.loads(config_json) 1339 return StorageClientConfig.from_dict( 1340 config_dict=config_dict, profile=profile, telemetry_provider=telemetry_provider 1341 )
1342
[docs] 1343 @staticmethod 1344 def from_yaml( 1345 config_yaml: str, 1346 profile: str = RESERVED_POSIX_PROFILE_NAME, 1347 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1348 ) -> "StorageClientConfig": 1349 """ 1350 Load a storage client configuration from a YAML string. 1351 1352 :param config_yaml: Configuration YAML string. 1353 :param profile: Profile to use. 1354 :param telemetry_provider: A function that provides a telemetry instance. The function must be defined at the top level of a module to work with pickling. 1355 """ 1356 config_dict = yaml.safe_load(config_yaml) or {} 1357 return StorageClientConfig.from_dict( 1358 config_dict=config_dict, profile=profile, telemetry_provider=telemetry_provider 1359 )
1360
[docs] 1361 @staticmethod 1362 def from_dict( 1363 config_dict: dict[str, Any], 1364 profile: str = RESERVED_POSIX_PROFILE_NAME, 1365 skip_validation: bool = False, 1366 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1367 ) -> "StorageClientConfig": 1368 """ 1369 Load a storage client configuration from a Python dictionary. 1370 1371 :param config_dict: Configuration Python dictionary. 1372 :param profile: Profile to use. 1373 :param skip_validation: Skip configuration schema validation. 1374 :param telemetry_provider: A function that provides a telemetry instance. The function must be defined at the top level of a module to work with pickling. 1375 """ 1376 # Validate the config file with predefined JSON schema 1377 if not skip_validation: 1378 validate_config(config_dict) 1379 1380 # Load config 1381 loader = StorageClientConfigLoader( 1382 config_dict=config_dict, 1383 profile=profile, 1384 telemetry_provider=telemetry_provider, 1385 ) 1386 config = loader.build_config() 1387 1388 return config
1389
[docs] 1390 @staticmethod 1391 def from_file( 1392 config_file_paths: Optional[Iterable[str]] = None, 1393 profile: str = RESERVED_POSIX_PROFILE_NAME, 1394 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1395 ) -> "StorageClientConfig": 1396 """ 1397 Load a storage client configuration from the first file found. 1398 1399 :param config_file_paths: Configuration file search paths. If omitted, the default search paths are used (see :py:meth:`StorageClientConfig.read_msc_config`). 1400 :param profile: Profile to use. 1401 :param telemetry_provider: A function that provides a telemetry instance. The function must be defined at the top level of a module to work with pickling. 1402 """ 1403 msc_config_dict, msc_config_file = StorageClientConfig.read_msc_config(config_file_paths=config_file_paths) 1404 # Parse rclone config file. 1405 rclone_config_dict, rclone_config_file = read_rclone_config() 1406 1407 # Merge config files. 1408 merged_config, conflicted_keys = merge_dictionaries_no_overwrite(msc_config_dict, rclone_config_dict) 1409 if conflicted_keys: 1410 raise ValueError( 1411 f'Conflicting keys found in configuration files "{msc_config_file}" and "{rclone_config_file}: {conflicted_keys}' 1412 ) 1413 merged_profiles = merged_config.get("profiles", {}) 1414 1415 # Check if profile is in merged_profiles 1416 if profile in merged_profiles: 1417 return StorageClientConfig.from_dict( 1418 config_dict=merged_config, profile=profile, telemetry_provider=telemetry_provider 1419 ) 1420 else: 1421 # Check if profile is the default POSIX profile or an implicit profile 1422 if profile == RESERVED_POSIX_PROFILE_NAME: 1423 implicit_profile_config = DEFAULT_POSIX_PROFILE 1424 elif profile.startswith("_"): 1425 # Handle implicit profiles 1426 parts = profile[1:].split("-", 1) 1427 if len(parts) == 2: 1428 protocol, bucket = parts 1429 # Verify it's a supported protocol 1430 if protocol not in SUPPORTED_IMPLICIT_PROFILE_PROTOCOLS: 1431 raise ValueError(f'Unsupported protocol in implicit profile: "{protocol}"') 1432 implicit_profile_config = create_implicit_profile_config( 1433 profile_name=profile, protocol=protocol, base_path=bucket 1434 ) 1435 else: 1436 raise ValueError(f'Invalid implicit profile format: "{profile}"') 1437 else: 1438 raise ValueError( 1439 f'Profile "{profile}" not found in configuration files. Configuration was checked in ' 1440 f"{msc_config_file or 'MSC config (not found)'} and {rclone_config_file or 'Rclone config (not found)'}. " 1441 f"Please verify that the profile exists and that configuration files are correctly located." 1442 ) 1443 # merge the implicit profile config into the merged config so the cache & observability config can be inherited 1444 if "profiles" not in merged_config: 1445 merged_config["profiles"] = implicit_profile_config["profiles"] 1446 else: 1447 merged_config["profiles"][profile] = implicit_profile_config["profiles"][profile] 1448 # the config is already validated while reading, skip the validation for implicit profiles which start profile with "_" 1449 return StorageClientConfig.from_dict( 1450 config_dict=merged_config, profile=profile, skip_validation=True, telemetry_provider=telemetry_provider 1451 )
1452 1453 @staticmethod 1454 def from_provider_bundle( 1455 config_dict: dict[str, Any], 1456 provider_bundle: Union[ProviderBundle, ProviderBundleV2], 1457 telemetry_provider: Optional[Callable[[], Telemetry]] = None, 1458 ) -> "StorageClientConfig": 1459 loader = StorageClientConfigLoader( 1460 config_dict=config_dict, provider_bundle=provider_bundle, telemetry_provider=telemetry_provider 1461 ) 1462 config = loader.build_config() 1463 config._config_dict = None # Explicitly mark as None to avoid confusing pickling errors 1464 return config 1465
[docs] 1466 @staticmethod 1467 def read_msc_config( 1468 config_file_paths: Optional[Iterable[str]] = None, 1469 ) -> tuple[Optional[dict[str, Any]], Optional[str]]: 1470 """Get the MSC configuration dictionary and the path of the first file found. 1471 1472 If no config paths are specified, configs are searched in the following order: 1473 1474 1. ``MSC_CONFIG`` environment variable (highest precedence) 1475 2. Standard search paths (user-specified config and system-wide config) 1476 1477 :param config_file_paths: Configuration file search paths. If omitted, the default search paths are used. 1478 :return: Tuple of ``(config_dict, config_file_path)``. ``config_dict`` is the MSC configuration 1479 dictionary or empty dict if no config was found. ``config_file_path`` is the absolute 1480 path of the config file used, or ``None`` if no config file was found. 1481 """ 1482 config_dict: dict[str, Any] = {} 1483 config_file_path: Optional[str] = None 1484 1485 config_file_paths = list(config_file_paths or []) 1486 1487 # Add default paths if none provided. 1488 if len(config_file_paths) == 0: 1489 # Environment variable. 1490 msc_config_env = os.getenv("MSC_CONFIG", None) 1491 if msc_config_env is not None: 1492 config_file_paths.append(msc_config_env) 1493 1494 # Standard search paths. 1495 config_file_paths.extend(_find_config_file_paths()) 1496 1497 # Normalize + absolutize paths. 1498 config_file_paths = [os.path.abspath(path) for path in config_file_paths] 1499 1500 # Log plan. 1501 logger.debug(f"Searching MSC config file paths: {config_file_paths}") 1502 1503 # Load config. 1504 for path in config_file_paths: 1505 if os.path.exists(path): 1506 try: 1507 with open(path) as f: 1508 if path.endswith(".json"): 1509 config_dict = json.load(f) 1510 else: 1511 config_dict = yaml.safe_load(f) 1512 config_file_path = path 1513 # Use the first config file. 1514 break 1515 except Exception as e: 1516 raise ValueError(f"malformed MSC config file: {path}, exception: {e}") 1517 1518 # Log result. 1519 if config_file_path is None: 1520 logger.debug("No MSC config files found in any of the search locations.") 1521 else: 1522 logger.debug(f"Using MSC config file: {config_file_path}") 1523 1524 if config_dict: 1525 validate_config(config_dict) 1526 1527 if "include" in config_dict and config_file_path: 1528 config_dict = _load_and_merge_includes(config_file_path, config_dict) 1529 1530 return config_dict, config_file_path
1531
[docs] 1532 @staticmethod 1533 def read_path_mapping() -> PathMapping: 1534 """ 1535 Get the path mapping defined in the MSC configuration. 1536 1537 Path mappings create a nested structure of protocol -> bucket -> [(prefix, profile)] 1538 where entries are sorted by prefix length (longest first) for optimal matching. 1539 Longer paths take precedence when matching. 1540 1541 :return: A PathMapping instance with translation mappings 1542 """ 1543 try: 1544 return PathMapping.from_config() 1545 except Exception: 1546 # Log the error but continue - this shouldn't stop the application from working 1547 logger.error("Failed to load path_mapping from MSC config") 1548 return PathMapping()
1549 1550 def __getstate__(self) -> dict[str, Any]: 1551 state = self.__dict__.copy() 1552 if not state.get("_config_dict"): 1553 raise ValueError("StorageClientConfig is not serializable") 1554 del state["credentials_provider"] 1555 del state["storage_provider"] 1556 del state["metadata_provider"] 1557 del state["cache_manager"] 1558 del state["replicas"] 1559 del state["child_configs"] 1560 return state 1561 1562 def __setstate__(self, state: dict[str, Any]) -> None: 1563 # Presence checked by __getstate__. 1564 config_dict = state["_config_dict"] 1565 loader = StorageClientConfigLoader( 1566 config_dict=config_dict, 1567 profile=state["profile"], 1568 telemetry_provider=state["telemetry_provider"], 1569 ) 1570 new_config = loader.build_config() 1571 self.profile = new_config.profile 1572 self.storage_provider = new_config.storage_provider 1573 self.credentials_provider = new_config.credentials_provider 1574 self.storage_provider_profiles = new_config.storage_provider_profiles 1575 self.child_configs = new_config.child_configs 1576 self.metadata_provider = new_config.metadata_provider 1577 self.cache_config = new_config.cache_config 1578 self.cache_manager = new_config.cache_manager 1579 self.retry_config = new_config.retry_config 1580 self.telemetry_provider = new_config.telemetry_provider 1581 self._config_dict = config_dict 1582 self.replicas = new_config.replicas 1583 self.autocommit_config = new_config.autocommit_config