notebookutils-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ """Process-safe POSIX mounting layer.
2
+
3
+ Concurrency safety across processes (e.g. co-scheduled Spark executors on a
4
+ shared host) is enforced with a POSIX ``fcntl`` file-lock protocol:
5
+
6
+ 1. Acquire an exclusive ``flock`` on a lock file derived from a SHA-256 hash
7
+ of the resolved local mount path (hashing avoids ``/tmp`` name collisions
8
+ between distinct paths).
9
+ 2. Inside the critical section, check ``os.path.ismount``: if already
10
+ mounted, step over the FUSE launch (idempotency).
11
+ 3. Otherwise translate the Global Registry payload + ``extraConfigs`` into an
12
+ explicit CLI command and execute the FUSE daemon.
13
+
14
+ An on-disk JSON mount registry (guarded by its own lock) backs ``mounts()``,
15
+ ``getMountPath()`` and ``unmount()`` across processes on the same host.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fcntl
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import subprocess
25
+ import tempfile
26
+ from contextlib import contextmanager
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from notebookutils.fs import _registry
30
+ from notebookutils.fs._client import get_canonical_protocol
31
+ from notebookutils.fs._fileinfo import MountPointInfo
32
+
33
+ DEFAULT_MOUNT_TIMEOUT = 30 # seconds, per Fabric documentation
34
+
35
+
36
+ def _state_dir() -> str:
37
+ base = os.environ.get(
38
+ "NOTEBOOKUTILS_STATE_DIR",
39
+ os.path.join(tempfile.gettempdir(), f"notebookutils-{os.getuid()}"),
40
+ )
41
+ os.makedirs(base, mode=0o700, exist_ok=True)
42
+ return base
43
+
44
+
45
+ def _mount_base_dir() -> str:
46
+ base = os.environ.get(
47
+ "NOTEBOOKUTILS_MOUNT_BASE", os.path.join(_state_dir(), "mounts")
48
+ )
49
+ os.makedirs(base, exist_ok=True)
50
+ return base
51
+
52
+
53
+ def _registry_path() -> str:
54
+ return os.path.join(_state_dir(), "mounts.json")
55
+
56
+
57
+ def _lock_path_for(key: str) -> str:
58
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
59
+ return os.path.join(_state_dir(), f"lock_{digest}.lock")
60
+
61
+
62
+ @contextmanager
63
+ def _flock(lock_path: str):
64
+ with open(lock_path, "w") as lock_file:
65
+ try:
66
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
67
+ yield
68
+ finally:
69
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Mount registry persistence
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _read_registry() -> Dict[str, Dict[str, Any]]:
77
+ path = _registry_path()
78
+ if not os.path.exists(path):
79
+ return {}
80
+ try:
81
+ with open(path) as f:
82
+ return json.load(f)
83
+ except (json.JSONDecodeError, OSError):
84
+ return {}
85
+
86
+
87
+ def _write_registry(data: Dict[str, Dict[str, Any]]) -> None:
88
+ path = _registry_path()
89
+ tmp = path + ".tmp"
90
+ with open(tmp, "w") as f:
91
+ json.dump(data, f, indent=2)
92
+ os.replace(tmp, path)
93
+
94
+
95
+ @contextmanager
96
+ def _registry_transaction():
97
+ with _flock(_lock_path_for("__mount_registry__")):
98
+ data = _read_registry()
99
+ yield data
100
+ _write_registry(data)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Mount point resolution
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _normalize_mount_point(mount_point: str) -> str:
108
+ mount_point = mount_point.rstrip("/") or "/"
109
+ if not mount_point.startswith("/"):
110
+ mount_point = "/" + mount_point
111
+ return mount_point
112
+
113
+
114
+ def _resolve_local_path(mount_point: str) -> str:
115
+ """Resolve the actual local directory backing a logical mount point.
116
+
117
+ Prefer mounting directly at ``mount_point``; if it cannot be created
118
+ (e.g. unprivileged ``/test``), fall back to a per-user base directory.
119
+ """
120
+ try:
121
+ os.makedirs(mount_point, exist_ok=True)
122
+ if os.access(mount_point, os.W_OK):
123
+ return mount_point
124
+ except OSError:
125
+ pass
126
+ fallback = os.path.join(_mount_base_dir(), mount_point.lstrip("/"))
127
+ os.makedirs(fallback, exist_ok=True)
128
+ return fallback
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Public API
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def mount(
136
+ source: str,
137
+ mount_point: str,
138
+ extra_configs: Optional[Dict[str, Any]] = None,
139
+ ) -> bool:
140
+ """Mount a cloud storage location at a local POSIX path.
141
+
142
+ Supported ``extra_configs`` keys (Fabric-compatible):
143
+
144
+ - ``accountKey``: Azure storage account key (abfs/abfss only).
145
+ - ``sasToken``: Azure SAS token (abfs/abfss only).
146
+ - ``fileCacheTimeout``: local file cache timeout in seconds.
147
+ - ``timeout``: mount operation timeout in seconds (default 30).
148
+ """
149
+ extra_configs = dict(extra_configs or {})
150
+ protocol = get_canonical_protocol(source)
151
+ mount_point = _normalize_mount_point(mount_point)
152
+ local_path = _resolve_local_path(mount_point)
153
+
154
+ with _flock(_lock_path_for(local_path)):
155
+ # Critical section: evaluate filesystem status inside the safe window
156
+ if os.path.ismount(local_path):
157
+ _record_mount(mount_point, source, local_path, extra_configs)
158
+ return True # already handled by a neighboring process
159
+
160
+ config = _registry.get_fsspec_options(protocol)
161
+ mount_opts = _registry.get_mount_options(protocol)
162
+
163
+ if protocol == "abfs":
164
+ from notebookutils.fs._mount_azure import exec_blobfuse2
165
+
166
+ exec_blobfuse2(source, local_path, config, mount_opts, extra_configs)
167
+ elif protocol == "s3":
168
+ from notebookutils.fs._mount_s3 import exec_mount_s3
169
+
170
+ exec_mount_s3(source, local_path, config, mount_opts, extra_configs)
171
+ elif protocol == "gcs":
172
+ from notebookutils.fs._mount_gcs import exec_gcsfuse
173
+
174
+ exec_gcsfuse(source, local_path, config, mount_opts, extra_configs)
175
+ else:
176
+ raise ValueError(
177
+ f"Unsupported mount target protocol scheme: {protocol}"
178
+ )
179
+
180
+ _record_mount(mount_point, source, local_path, extra_configs)
181
+ return True
182
+
183
+
184
+ def unmount(mount_point: str, extra_configs: Optional[Dict[str, Any]] = None) -> bool:
185
+ """Unmount and remove a mount point previously created with :func:`mount`."""
186
+ mount_point = _normalize_mount_point(mount_point)
187
+ with _registry_transaction() as data:
188
+ entry = data.get(mount_point)
189
+ local_path = entry["localPath"] if entry else _resolve_local_path(mount_point)
190
+
191
+ with _flock(_lock_path_for(local_path)):
192
+ if os.path.ismount(local_path):
193
+ _exec_unmount(local_path)
194
+
195
+ with _registry_transaction() as data:
196
+ data.pop(mount_point, None)
197
+ return True
198
+
199
+
200
+ def mounts() -> List[MountPointInfo]:
201
+ """List all existing mount points registered on this host."""
202
+ result: List[MountPointInfo] = []
203
+ with _registry_transaction() as data:
204
+ stale = [
205
+ mp for mp, e in data.items() if not os.path.ismount(e.get("localPath", ""))
206
+ ]
207
+ for mp in stale:
208
+ data.pop(mp, None)
209
+ for mp, entry in sorted(data.items()):
210
+ result.append(
211
+ MountPointInfo(
212
+ mountPoint=mp,
213
+ source=entry.get("source", ""),
214
+ localPath=entry.get("localPath", ""),
215
+ extraConfigs=entry.get("extraConfigs", {}),
216
+ )
217
+ )
218
+ return result
219
+
220
+
221
+ def getMountPath(mount_point: str, scope: str = "") -> str:
222
+ """Get the local file system path backing a mount point."""
223
+ mount_point = _normalize_mount_point(mount_point)
224
+ with _registry_transaction() as data:
225
+ entry = data.get(mount_point)
226
+ if entry is None:
227
+ raise ValueError(f"Mount point not found: {mount_point}")
228
+ return entry["localPath"]
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Internals
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def _record_mount(
236
+ mount_point: str,
237
+ source: str,
238
+ local_path: str,
239
+ extra_configs: Dict[str, Any],
240
+ ) -> None:
241
+ # Never persist raw credentials in the on-disk registry
242
+ safe_configs = {
243
+ k: v
244
+ for k, v in extra_configs.items()
245
+ if k not in ("accountKey", "sasToken")
246
+ }
247
+ with _registry_transaction() as data:
248
+ data[mount_point] = {
249
+ "source": source,
250
+ "localPath": local_path,
251
+ "extraConfigs": safe_configs,
252
+ }
253
+
254
+
255
+ def _exec_unmount(local_path: str) -> None:
256
+ timeout = DEFAULT_MOUNT_TIMEOUT
257
+ for cmd in (["fusermount", "-u", local_path], ["umount", local_path]):
258
+ try:
259
+ subprocess.run(
260
+ cmd,
261
+ check=True,
262
+ timeout=timeout,
263
+ capture_output=True,
264
+ )
265
+ return
266
+ except (FileNotFoundError, subprocess.CalledProcessError):
267
+ continue
268
+ raise RuntimeError(f"Failed to unmount {local_path}")
@@ -0,0 +1,188 @@
1
+ """FUSE execution layer for Azure Blob Storage / ADLS Gen2 via blobfuse2.
2
+
3
+ Because blobfuse2 cannot consume abstract Python credential classes, this
4
+ module unrolls the ``DefaultAzureCredential`` chain to discover which concrete
5
+ mechanism succeeds locally, then maps it onto an explicit blobfuse2 auth-type
6
+ (``spn``, ``azcli``, ``msi``, ``key``, ``sas``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import subprocess
14
+ import tempfile
15
+ from typing import Any, Dict, Optional, Tuple
16
+
17
+ from notebookutils.fs._mount import DEFAULT_MOUNT_TIMEOUT
18
+
19
+ logging.getLogger("azure.identity").setLevel(logging.ERROR)
20
+
21
+
22
+ def _parse_azure_source(source: str, config: Dict[str, Any]) -> Tuple[str, str, str]:
23
+ """Return ``(account, container, prefix)`` from an abfs(s):// URL.
24
+
25
+ Supports both ``abfss://container@account.dfs.core.windows.net/path`` and
26
+ ``abfs://container/path`` (account from registry ``account_name``).
27
+ """
28
+ clean = source.split("://", 1)[1]
29
+ if "@" in clean:
30
+ container, _, host_and_path = clean.partition("@")
31
+ host, _, prefix = host_and_path.partition("/")
32
+ account = host.split(".", 1)[0]
33
+ else:
34
+ container, _, prefix = clean.partition("/")
35
+ account = config.get("account_name", "")
36
+ account = config.get("account_name") or account
37
+ if not account:
38
+ raise ValueError(
39
+ "Cannot determine the Azure storage account name. Use the "
40
+ "abfss://container@account.dfs.core.windows.net form or call "
41
+ "configure('abfs', account_name=...)."
42
+ )
43
+ return account, container, prefix
44
+
45
+
46
+ def _resolve_auth(
47
+ account: str, config: Dict[str, Any], extra_configs: Dict[str, Any]
48
+ ) -> Tuple[str, Dict[str, str]]:
49
+ """Resolve blobfuse2 auth mode + parameters.
50
+
51
+ Priority: explicit extraConfigs (accountKey/sasToken) > explicit registry
52
+ keys > DefaultAzureCredential chain unrolling.
53
+ """
54
+ if extra_configs.get("accountKey"):
55
+ return "key", {"account-key": str(extra_configs["accountKey"])}
56
+ if extra_configs.get("sasToken"):
57
+ return "sas", {"sas": str(extra_configs["sasToken"])}
58
+ if config.get("account_key"):
59
+ return "key", {"account-key": str(config["account_key"])}
60
+ if config.get("sas_token"):
61
+ return "sas", {"sas": str(config["sas_token"])}
62
+
63
+ auth_type = _unroll_credential_chain(account)
64
+ params: Dict[str, str] = {}
65
+ if auth_type == "spn":
66
+ params = {
67
+ "clientid": os.environ.get("AZURE_CLIENT_ID", ""),
68
+ "clientsecret": os.environ.get("AZURE_CLIENT_SECRET", ""),
69
+ "tenantid": os.environ.get("AZURE_TENANT_ID", ""),
70
+ }
71
+ return auth_type, params
72
+
73
+
74
+ def _unroll_credential_chain(account: str) -> str:
75
+ """Probe the DefaultAzureCredential chain and map the first working
76
+ sub-credential onto a blobfuse2 auth-type."""
77
+ try:
78
+ from azure.identity import DefaultAzureCredential
79
+ except ImportError:
80
+ return "msi" # fallback guess without the SDK
81
+
82
+ scope = f"https://{account}.dfs.core.windows.net/.default"
83
+ try:
84
+ cred = DefaultAzureCredential()
85
+ for sub_cred in getattr(cred, "credentials", []):
86
+ try:
87
+ sub_cred.get_token(scope)
88
+ except Exception:
89
+ continue
90
+ class_name = type(sub_cred).__name__
91
+ if class_name == "EnvironmentCredential":
92
+ return "spn"
93
+ if class_name in ("AzureCliCredential", "AzureDeveloperCliCredential"):
94
+ return "azcli"
95
+ if class_name in ("ManagedIdentityCredential",):
96
+ return "msi"
97
+ return "msi"
98
+ except Exception:
99
+ pass
100
+ return "msi"
101
+
102
+
103
+ def _build_yaml_config(
104
+ account: str,
105
+ container: str,
106
+ prefix: str,
107
+ mount_point: str,
108
+ cache_dir: str,
109
+ auth_type: str,
110
+ auth_params: Dict[str, str],
111
+ mount_opts: Dict[str, Any],
112
+ extra_configs: Dict[str, Any],
113
+ ) -> str:
114
+ file_cache_timeout = int(
115
+ extra_configs.get(
116
+ "fileCacheTimeout", mount_opts.get("fileCacheTimeout", 120)
117
+ )
118
+ )
119
+ allow_other = str(bool(mount_opts.get("allow_other", True))).lower()
120
+
121
+ lines = [
122
+ f"allow-other: {allow_other}",
123
+ "components:",
124
+ " - libfuse",
125
+ " - file_cache",
126
+ " - attr_cache",
127
+ " - azstorage",
128
+ "libfuse:",
129
+ f" mount-path: {mount_point}",
130
+ "file_cache:",
131
+ f" path: {cache_dir}",
132
+ f" timeout-sec: {file_cache_timeout}",
133
+ "azstorage:",
134
+ " type: adls",
135
+ f" account-name: {account}",
136
+ f" container: {container}",
137
+ f" mode: {auth_type}",
138
+ ]
139
+ if prefix:
140
+ lines.append(f" subdirectory: {prefix.rstrip('/')}")
141
+ for key, value in auth_params.items():
142
+ if value:
143
+ lines.append(f" {key}: {value}")
144
+ return "\n".join(lines) + "\n"
145
+
146
+
147
+ def exec_blobfuse2(
148
+ source: str,
149
+ mount_point: str,
150
+ config: Dict[str, Any],
151
+ mount_opts: Dict[str, Any],
152
+ extra_configs: Dict[str, Any],
153
+ cache_dir: Optional[str] = None,
154
+ ) -> None:
155
+ account, container, prefix = _parse_azure_source(source, config)
156
+ auth_type, auth_params = _resolve_auth(account, config, extra_configs)
157
+
158
+ cache_dir = cache_dir or mount_opts.get("cache_path") or tempfile.mkdtemp(
159
+ prefix=f"blobfuse_cache_{container}_"
160
+ )
161
+ os.makedirs(cache_dir, exist_ok=True)
162
+
163
+ yaml_config = _build_yaml_config(
164
+ account,
165
+ container,
166
+ prefix,
167
+ mount_point,
168
+ cache_dir,
169
+ auth_type,
170
+ auth_params,
171
+ mount_opts,
172
+ extra_configs,
173
+ )
174
+
175
+ fd, cfg_name = tempfile.mkstemp(suffix=".yaml", prefix="blobfuse2_cfg_")
176
+ try:
177
+ os.fchmod(fd, 0o600)
178
+ with os.fdopen(fd, "w") as tmp:
179
+ tmp.write(yaml_config)
180
+ timeout = int(extra_configs.get("timeout", DEFAULT_MOUNT_TIMEOUT))
181
+ subprocess.run(
182
+ ["blobfuse2", "mount", mount_point, f"--config-file={cfg_name}"],
183
+ check=True,
184
+ timeout=timeout,
185
+ )
186
+ finally:
187
+ if os.path.exists(cfg_name):
188
+ os.remove(cfg_name)
@@ -0,0 +1,51 @@
1
+ """FUSE execution layer for Google Cloud Storage via gcsfuse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from typing import Any, Dict
8
+
9
+ from notebookutils.fs._mount import DEFAULT_MOUNT_TIMEOUT
10
+
11
+
12
+ def exec_gcsfuse(
13
+ source: str,
14
+ mount_point: str,
15
+ config: Dict[str, Any],
16
+ mount_opts: Dict[str, Any],
17
+ extra_configs: Dict[str, Any],
18
+ ) -> None:
19
+ """Translate registry configuration into a ``gcsfuse`` CLI invocation.
20
+
21
+ Credential resolution follows GCP Application Default Credentials:
22
+ explicit key file (registry ``token`` or ``GOOGLE_APPLICATION_CREDENTIALS``)
23
+ else the GCE metadata server, handled natively by gcsfuse.
24
+ """
25
+ remainder = source.split("://", 1)[1]
26
+ bucket, _, prefix = remainder.partition("/")
27
+
28
+ cmd = ["gcsfuse"]
29
+
30
+ key_file = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") or config.get("token")
31
+ if key_file and isinstance(key_file, str) and os.path.exists(key_file):
32
+ cmd.extend(["--key-file", key_file])
33
+
34
+ if prefix:
35
+ cmd.extend(["--only-dir", prefix.rstrip("/")])
36
+
37
+ if mount_opts.get("allow_other", True):
38
+ cmd.extend(["-o", "allow_other"])
39
+ if "uid" in mount_opts:
40
+ cmd.extend(["--uid", str(mount_opts["uid"])])
41
+ if "gid" in mount_opts:
42
+ cmd.extend(["--gid", str(mount_opts["gid"])])
43
+
44
+ if "fileCacheTimeout" in extra_configs:
45
+ ttl = int(extra_configs["fileCacheTimeout"])
46
+ cmd.extend(["--stat-cache-ttl", f"{ttl}s", "--type-cache-ttl", f"{ttl}s"])
47
+
48
+ cmd.extend([bucket, mount_point])
49
+
50
+ timeout = int(extra_configs.get("timeout", DEFAULT_MOUNT_TIMEOUT))
51
+ subprocess.run(cmd, check=True, timeout=timeout)
@@ -0,0 +1,70 @@
1
+ """FUSE execution layer for Amazon S3 (and S3-compatible) via mount-s3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from typing import Any, Dict
8
+
9
+ from notebookutils.fs._mount import DEFAULT_MOUNT_TIMEOUT
10
+
11
+
12
+ def exec_mount_s3(
13
+ source: str,
14
+ mount_point: str,
15
+ config: Dict[str, Any],
16
+ mount_opts: Dict[str, Any],
17
+ extra_configs: Dict[str, Any],
18
+ ) -> None:
19
+ """Translate registry configuration into a ``mount-s3`` CLI invocation.
20
+
21
+ Credential resolution mirrors the AWS default chain: explicit registry
22
+ keys > profile > environment variables > IMDS instance profile (handled
23
+ natively by mount-s3).
24
+ """
25
+ remainder = source.split("://", 1)[1]
26
+ bucket, _, prefix = remainder.partition("/")
27
+
28
+ cmd = ["mount-s3", bucket, mount_point]
29
+ env = os.environ.copy()
30
+
31
+ if prefix:
32
+ prefix = prefix.rstrip("/") + "/"
33
+ cmd.extend(["--prefix", prefix])
34
+
35
+ if "profile" in config:
36
+ cmd.extend(["--profile", str(config["profile"])])
37
+ if config.get("anon", False):
38
+ cmd.append("--no-sign-request")
39
+ if "key" in config and "secret" in config:
40
+ env["AWS_ACCESS_KEY_ID"] = str(config["key"])
41
+ env["AWS_SECRET_ACCESS_KEY"] = str(config["secret"])
42
+ if config.get("token"):
43
+ env["AWS_SESSION_TOKEN"] = str(config["token"])
44
+
45
+ # Custom / S3-compatible endpoints (OVH, Hetzner, DigitalOcean, MinIO...)
46
+ endpoint = config.get("endpoint_url") or (
47
+ config.get("client_kwargs", {}) or {}
48
+ ).get("endpoint_url")
49
+ if endpoint:
50
+ cmd.extend(["--endpoint-url", str(endpoint)])
51
+ cmd.append("--force-path-style")
52
+
53
+ region = config.get("region") or (config.get("client_kwargs", {}) or {}).get(
54
+ "region_name"
55
+ )
56
+ if region:
57
+ cmd.extend(["--region", str(region)])
58
+
59
+ # OS-level mount tuning
60
+ if mount_opts.get("allow_other", True):
61
+ cmd.append("--allow-other")
62
+ if "uid" in mount_opts:
63
+ cmd.extend(["--uid", str(mount_opts["uid"])])
64
+ if "gid" in mount_opts:
65
+ cmd.extend(["--gid", str(mount_opts["gid"])])
66
+ if not mount_opts.get("read_only", False):
67
+ cmd.append("--allow-delete")
68
+
69
+ timeout = int(extra_configs.get("timeout", DEFAULT_MOUNT_TIMEOUT))
70
+ subprocess.run(cmd, env=env, check=True, timeout=timeout)
@@ -0,0 +1,86 @@
1
+ """Thread-safe Global Registry for per-protocol configuration.
2
+
3
+ The registry tracks two groups of parameters per protocol:
4
+
5
+ - ``fsspec_options``: kwargs passed directly to ``fsspec.filesystem()``
6
+ (memory-space client wrappers).
7
+ - ``mount_options``: OS-specific tuning parameters consumed during FUSE
8
+ mount command generation (uid, gid, cache settings, allow_other, ...).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from typing import Any, Dict
15
+
16
+ _lock = threading.RLock()
17
+
18
+ _fs_registry: Dict[str, Dict[str, Any]] = {}
19
+ _fs_instances: Dict[str, Any] = {}
20
+
21
+
22
+ def configure(protocol: str, **kwargs: Any) -> None:
23
+ """Explicit setup interface to override environment discovery.
24
+
25
+ Strips out system-level mount configurations (``mount_options``) from
26
+ pure fsspec parameters. Invalidates any cached fsspec client instance
27
+ for the protocol.
28
+
29
+ Example::
30
+
31
+ notebookutils.fs.configure("s3", key="AKIA...", secret="...",
32
+ mount_options={"allow_other": False})
33
+ """
34
+ protocol = normalize_protocol(protocol)
35
+ mount_options = kwargs.pop("mount_options", {})
36
+ with _lock:
37
+ _fs_registry[protocol] = {
38
+ "fsspec_options": dict(kwargs),
39
+ "mount_options": dict(mount_options),
40
+ }
41
+ _fs_instances.pop(protocol, None)
42
+
43
+
44
+ def normalize_protocol(protocol: str) -> str:
45
+ """Collapse protocol aliases to their canonical fsspec name."""
46
+ aliases = {
47
+ "s3a": "s3",
48
+ "s3n": "s3",
49
+ "abfss": "abfs",
50
+ "adl": "abfs",
51
+ "gs": "gcs",
52
+ "": "file",
53
+ "local": "file",
54
+ }
55
+ return aliases.get(protocol, protocol)
56
+
57
+
58
+ def get_fsspec_options(protocol: str) -> Dict[str, Any]:
59
+ with _lock:
60
+ return dict(
61
+ _fs_registry.get(normalize_protocol(protocol), {}).get("fsspec_options", {})
62
+ )
63
+
64
+
65
+ def get_mount_options(protocol: str) -> Dict[str, Any]:
66
+ with _lock:
67
+ return dict(
68
+ _fs_registry.get(normalize_protocol(protocol), {}).get("mount_options", {})
69
+ )
70
+
71
+
72
+ def get_cached_instance(protocol: str) -> Any:
73
+ with _lock:
74
+ return _fs_instances.get(normalize_protocol(protocol))
75
+
76
+
77
+ def set_cached_instance(protocol: str, instance: Any) -> None:
78
+ with _lock:
79
+ _fs_instances[normalize_protocol(protocol)] = instance
80
+
81
+
82
+ def reset() -> None:
83
+ """Clear all configuration and cached clients (mainly for tests)."""
84
+ with _lock:
85
+ _fs_registry.clear()
86
+ _fs_instances.clear()