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