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.
- notebookutils/__init__.py +19 -0
- notebookutils/fs/__init__.py +359 -0
- notebookutils/fs/_client.py +69 -0
- notebookutils/fs/_fileinfo.py +47 -0
- notebookutils/fs/_mount.py +268 -0
- notebookutils/fs/_mount_azure.py +188 -0
- notebookutils/fs/_mount_gcs.py +51 -0
- notebookutils/fs/_mount_s3.py +70 -0
- notebookutils/fs/_registry.py +86 -0
- notebookutils_py-0.1.0.dist-info/METADATA +359 -0
- notebookutils_py-0.1.0.dist-info/RECORD +13 -0
- notebookutils_py-0.1.0.dist-info/WHEEL +4 -0
- notebookutils_py-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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()
|