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,19 @@
1
+ """notebookutils: platform-agnostic notebook utilities.
2
+
3
+ Provides :mod:`notebookutils.fs`, a Fabric/Synapse-compatible file system
4
+ utility module backed by ``fsspec`` (memory-space operations) and FUSE
5
+ binaries (POSIX mounting), usable on any local, VM, or Spark/K8s environment.
6
+ """
7
+
8
+ import sys as _sys
9
+
10
+ from notebookutils import fs
11
+
12
+ __all__ = ["fs"]
13
+ __version__ = "0.1.0"
14
+
15
+ # Best-effort compatibility shim: after `import notebookutils`, legacy code
16
+ # using `import mssparkutils` / `mssparkutils.fs` also works within the same
17
+ # interpreter session.
18
+ _sys.modules.setdefault("mssparkutils", _sys.modules[__name__])
19
+ _sys.modules.setdefault("mssparkutils.fs", fs)
@@ -0,0 +1,359 @@
1
+ """notebookutils.fs — Fabric/Synapse-compatible file system utilities.
2
+
3
+ Memory-space operations are backed by ``fsspec`` (s3fs, adlfs, gcsfs, local)
4
+ and POSIX mounting is backed by FUSE binaries (mount-s3, blobfuse2, gcsfuse).
5
+
6
+ Run ``notebookutils.fs.help()`` for an overview of the available methods.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import posixpath
13
+ import shutil
14
+ import subprocess
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from notebookutils.fs._client import (
18
+ get_canonical_protocol,
19
+ get_fs_and_path,
20
+ get_fs_client,
21
+ strip_protocol_for_local,
22
+ )
23
+ from notebookutils.fs._fileinfo import FileInfo, MountPointInfo
24
+ from notebookutils.fs._mount import (
25
+ getMountPath,
26
+ mount,
27
+ mounts,
28
+ unmount,
29
+ )
30
+ from notebookutils.fs._registry import configure
31
+
32
+ # Suppress underlying vendor telemetry noise
33
+ logging.getLogger("azure.identity").setLevel(logging.ERROR)
34
+ logging.getLogger("google.auth").setLevel(logging.ERROR)
35
+
36
+ __all__ = [
37
+ "ls",
38
+ "mkdirs",
39
+ "cp",
40
+ "fastcp",
41
+ "mv",
42
+ "put",
43
+ "head",
44
+ "append",
45
+ "rm",
46
+ "exists",
47
+ "getProperties",
48
+ "help",
49
+ "mount",
50
+ "unmount",
51
+ "mounts",
52
+ "getMountPath",
53
+ "configure",
54
+ "FileInfo",
55
+ "MountPointInfo",
56
+ ]
57
+
58
+ _COPY_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB streaming buffer
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def _to_fileinfo(entry: Dict[str, Any], protocol: str) -> FileInfo:
66
+ raw_path = entry.get("name", "")
67
+ is_dir = entry.get("type") == "directory"
68
+ size = int(entry.get("size") or 0)
69
+
70
+ name = posixpath.basename(raw_path.rstrip("/"))
71
+ if protocol != "file" and "://" not in raw_path:
72
+ display_path = f"{protocol}://{raw_path}"
73
+ else:
74
+ display_path = raw_path
75
+
76
+ mtime = entry.get("mtime") or entry.get("last_modified") or entry.get("LastModified")
77
+ modify_time: Optional[int] = None
78
+ if mtime is not None:
79
+ try:
80
+ modify_time = int(mtime.timestamp() * 1000) # datetime-like
81
+ except AttributeError:
82
+ try:
83
+ modify_time = int(float(mtime) * 1000)
84
+ except (TypeError, ValueError):
85
+ modify_time = None
86
+
87
+ return FileInfo(
88
+ path=display_path,
89
+ name=name,
90
+ size=size,
91
+ isDir=is_dir,
92
+ modifyTime=modify_time,
93
+ )
94
+
95
+
96
+ def _is_dir(fs: Any, path: str) -> bool:
97
+ try:
98
+ return fs.isdir(path)
99
+ except Exception:
100
+ return False
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Memory-space public API (Fabric-compatible signatures)
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def ls(path: str) -> List[FileInfo]:
108
+ """List the contents of a directory as :class:`FileInfo` objects."""
109
+ fs, native = get_fs_and_path(path)
110
+ protocol = get_canonical_protocol(path)
111
+ entries = fs.ls(native, detail=True)
112
+ return [_to_fileinfo(e, protocol) for e in entries]
113
+
114
+
115
+ def mkdirs(path: str) -> bool:
116
+ """Create the given directory and any necessary parent directories."""
117
+ fs, native = get_fs_and_path(path)
118
+ fs.makedirs(native, exist_ok=True)
119
+ return True
120
+
121
+
122
+ def exists(path: str) -> bool:
123
+ """Check whether a file or directory exists at the given path."""
124
+ fs, native = get_fs_and_path(path)
125
+ return bool(fs.exists(native))
126
+
127
+
128
+ def rm(path: str, recurse: bool = False) -> bool:
129
+ """Remove a file or directory. Set ``recurse=True`` for directories."""
130
+ fs, native = get_fs_and_path(path)
131
+ fs.rm(native, recursive=recurse)
132
+ return True
133
+
134
+
135
+ def put(file: str, content: str, overwrite: bool = False) -> bool:
136
+ """Write the given string out to a file, encoded in UTF-8."""
137
+ fs, native = get_fs_and_path(file)
138
+ if not overwrite and fs.exists(native):
139
+ raise FileExistsError(
140
+ f"File already exists: {file}. Pass overwrite=True to replace it."
141
+ )
142
+ parent = posixpath.dirname(native.rstrip("/"))
143
+ if parent:
144
+ try:
145
+ fs.makedirs(parent, exist_ok=True)
146
+ except Exception:
147
+ pass
148
+ with fs.open(native, "wb") as f:
149
+ f.write(content.encode("utf-8"))
150
+ return True
151
+
152
+
153
+ def append(file: str, content: str, createFileIfNotExists: bool = False) -> bool:
154
+ """Append the content to a file, encoded in UTF-8.
155
+
156
+ .. note:: Not safe for concurrent writes to the same file. Object-store
157
+ backends lack a native append primitive, so this performs
158
+ read-modify-write there; local files use a true O_APPEND.
159
+ """
160
+ fs, native = get_fs_and_path(file)
161
+ file_exists = fs.exists(native)
162
+ if not file_exists and not createFileIfNotExists:
163
+ raise FileNotFoundError(
164
+ f"File not found: {file}. Pass createFileIfNotExists=True to create it."
165
+ )
166
+ protocol = get_canonical_protocol(file)
167
+ data = content.encode("utf-8")
168
+ if protocol == "file":
169
+ with fs.open(native, "ab") as f:
170
+ f.write(data)
171
+ else:
172
+ existing = fs.cat_file(native) if file_exists else b""
173
+ with fs.open(native, "wb") as f:
174
+ f.write(existing + data)
175
+ return True
176
+
177
+
178
+ def head(file: str, max_bytes: int = 1024 * 100) -> str:
179
+ """Return up to the first ``max_bytes`` bytes of the file as a UTF-8 string."""
180
+ fs, native = get_fs_and_path(file)
181
+ with fs.open(native, "rb") as f:
182
+ data = f.read(max_bytes)
183
+ return data.decode("utf-8", errors="replace")
184
+
185
+
186
+ def getProperties(path: str) -> Dict[str, Any]:
187
+ """Get the metadata properties of the given path as a dictionary."""
188
+ fs, native = get_fs_and_path(path)
189
+ info = dict(fs.info(native))
190
+ # Normalize non-JSON-friendly values to strings for portability
191
+ return {k: (v if isinstance(v, (str, int, float, bool, list, dict, type(None))) else str(v)) for k, v in info.items()}
192
+
193
+
194
+ def cp(src: str, dest: str, recurse: bool = False) -> bool:
195
+ """Copy a file or directory, possibly across file systems / clouds.
196
+
197
+ Same-protocol copies use the backend's native copy. Cross-protocol copies
198
+ stream through memory in chunks — no intermediate files are written to
199
+ local disk.
200
+ """
201
+ src_proto = get_canonical_protocol(src)
202
+ dest_proto = get_canonical_protocol(dest)
203
+
204
+ if src_proto == dest_proto:
205
+ fs, native_src = get_fs_and_path(src)
206
+ _, native_dest = get_fs_and_path(dest)
207
+ if _is_dir(fs, native_src) and not recurse:
208
+ raise IsADirectoryError(
209
+ f"{src} is a directory. Pass recurse=True to copy directories."
210
+ )
211
+ fs.cp(native_src, native_dest, recursive=recurse)
212
+ return True
213
+
214
+ # Cross-provider streaming bridge
215
+ src_fs, native_src = get_fs_and_path(src)
216
+ dest_fs, native_dest = get_fs_and_path(dest)
217
+
218
+ if _is_dir(src_fs, native_src):
219
+ if not recurse:
220
+ raise IsADirectoryError(
221
+ f"{src} is a directory. Pass recurse=True to copy directories."
222
+ )
223
+ base = native_src.rstrip("/")
224
+ for src_file in src_fs.find(base):
225
+ rel = src_file[len(base) :].lstrip("/")
226
+ target = posixpath.join(native_dest.rstrip("/"), rel)
227
+ _stream_copy_file(src_fs, src_file, dest_fs, target)
228
+ else:
229
+ _stream_copy_file(src_fs, native_src, dest_fs, native_dest)
230
+ return True
231
+
232
+
233
+ def _stream_copy_file(src_fs: Any, src_path: str, dest_fs: Any, dest_path: str) -> None:
234
+ parent = posixpath.dirname(dest_path.rstrip("/"))
235
+ if parent:
236
+ try:
237
+ dest_fs.makedirs(parent, exist_ok=True)
238
+ except Exception:
239
+ pass
240
+ with src_fs.open(src_path, "rb") as rf, dest_fs.open(dest_path, "wb") as wf:
241
+ shutil.copyfileobj(rf, wf, _COPY_CHUNK_SIZE)
242
+
243
+
244
+ def fastcp(
245
+ src: str,
246
+ dest: str,
247
+ recurse: bool = True,
248
+ extraConfigs: Optional[Dict[str, Any]] = None,
249
+ ) -> bool:
250
+ """Copy via ``azcopy`` for better performance with large data volumes.
251
+
252
+ Falls back to :func:`cp` transparently when the ``azcopy`` binary is not
253
+ available or when neither endpoint is Azure/local.
254
+ """
255
+ azcopy = shutil.which("azcopy")
256
+ src_proto = get_canonical_protocol(src)
257
+ dest_proto = get_canonical_protocol(dest)
258
+ azcopy_protos = {"file", "abfs", "az", "https"}
259
+
260
+ if (
261
+ azcopy
262
+ and src_proto in azcopy_protos
263
+ and dest_proto in azcopy_protos
264
+ and "abfs" in (src_proto, dest_proto)
265
+ ):
266
+ cmd = [azcopy, "copy", _azcopy_url(src), _azcopy_url(dest)]
267
+ if recurse:
268
+ cmd.append("--recursive")
269
+ result = subprocess.run(cmd, capture_output=True, text=True)
270
+ if result.returncode == 0:
271
+ return True
272
+ logging.getLogger(__name__).warning(
273
+ "azcopy failed (%s); falling back to cp()", result.stderr.strip()[:500]
274
+ )
275
+ return cp(src, dest, recurse=recurse)
276
+
277
+
278
+ def _azcopy_url(path: str) -> str:
279
+ proto = get_canonical_protocol(path)
280
+ if proto == "file":
281
+ return strip_protocol_for_local(path)
282
+ if proto == "abfs":
283
+ clean = path.split("://", 1)[1]
284
+ if "@" in clean:
285
+ container, _, host_and_path = clean.partition("@")
286
+ host, _, sub = host_and_path.partition("/")
287
+ account = host.split(".", 1)[0]
288
+ url = f"https://{account}.blob.core.windows.net/{container}"
289
+ return f"{url}/{sub}" if sub else url
290
+ return path
291
+
292
+
293
+ def mv(
294
+ src: str,
295
+ dest: str,
296
+ create_path: bool = False,
297
+ overwrite: bool = False,
298
+ ) -> bool:
299
+ """Move a file or directory, possibly across file systems."""
300
+ src_proto = get_canonical_protocol(src)
301
+ dest_proto = get_canonical_protocol(dest)
302
+
303
+ dest_fs, native_dest = get_fs_and_path(dest)
304
+ if dest_fs.exists(native_dest) and not overwrite:
305
+ raise FileExistsError(
306
+ f"Destination already exists: {dest}. Pass overwrite=True to replace it."
307
+ )
308
+ parent = posixpath.dirname(native_dest.rstrip("/"))
309
+ if parent and not dest_fs.exists(parent):
310
+ if create_path:
311
+ dest_fs.makedirs(parent, exist_ok=True)
312
+ elif dest_proto == "file":
313
+ # Object stores have virtual directories; only real local/POSIX
314
+ # filesystems require the parent to pre-exist.
315
+ raise FileNotFoundError(
316
+ f"Parent directory does not exist: {parent}. "
317
+ "Pass create_path=True to create it."
318
+ )
319
+
320
+ src_fs, native_src = get_fs_and_path(src)
321
+ recurse = _is_dir(src_fs, native_src)
322
+
323
+ if dest_fs.exists(native_dest) and overwrite:
324
+ dest_fs.rm(native_dest, recursive=True)
325
+
326
+ if src_proto == dest_proto:
327
+ src_fs.mv(native_src, native_dest, recursive=recurse)
328
+ else:
329
+ cp(src, dest, recurse=recurse)
330
+ src_fs.rm(native_src, recursive=recurse)
331
+ return True
332
+
333
+
334
+ def help(method: Optional[str] = None) -> None:
335
+ """Print an overview of the available notebookutils.fs methods."""
336
+ table = {
337
+ "ls": "ls(path: str) -> List[FileInfo]: Lists the contents of a directory.",
338
+ "mkdirs": "mkdirs(path: str) -> bool: Creates the directory and necessary parents.",
339
+ "cp": "cp(src: str, dest: str, recurse: bool = False) -> bool: Copies a file or directory, possibly across file systems.",
340
+ "fastcp": "fastcp(src: str, dest: str, recurse: bool = True, extraConfigs: dict = None) -> bool: Copies via azcopy for better performance; falls back to cp().",
341
+ "mv": "mv(src: str, dest: str, create_path: bool = False, overwrite: bool = False) -> bool: Moves a file or directory, possibly across file systems.",
342
+ "put": "put(file: str, content: str, overwrite: bool = False) -> bool: Writes the given string out to a file, encoded in UTF-8.",
343
+ "head": "head(file: str, max_bytes: int = 102400) -> str: Returns up to the first max_bytes of the file as a UTF-8 string.",
344
+ "append": "append(file: str, content: str, createFileIfNotExists: bool = False) -> bool: Appends the content to a file.",
345
+ "rm": "rm(path: str, recurse: bool = False) -> bool: Removes a file or directory.",
346
+ "exists": "exists(path: str) -> bool: Checks if a file or directory exists.",
347
+ "getProperties": "getProperties(path: str) -> dict: Gets the metadata properties of the given path.",
348
+ "mount": "mount(source: str, mount_point: str, extra_configs: dict = None) -> bool: Mounts remote storage at a local POSIX path.",
349
+ "unmount": "unmount(mount_point: str, extra_configs: dict = None) -> bool: Unmounts and removes a mount point.",
350
+ "mounts": "mounts() -> List[MountPointInfo]: Lists all existing mount points.",
351
+ "getMountPath": "getMountPath(mount_point: str, scope: str = '') -> str: Gets the local path for a mount point.",
352
+ "configure": "configure(protocol: str, **kwargs) -> None: Overrides credential/config discovery for a protocol.",
353
+ }
354
+ if method:
355
+ print(table.get(method, f"Unknown method: {method}"))
356
+ return
357
+ print("notebookutils.fs provides file system utilities backed by fsspec and FUSE.\n")
358
+ for line in table.values():
359
+ print(f" {line}")
@@ -0,0 +1,69 @@
1
+ """Protocol parsing and fsspec client resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Tuple
6
+
7
+ from notebookutils.fs import _registry
8
+
9
+ # Maps canonical protocol -> (pip extra, backend package) for error messages.
10
+ _BACKEND_HINTS = {
11
+ "s3": ("s3", "s3fs"),
12
+ "abfs": ("azure", "adlfs"),
13
+ "az": ("azure", "adlfs"),
14
+ "gcs": ("gcs", "gcsfs"),
15
+ }
16
+
17
+
18
+ def get_protocol(path: str) -> str:
19
+ """Extract the raw protocol scheme from a path (without normalizing)."""
20
+ if "://" in path:
21
+ return path.split("://", 1)[0]
22
+ if path.startswith("file:"):
23
+ return "file"
24
+ return "file"
25
+
26
+
27
+ def get_canonical_protocol(path: str) -> str:
28
+ return _registry.normalize_protocol(get_protocol(path))
29
+
30
+
31
+ def strip_protocol_for_local(path: str) -> str:
32
+ """For ``file:`` URLs return the plain OS path; otherwise return as-is."""
33
+ if path.startswith("file://"):
34
+ return path[len("file://") :] or "/"
35
+ if path.startswith("file:"):
36
+ return path[len("file:") :]
37
+ return path
38
+
39
+
40
+ def get_fs_client(path: str) -> Any:
41
+ """Resolve and instantiate the proper fsspec storage client wrapper."""
42
+ import fsspec
43
+
44
+ protocol = get_canonical_protocol(path)
45
+ cached = _registry.get_cached_instance(protocol)
46
+ if cached is not None:
47
+ return cached
48
+
49
+ config = _registry.get_fsspec_options(protocol)
50
+ try:
51
+ instance = fsspec.filesystem(protocol, **config)
52
+ except ImportError as exc:
53
+ extra, pkg = _BACKEND_HINTS.get(protocol, (None, None))
54
+ if extra:
55
+ raise ImportError(
56
+ f"The '{protocol}' protocol requires the '{pkg}' package. "
57
+ f"Install it with: pip install notebookutils[{extra}]"
58
+ ) from exc
59
+ raise
60
+ _registry.set_cached_instance(protocol, instance)
61
+ return instance
62
+
63
+
64
+ def get_fs_and_path(path: str) -> Tuple[Any, str]:
65
+ """Return ``(fsspec_filesystem, backend_native_path)`` for a user path."""
66
+ fs = get_fs_client(path)
67
+ if get_canonical_protocol(path) == "file":
68
+ return fs, strip_protocol_for_local(path)
69
+ return fs, path
@@ -0,0 +1,47 @@
1
+ """FileInfo and MountPointInfo data structures (Fabric-compatible)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class FileInfo:
11
+ """Describes a file system entry, mirroring Fabric's notebookutils.
12
+
13
+ Attributes match the Fabric usage pattern::
14
+
15
+ for f in notebookutils.fs.ls(path):
16
+ print(f.name, f.isDir, f.isFile, f.path, f.size)
17
+ """
18
+
19
+ path: str
20
+ name: str
21
+ size: int
22
+ isDir: bool
23
+ modifyTime: Optional[int] = None
24
+
25
+ @property
26
+ def isFile(self) -> bool:
27
+ return not self.isDir
28
+
29
+ def __repr__(self) -> str: # compact, notebook-friendly
30
+ kind = "dir" if self.isDir else "file"
31
+ return f"FileInfo(path={self.path!r}, name={self.name!r}, size={self.size}, {kind})"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class MountPointInfo:
36
+ """Describes an active mount point, mirroring Fabric's ``mounts()`` output."""
37
+
38
+ mountPoint: str
39
+ source: str
40
+ localPath: str
41
+ extraConfigs: Dict[str, Any] = field(default_factory=dict)
42
+
43
+ def __repr__(self) -> str:
44
+ return (
45
+ f"MountPointInfo(mountPoint={self.mountPoint!r}, "
46
+ f"source={self.source!r}, localPath={self.localPath!r})"
47
+ )