globusfs 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.
globusfs/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """fsspec filesystem for Globus collections."""
2
+
3
+ from .core import GlobusFileSystem
4
+ from .credentials import (
5
+ AnonymousCredentials,
6
+ AppCredentials,
7
+ CallableToken,
8
+ GlobusCredentials,
9
+ StaticToken,
10
+ )
11
+ from .errors import GlobusFSError, TransientBackendError
12
+ from .login import filesystem, login
13
+ from .transfer import TransferMetadata
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "AnonymousCredentials",
19
+ "AppCredentials",
20
+ "CallableToken",
21
+ "GlobusCredentials",
22
+ "GlobusFSError",
23
+ "GlobusFileSystem",
24
+ "StaticToken",
25
+ "TransferMetadata",
26
+ "TransientBackendError",
27
+ "filesystem",
28
+ "login",
29
+ ]
globusfs/core.py ADDED
@@ -0,0 +1,486 @@
1
+ """fsspec filesystem for Globus collections.
2
+
3
+ URLs look like ``globus://<collection-uuid>/path/to/file``. The UUID is
4
+ the authority because it is the stable identifier for a collection; the
5
+ ``https_url`` that actually serves bytes is a deployment detail resolved
6
+ at runtime.
7
+
8
+ Why this subclasses HTTPFileSystem
9
+ ----------------------------------
10
+ Globus Connect Server serves full HTTP range semantics -- verified
11
+ against a live collection: 206 responses, correct ``Content-Range``,
12
+ mid-file seeks, and multipart ranges. ``HTTPFileSystem`` already speaks
13
+ exactly that dialect, including 416 for out-of-range. So the read path
14
+ is inherited rather than rewritten.
15
+
16
+ What has to be overridden
17
+ -------------------------
18
+ Two things, both about *not trusting a 404*:
19
+
20
+ ``_raise_not_found_for_status``
21
+ fsspec maps 404 to ``FileNotFoundError``. On GCS a 404 is just as
22
+ likely to be a transient backend fault (see :mod:`globusfs.errors`),
23
+ so this inspects the body first. Every read path in HTTPFileSystem
24
+ funnels through this one method, which is why the fix is small.
25
+
26
+ ``_info`` / ``_ls``
27
+ The HTTPS interface has no directory listings at all, so metadata
28
+ comes from the Transfer API instead.
29
+
30
+ The Transfer API also supplies real file sizes, which sidesteps a third
31
+ quirk: GCS returns 416 for suffix ranges (``bytes=-8``), the usual way
32
+ parquet readers seek to a footer. With the size known, readers can
33
+ address the footer absolutely.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import logging
40
+ from typing import Any
41
+
42
+ from fsspec import AbstractFileSystem
43
+ from fsspec.asyn import sync
44
+ from fsspec.implementations.http import HTTPFileSystem
45
+
46
+ from .credentials import AnonymousCredentials, GlobusCredentials
47
+ from .errors import TransientBackendError, is_transient_body
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ __all__ = ["GlobusFileSystem"]
52
+
53
+ DEFAULT_RETRIES = 5
54
+ DEFAULT_BACKOFF = 0.3
55
+
56
+
57
+ class _RetryingFetcher:
58
+ """Block fetcher that routes through the transient-404 retry path.
59
+
60
+ ``HTTPFile.async_fetch_range`` calls ``raise_for_status()`` directly,
61
+ so a backend fault reaches the caller as a bare 404 with no
62
+ classification -- the exact failure this package exists to prevent.
63
+ Substituting the fetcher puts file-object reads on the same footing
64
+ as ``cat_file``.
65
+
66
+ A class rather than a closure so the file object stays picklable.
67
+ """
68
+
69
+ def __init__(self, fs: GlobusFileSystem, url: str) -> None:
70
+ self.fs = fs
71
+ self.url = url
72
+
73
+ def __call__(self, start, end):
74
+ return sync(self.fs.loop, self.fs._cat_file, self.url, start=start, end=end)
75
+
76
+
77
+ class GlobusFileSystem(HTTPFileSystem):
78
+ """Read files from Globus collections over HTTPS.
79
+
80
+ Parameters
81
+ ----------
82
+ collection_id:
83
+ Default collection UUID, used when a path omits the authority.
84
+ credentials:
85
+ A :class:`~globusfs.credentials.GlobusCredentials`. Defaults to
86
+ anonymous, which works for public collections and fails with a
87
+ clean 401 elsewhere.
88
+ https_url:
89
+ Override the collection's base URL instead of resolving it via
90
+ the Transfer API. Useful offline, in tests, and when the caller
91
+ already knows it.
92
+ retries:
93
+ Attempts before giving up on a transient backend fault.
94
+ """
95
+
96
+ protocol = "globus"
97
+ sep = "/"
98
+
99
+ def __init__(
100
+ self,
101
+ collection_id: str | None = None,
102
+ credentials: GlobusCredentials | None = None,
103
+ https_url: str | None = None,
104
+ retries: int = DEFAULT_RETRIES,
105
+ backoff: float = DEFAULT_BACKOFF,
106
+ metadata: Any = None,
107
+ **kwargs: Any,
108
+ ) -> None:
109
+ self.collection_id = collection_id
110
+ self.credentials = credentials or AnonymousCredentials()
111
+ self._https_url = https_url.rstrip("/") if https_url else None
112
+ self.retries = retries
113
+ self.backoff = backoff
114
+ # TransferMetadata (or a stand-in). Optional: the byte path works
115
+ # without it, so reading public data needs no globus-sdk.
116
+ self.metadata = metadata
117
+ self._base_cache: dict[str, str] = {}
118
+ super().__init__(**kwargs)
119
+
120
+ @property
121
+ def fsid(self) -> str:
122
+ return "globus"
123
+
124
+ # ---------------------------------------------------------------- paths
125
+
126
+ @classmethod
127
+ def _strip_protocol(cls, path: str) -> str:
128
+ """``globus://uuid/a/b`` -> ``uuid/a/b``; leave bare paths alone."""
129
+ for prefix in ("globus://", "globus:/"):
130
+ if path.startswith(prefix):
131
+ path = path[len(prefix) :]
132
+ break
133
+ return path.lstrip("/").rstrip("/") or ""
134
+
135
+ def split_path(self, path: str) -> tuple[str, str]:
136
+ """Split a stripped path into ``(collection_id, remainder)``."""
137
+ stripped = self._strip_protocol(path)
138
+ if not stripped:
139
+ if not self.collection_id:
140
+ raise ValueError(
141
+ "No collection: use globus://<collection-uuid>/path or pass "
142
+ "collection_id= to GlobusFileSystem."
143
+ )
144
+ return self.collection_id, ""
145
+
146
+ head, _, tail = stripped.partition("/")
147
+ # A leading segment is the collection only when no default is set,
148
+ # or when it actually differs from the default. This lets both
149
+ # "uuid/a/b" and "a/b" work against a configured default.
150
+ if self.collection_id and head != self.collection_id:
151
+ return self.collection_id, stripped
152
+ return head, tail
153
+
154
+ def _url(self, path: str) -> str:
155
+ """Full HTTPS URL for a globus path.
156
+
157
+ Idempotent: an already-resolved ``https://`` URL passes through
158
+ unchanged. fsspec re-enters with whatever it was handed (``_open``
159
+ resolves, then the parent calls ``info`` with the result), so
160
+ resolving twice must not double the prefix.
161
+ """
162
+ if path.startswith(("http://", "https://")):
163
+ return path
164
+ collection, rel = self.split_path(path)
165
+ return (
166
+ f"{self._resolve_base(collection)}/{rel}"
167
+ if rel
168
+ else (self._resolve_base(collection) + "/")
169
+ )
170
+
171
+ def _resolve_base(self, collection_id: str) -> str:
172
+ """Map a collection UUID to its ``https_url``, caching the result."""
173
+ if self._https_url:
174
+ return self._https_url
175
+ if collection_id in self._base_cache:
176
+ return self._base_cache[collection_id]
177
+ if self.metadata is None:
178
+ raise ValueError(
179
+ f"Cannot resolve collection {collection_id} to a URL: pass "
180
+ f"https_url= if you know it, or metadata= (a TransferMetadata) "
181
+ f"to look it up."
182
+ )
183
+ base = self.metadata.https_url(collection_id)
184
+ self._base_cache[collection_id] = base
185
+ return base
186
+
187
+ # ------------------------------------------------------------ requests
188
+
189
+ def _auth_headers(self, path: str) -> dict[str, str]:
190
+ collection, _ = self.split_path(path)
191
+ token = self.credentials.token_for(collection)
192
+ return {"Authorization": f"Bearer {token}"} if token else {}
193
+
194
+ async def _check(self, response, url: str) -> None:
195
+ """Classify a response, distinguishing a backend fault from a miss.
196
+
197
+ This is async because the distinction lives in the response body
198
+ and aiohttp bodies must be awaited -- which is exactly why the
199
+ inherited sync ``_raise_not_found_for_status`` hook cannot do the
200
+ job on its own.
201
+ """
202
+ if response.status == 404:
203
+ body = await response.text(errors="ignore")
204
+ if is_transient_body(body):
205
+ raise TransientBackendError(
206
+ f"{url}: GCS backend fault reported as HTTP 404. This is a "
207
+ f"transient failure of the collection, not a missing file. "
208
+ f"Body: {body[:200]!r}"
209
+ )
210
+ raise FileNotFoundError(url)
211
+ response.raise_for_status()
212
+
213
+ async def _retry(self, attempt_factory, what: str):
214
+ """Run ``attempt_factory()``, retrying transient backend faults.
215
+
216
+ Each attempt re-issues the request, because the failing backend is
217
+ selected at connect time and is sticky for that connection.
218
+ """
219
+ last: Exception | None = None
220
+ for attempt in range(1, self.retries + 1):
221
+ try:
222
+ return await attempt_factory()
223
+ except TransientBackendError as exc:
224
+ last = exc
225
+ if attempt < self.retries:
226
+ logger.debug(
227
+ "%s: transient backend fault (attempt %d/%d), retrying",
228
+ what,
229
+ attempt,
230
+ self.retries,
231
+ )
232
+ await asyncio.sleep(self.backoff * attempt)
233
+ raise TransientBackendError(
234
+ f"{what}: {self.retries} attempts all hit GCS backend faults. The "
235
+ f"collection is degraded; this is not a missing file."
236
+ ) from last
237
+
238
+ async def _cat_file(self, path, start=None, end=None, **kwargs):
239
+ """Read bytes, retrying past transient backend faults.
240
+
241
+ Overridden rather than inherited so that a 404 gets classified
242
+ before fsspec turns it into ``FileNotFoundError``.
243
+ """
244
+ url = self._url(path)
245
+
246
+ async def attempt():
247
+ headers = dict(kwargs.pop("headers", {}))
248
+ headers.update(self._auth_headers(path))
249
+ if start is not None or end is not None:
250
+ if start == end:
251
+ return b""
252
+ headers["Range"] = await self._process_limits(url, start, end)
253
+ session = await self.set_session()
254
+ async with session.get(
255
+ self.encode_url(url), headers=headers, **self.kwargs
256
+ ) as r:
257
+ if r.status >= 400:
258
+ await self._check(r, url)
259
+ return await r.read()
260
+
261
+ return await self._retry(attempt, f"cat_file({path})")
262
+
263
+ # --------------------------------------------------------------- write
264
+
265
+ async def _pipe_file(self, path, value, mode="overwrite", **kwargs):
266
+ """Upload bytes with PUT.
267
+
268
+ GCS supports PUT on the same URL that serves GET, so writing is a
269
+ single request. Retried like reads: a backend fault must not be
270
+ mistaken for a permission or path problem.
271
+
272
+ Note there is no server-side atomicity. A PUT that fails partway
273
+ can leave a truncated object, so callers who need all-or-nothing
274
+ should write to a temporary name and rename via the Transfer API.
275
+ """
276
+ url = self._url(path)
277
+
278
+ async def attempt():
279
+ headers = dict(kwargs.pop("headers", {}))
280
+ headers.update(self._auth_headers(path))
281
+ session = await self.set_session()
282
+ async with session.put(
283
+ self.encode_url(url), data=value, headers=headers, **self.kwargs
284
+ ) as r:
285
+ if r.status >= 400:
286
+ await self._check(r, url)
287
+ return
288
+
289
+ return await self._retry(attempt, f"pipe_file({path})")
290
+
291
+ async def _rm_file(self, path, **kwargs):
292
+ """Delete one file with DELETE."""
293
+ url = self._url(path)
294
+
295
+ async def attempt():
296
+ headers = dict(kwargs.pop("headers", {}))
297
+ headers.update(self._auth_headers(path))
298
+ session = await self.set_session()
299
+ async with session.delete(
300
+ self.encode_url(url), headers=headers, **self.kwargs
301
+ ) as r:
302
+ if r.status >= 400:
303
+ await self._check(r, url)
304
+ return
305
+
306
+ return await self._retry(attempt, f"rm_file({path})")
307
+
308
+ # -------------------------------------------------------------- listing
309
+
310
+ # NOTE: both the async and sync forms are overridden on purpose.
311
+ # fsspec's mirror_sync_methods only wires sync `ls` -> `_ls` when the
312
+ # sync version is still AbstractFileSystem's default; HTTPFileSystem
313
+ # binds its own, so overriding `_ls` alone leaves the parent's HTML
314
+ # link-scraping in place and the override silently never runs.
315
+
316
+ _NEED_META = (
317
+ "{op}() needs the Transfer API: the Globus HTTPS interface has no "
318
+ "directory listings and its HEAD responses cannot be classified. "
319
+ "Construct with metadata=TransferMetadata(...) -- see the README."
320
+ )
321
+
322
+ def _metadata(self, op: str):
323
+ if self.metadata is None:
324
+ raise NotImplementedError(self._NEED_META.format(op=op))
325
+ return self.metadata
326
+
327
+ def ls(self, path, detail=True, **kwargs):
328
+ collection, rel = self.split_path(self._unresolve(path))
329
+ return self._metadata("ls").ls(collection, rel, detail=detail)
330
+
331
+ async def _ls(self, path, detail=True, **kwargs):
332
+ return self.ls(path, detail=detail, **kwargs)
333
+
334
+ def _unresolve(self, path: str) -> str:
335
+ """Inverse of :meth:`_url` for a base this filesystem knows.
336
+
337
+ ``_open`` hands the parent a resolved URL, and fsspec then calls
338
+ back into ``info``/``ls`` with it. Transfer speaks collection
339
+ paths, not URLs, so a URL coming back in has to be turned back
340
+ into ``uuid/rel`` before it is used as a path.
341
+ """
342
+ if not path.startswith(("http://", "https://")):
343
+ return path
344
+ bases = [self._https_url] if self._https_url else []
345
+ bases += list(self._base_cache.values())
346
+ for base in bases:
347
+ if base and path.startswith(base):
348
+ rel = path[len(base) :].lstrip("/")
349
+ collection = next(
350
+ (c for c, b in self._base_cache.items() if b == base),
351
+ self.collection_id,
352
+ )
353
+ return f"{collection}/{rel}" if collection else rel
354
+ return path
355
+
356
+ def info(self, path, **kwargs):
357
+ """Stat a path, preferring the Transfer API.
358
+
359
+ Falls back to a **ranged GET** when no Transfer client is
360
+ configured, so anonymous public reads keep working: ``open()``
361
+ needs a size, and a plain HEAD cannot supply one safely (a HEAD
362
+ 404 has no body, so it cannot be told apart from a backend
363
+ fault). A ranged GET returns a body and puts the total in
364
+ ``Content-Range``.
365
+
366
+ The fallback can only ever report a file. Directory detection
367
+ genuinely requires Transfer.
368
+ """
369
+ collection, rel = self.split_path(self._unresolve(path))
370
+ if self.metadata is not None:
371
+ return self.metadata.info(collection, rel)
372
+ return {
373
+ "name": self._strip_protocol(path),
374
+ "size": self._size_via_range(path),
375
+ "type": "file",
376
+ }
377
+
378
+ def _size_via_range(self, path) -> int | None:
379
+ """Total size via an open-ended ranged GET.
380
+
381
+ Uses ``Range: bytes=0-`` rather than ``bytes=0-0``: GCS reports
382
+ the total as ``*`` in Content-Range (``bytes 0-0/*``), so a
383
+ one-byte probe cannot see the size. The open-ended form answers
384
+ ``bytes 0-<last>/*`` with a matching Content-Length, which does.
385
+ The body is never read, so this costs headers, not bytes.
386
+ """
387
+ url = self._url(path)
388
+
389
+ async def attempt():
390
+ headers = {"Range": "bytes=0-"}
391
+ headers.update(self._auth_headers(path))
392
+ session = await self.set_session()
393
+ async with session.get(
394
+ self.encode_url(url), headers=headers, **self.kwargs
395
+ ) as r:
396
+ if r.status >= 400:
397
+ await self._check(r, url)
398
+ start, _, last = (
399
+ r.headers.get("Content-Range", "")
400
+ .removeprefix("bytes ")
401
+ .partition("/")[0]
402
+ .partition("-")
403
+ )
404
+ if start.isdigit() and last.isdigit():
405
+ return int(last) - int(start) + 1
406
+ cl = r.headers.get("Content-Length")
407
+ return int(cl) if cl and cl.isdigit() else None
408
+
409
+ return sync(self.loop, self._retry, attempt, f"info({path})")
410
+
411
+ def _open(self, path, mode="rb", **kwargs):
412
+ """Open by resolved URL, with retry-aware block fetches.
413
+
414
+ Two fixes over the parent. It builds its file object straight
415
+ from the path it is handed (``uuid/rel``, not a URL), so the URL
416
+ is resolved first. And ``HTTPFile.async_fetch_range`` calls
417
+ ``raise_for_status()`` directly, bypassing the transient-404
418
+ classification -- so the returned file's fetcher is wrapped to
419
+ route block reads through :meth:`_cat_file`, which retries.
420
+ """
421
+ f = super()._open(self._url(path), mode=mode, **kwargs)
422
+ # The cache binds `self.fetcher = f._fetch_range` at construction,
423
+ # so rebinding the method afterwards is too late -- the cache must
424
+ # be repointed as well.
425
+ f._fetch_range = _RetryingFetcher(self, self._url(path))
426
+ if getattr(f, "cache", None) is not None:
427
+ f.cache.fetcher = f._fetch_range
428
+ return f
429
+
430
+ async def _info(self, path, **kwargs):
431
+ return self.info(path, **kwargs)
432
+
433
+ # HTTPFileSystem overrides _glob/_exists with logic built on scraping
434
+ # links out of HTML index pages -- meaningless for a Globus
435
+ # collection. Delegating to AbstractFileSystem puts them back on top
436
+ # of ls()/info(), which are Transfer-backed and correct.
437
+
438
+ def glob(self, path, maxdepth=None, **kwargs):
439
+ return AbstractFileSystem.glob(self, path, maxdepth=maxdepth, **kwargs)
440
+
441
+ async def _glob(self, path, maxdepth=None, **kwargs):
442
+ return self.glob(path, maxdepth=maxdepth, **kwargs)
443
+
444
+ def find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
445
+ return AbstractFileSystem.find(
446
+ self, path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
447
+ )
448
+
449
+ async def _find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
450
+ return self.find(
451
+ path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
452
+ )
453
+
454
+ def exists(self, path, **kwargs):
455
+ """True if the path exists.
456
+
457
+ Built on info() rather than the parent's HTTP probe so that a
458
+ transient backend fault propagates instead of being flattened
459
+ into "does not exist".
460
+ """
461
+ try:
462
+ self.info(path, **kwargs)
463
+ except FileNotFoundError:
464
+ return False
465
+ return True
466
+
467
+ async def _exists(self, path, **kwargs):
468
+ return self.exists(path, **kwargs)
469
+
470
+ def isdir(self, path):
471
+ try:
472
+ return self.info(path)["type"] == "directory"
473
+ except FileNotFoundError:
474
+ return False
475
+
476
+ async def _isdir(self, path):
477
+ return self.isdir(path)
478
+
479
+ def isfile(self, path):
480
+ try:
481
+ return self.info(path)["type"] == "file"
482
+ except FileNotFoundError:
483
+ return False
484
+
485
+ async def _isfile(self, path, **kwargs):
486
+ return self.isfile(path)
@@ -0,0 +1,140 @@
1
+ """Credential providers for Globus collections.
2
+
3
+ Kept separate from the filesystem because token acquisition is the part
4
+ that varies: a public collection needs nothing, a portal has a token
5
+ already, and an interactive user needs a browser login. The filesystem
6
+ only ever asks for a bearer string.
7
+
8
+ Two constraints drive the design here, both from the globus-sdk docs:
9
+
10
+ * ``GlobusApp`` is explicitly **not thread safe**, but fsspec shares one
11
+ filesystem instance across threads. Anything wrapping an app has to
12
+ serialize access.
13
+ * fsspec pickles filesystems to worker processes
14
+ (``AbstractFileSystem.__reduce__`` re-instantiates from the constructor
15
+ args). A live bearer token in those args would be written into every
16
+ worker payload, so providers serialize the *recipe* for getting a
17
+ token, never the token itself.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ from collections.abc import Callable
24
+ from typing import Protocol, runtime_checkable
25
+
26
+
27
+ @runtime_checkable
28
+ class GlobusCredentials(Protocol):
29
+ """Supplies bearer tokens for Globus collections."""
30
+
31
+ def token_for(self, collection_id: str) -> str | None:
32
+ """Return a bearer token for ``collection_id``, or None for anonymous.
33
+
34
+ Must be safe to call from multiple threads.
35
+ """
36
+ ...
37
+
38
+
39
+ class AnonymousCredentials:
40
+ """No credentials; for collections that allow public read.
41
+
42
+ The default. Real collections will 401, which is the correct and
43
+ legible failure -- better than pulling in an auth dependency that
44
+ most reads of public data would not need.
45
+ """
46
+
47
+ def token_for(self, collection_id: str) -> str | None:
48
+ return None
49
+
50
+ def __repr__(self) -> str:
51
+ return "AnonymousCredentials()"
52
+
53
+
54
+ class StaticToken:
55
+ """A token the caller already has.
56
+
57
+ Intended for portals and services that run their own OAuth flow, and
58
+ for tests. Note the token lives in memory for the life of the object;
59
+ ``__reduce__`` deliberately refuses to pickle it rather than silently
60
+ copying a secret into worker processes.
61
+ """
62
+
63
+ def __init__(self, token: str) -> None:
64
+ self._token = token
65
+
66
+ def token_for(self, collection_id: str) -> str | None:
67
+ return self._token
68
+
69
+ def __reduce__(self):
70
+ raise TypeError(
71
+ "StaticToken refuses to pickle: this would copy a bearer token "
72
+ "into every worker process and into any serialized fsspec state. "
73
+ "Use CallableToken with a function that reads the token from the "
74
+ "environment or a file, so workers re-read it instead."
75
+ )
76
+
77
+ def __repr__(self) -> str:
78
+ # Never render the token.
79
+ return "StaticToken(token=<redacted>)"
80
+
81
+
82
+ class CallableToken:
83
+ """Fetch a token on demand from a user-supplied callable.
84
+
85
+ The pickle-safe way to carry credentials into dataloader workers: the
86
+ *function* crosses the process boundary, the secret does not. Point it
87
+ at an env var or a file on shared storage.
88
+
89
+ The callable may be invoked concurrently, so it must be thread-safe.
90
+ """
91
+
92
+ def __init__(self, fn: Callable[[str], str | None]) -> None:
93
+ self._fn = fn
94
+
95
+ def token_for(self, collection_id: str) -> str | None:
96
+ return self._fn(collection_id)
97
+
98
+ def __repr__(self) -> str:
99
+ return f"CallableToken(fn={getattr(self._fn, '__name__', '<callable>')})"
100
+
101
+
102
+ class AppCredentials:
103
+ """Wrap a globus_sdk ``UserApp``/``ClientApp``.
104
+
105
+ Every call is serialized through a lock because GlobusApp is not
106
+ thread safe and fsspec will share this across threads.
107
+
108
+ Two things to get right when constructing the app:
109
+
110
+ * Set ``request_refresh_tokens=True``. It defaults to False, and
111
+ without it a long training run dies when the access token expires
112
+ mid-epoch.
113
+ * Point token storage at a path on shared storage. A UserApp cannot
114
+ run a browser login inside a dataloader worker, so workers must be
115
+ able to *load* tokens rather than acquire them.
116
+ """
117
+
118
+ def __init__(self, app, scope_suffix: str = "https") -> None:
119
+ self._app = app
120
+ self._scope_suffix = scope_suffix
121
+ self._lock = threading.Lock()
122
+
123
+ def token_for(self, collection_id: str) -> str | None:
124
+ """Current access token for a collection, refreshing if needed.
125
+
126
+ Goes through ``GlobusApp.get_authorizer()`` rather than reading
127
+ stored token data directly: the authorizer owns refresh, so an
128
+ expired access token is renewed here instead of failing a read
129
+ partway through a long job.
130
+ """
131
+ with self._lock:
132
+ authorizer = self._app.get_authorizer(collection_id)
133
+ # RefreshTokenAuthorizer renews on demand; others just hold one.
134
+ ensure = getattr(authorizer, "ensure_valid_token", None)
135
+ if ensure is not None:
136
+ ensure()
137
+ return getattr(authorizer, "access_token", None)
138
+
139
+ def __repr__(self) -> str:
140
+ return f"AppCredentials(scope_suffix={self._scope_suffix!r})"
globusfs/errors.py ADDED
@@ -0,0 +1,64 @@
1
+ """Exceptions, and the transient-vs-real 404 distinction.
2
+
3
+ Globus Connect Server load-balances across GridFTP backends. When a
4
+ backend is unhealthy it returns::
5
+
6
+ Mapping collection to specified ID failed.
7
+ GlobusError: v=1 c=ENDPOINT_ERROR
8
+ GCS Manager Internal Error
9
+
10
+ with **HTTP status 404** -- byte-identical in status to a genuinely
11
+ missing file. Measured against a healthy public collection, failure
12
+ rates swung from 0/20 to 20/20 within minutes, hitting files,
13
+ directories, and the collection root alike.
14
+
15
+ This is the single most dangerous behavior of the platform for a
16
+ filesystem client: fsspec maps 404 to ``FileNotFoundError``, so without
17
+ this distinction a transient backend fault silently becomes "your data
18
+ does not exist" -- intermittently, and only under load.
19
+
20
+ The only signal is the response body.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ # Markers seen in GCS backend-failure bodies. ENDPOINT_ERROR is what the
26
+ # GCS Manager emits; INTERNAL_ERROR / GridFTP-Errno 108 is the older
27
+ # GridFTP-level form.
28
+ _TRANSIENT_MARKERS = (
29
+ "ENDPOINT_ERROR",
30
+ "INTERNAL_ERROR",
31
+ "GCS Manager Internal Error",
32
+ )
33
+
34
+ # GridFTP-Errno 2 is PATH_NOT_FOUND: a real, permanent miss.
35
+ _PERMANENT_MARKERS = ("GridFTP-Errno: 2",)
36
+
37
+
38
+ class GlobusFSError(Exception):
39
+ """Base for globusfs errors."""
40
+
41
+
42
+ class TransientBackendError(GlobusFSError):
43
+ """A GCS backend failed a request that should have succeeded.
44
+
45
+ Raised only after retries are exhausted. Deliberately *not* a
46
+ subclass of ``FileNotFoundError``: the whole point is that callers
47
+ must not confuse it with absence.
48
+ """
49
+
50
+
51
+ def is_transient_body(body: str | None) -> bool:
52
+ """True if a >=400 response body indicates backend flakiness.
53
+
54
+ A permanent marker wins if both appear, so a real miss is never
55
+ retried into a timeout.
56
+ """
57
+ if not body:
58
+ # No body is not evidence of a transient fault. Treating unknown
59
+ # as permanent keeps a real 404 fast; a genuine transient will
60
+ # normally carry the marker.
61
+ return False
62
+ if any(m in body for m in _PERMANENT_MARKERS):
63
+ return False
64
+ return any(m in body for m in _TRANSIENT_MARKERS)
globusfs/login.py ADDED
@@ -0,0 +1,211 @@
1
+ """Interactive login helper.
2
+
3
+ Authenticating to Globus requires a browser and a human identity, so this
4
+ cannot be automated. What it can do is make the flow one command and put
5
+ the tokens somewhere workers can read them.
6
+
7
+ Two settings here are load-bearing for HPC use:
8
+
9
+ * ``refresh_tokens=True``. The SDK default is False, and without it an
10
+ access token expires (typically hours) and a long training run dies
11
+ mid-epoch.
12
+ * Tokens land in a file, by default under ``~/.globusfs``. Dataloader
13
+ workers cannot run a browser flow, so they must *load* tokens rather
14
+ than acquire them. On a cluster, point this at shared storage.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+ # Globus's public tutorial native client, intended for exactly this.
23
+ DEFAULT_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2"
24
+ TRANSFER_SCOPE = "urn:globus:auth:scope:transfer.api.globus.org:all"
25
+
26
+ DEFAULT_TOKEN_PATH = Path(
27
+ os.environ.get("GLOBUSFS_TOKENS", Path.home() / ".globusfs" / "tokens.json")
28
+ )
29
+
30
+
31
+ def needs_data_access(collection_id: str, client=None) -> bool:
32
+ """True if a collection requires the ``data_access`` scope.
33
+
34
+ Only mapped, non-High-Assurance collections do. Guest collections
35
+ manage access through ACLs and High Assurance ones forbid it, so
36
+ requesting it where it does not apply costs the user a consent
37
+ prompt for nothing.
38
+
39
+ Needs an authenticated ``TransferClient``: the Transfer API rejects
40
+ unauthenticated metadata lookups (400, no credentials). Without one,
41
+ or if the lookup fails, errs toward True -- a needless prompt is a
42
+ smaller failure than omitting a scope that is genuinely required.
43
+ """
44
+ if client is None:
45
+ return True
46
+ try:
47
+ doc = client.get_endpoint(collection_id)
48
+ except Exception: # noqa: BLE001 - detection must never block login
49
+ return True
50
+ if doc.get("high_assurance"):
51
+ return False
52
+ return "guest" not in (doc.get("entity_type") or "").lower()
53
+
54
+
55
+ def _require_sdk():
56
+ """Import globus_sdk, or explain how to get it.
57
+
58
+ Deliberately narrow: it reports a *missing package* only when the
59
+ package is genuinely missing. An earlier version wrapped a wider
60
+ block and told users to install a dependency they already had,
61
+ hiding the real error (a renamed submodule).
62
+ """
63
+ try:
64
+ import globus_sdk
65
+ except ImportError as exc: # pragma: no cover - trivial
66
+ raise ImportError(
67
+ "Logging in to Globus needs globus-sdk. "
68
+ "Install with: pip install 'globusfs[auth]'"
69
+ ) from exc
70
+ return globus_sdk
71
+
72
+
73
+ def _json_token_storage(globus_sdk):
74
+ """Locate ``JSONTokenStorage`` across globus-sdk versions.
75
+
76
+ Renamed from ``globus_sdk.tokenstorage`` (v3) to
77
+ ``globus_sdk.token_storage`` (v4). Both are supported, so the
78
+ dependency floor does not have to jump a major version.
79
+ """
80
+ try:
81
+ from globus_sdk.token_storage import JSONTokenStorage # v4+
82
+ except ImportError:
83
+ try:
84
+ from globus_sdk.tokenstorage import JSONTokenStorage # v3
85
+ except ImportError as exc:
86
+ raise ImportError(
87
+ f"Cannot locate JSONTokenStorage in globus-sdk "
88
+ f"{getattr(globus_sdk, '__version__', '?')}: tried "
89
+ f"globus_sdk.token_storage (v4) and globus_sdk.tokenstorage "
90
+ f"(v3). Please report this with your globus-sdk version."
91
+ ) from exc
92
+ return JSONTokenStorage
93
+
94
+
95
+ def collection_scopes(collection_id: str, data_access: bool = True) -> list[str]:
96
+ """Scopes needed to read one collection over HTTPS.
97
+
98
+ ``data_access`` applies only to **mapped**, non-High-Assurance
99
+ collections. Guest and High Assurance collections do not use it, and
100
+ requesting it there forces an avoidable extra consent prompt -- so
101
+ pass ``data_access=False`` for those. :func:`login` detects the
102
+ collection type automatically when it can.
103
+ """
104
+ base = f"https://auth.globus.org/scopes/{collection_id}"
105
+ scopes = [f"{base}/https"]
106
+ if data_access:
107
+ scopes.append(f"{base}/data_access")
108
+ return scopes
109
+
110
+
111
+ def login(
112
+ collection_id: str | None = None,
113
+ client_id: str = DEFAULT_CLIENT_ID,
114
+ token_path: Path | str = DEFAULT_TOKEN_PATH,
115
+ data_access: bool | None = None,
116
+ run_flow: bool = True,
117
+ ):
118
+ """Log in to Globus and persist tokens.
119
+
120
+ Runs the login flow if the required scopes are not already held, then
121
+ returns a ``globus_sdk.UserApp`` usable for both Transfer and HTTPS.
122
+ Calling it again once tokens exist is cheap and does not re-prompt.
123
+
124
+ Parameters
125
+ ----------
126
+ data_access:
127
+ Whether to request the ``data_access`` scope. ``None`` (default)
128
+ detects it: mapped non-High-Assurance collections need it, guest
129
+ and High Assurance collections do not, and asking for it where it
130
+ does not apply costs an extra consent prompt. Detection is a
131
+ single unauthenticated metadata lookup; pass True/False to skip
132
+ it.
133
+ run_flow:
134
+ Set False to build the app without logging in (tests, or when the
135
+ caller drives the flow itself). The returned app will still
136
+ trigger a login on first use if it lacks tokens.
137
+ """
138
+ globus_sdk = _require_sdk()
139
+ JSONTokenStorage = _json_token_storage(globus_sdk)
140
+
141
+ token_path = Path(token_path)
142
+ token_path.parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ if data_access is None and collection_id:
145
+ # Detection needs an authenticated client, so log in for Transfer
146
+ # alone first; that consent is needed either way and is not
147
+ # wasted. Then re-enter with the answer.
148
+ probe = login(None, client_id, token_path, run_flow=run_flow)
149
+ try:
150
+ data_access = needs_data_access(
151
+ collection_id, globus_sdk.TransferClient(app=probe)
152
+ )
153
+ except Exception: # noqa: BLE001 - fall back to the common case
154
+ data_access = True
155
+ elif data_access is None:
156
+ data_access = False
157
+
158
+ # Transfer needs data_access as a *dependent* scope, written
159
+ # `transfer:all[*<collection>/data_access]` -- not as a standalone
160
+ # requirement. Requesting it flat yields tokens that look complete but
161
+ # fail every operation_ls with 403 ConsentRequired.
162
+ transfer_scope = globus_sdk.Scope(TRANSFER_SCOPE)
163
+ if collection_id and data_access:
164
+ transfer_scope = transfer_scope.with_dependency(
165
+ globus_sdk.Scope(
166
+ f"https://auth.globus.org/scopes/{collection_id}/data_access",
167
+ optional=True,
168
+ )
169
+ )
170
+ requirements = {"transfer.api.globus.org": [transfer_scope]}
171
+ if collection_id:
172
+ # The HTTPS scope stays a direct requirement of the collection
173
+ # itself; only Transfer needs the dependent form.
174
+ requirements[collection_id] = collection_scopes(collection_id, data_access)
175
+
176
+ config = globus_sdk.GlobusAppConfig(
177
+ token_storage=JSONTokenStorage(str(token_path)),
178
+ # Not the SDK default; without it long runs die on token expiry.
179
+ request_refresh_tokens=True,
180
+ )
181
+ app = globus_sdk.UserApp(
182
+ "globusfs", client_id=client_id, config=config, scope_requirements=requirements
183
+ )
184
+ # Constructing the app does NOT authenticate. Without this the call
185
+ # returns cleanly, writes no tokens, and the failure only shows up
186
+ # later as a surprise login prompt from deep inside a read.
187
+ if run_flow and app.login_required():
188
+ app.login()
189
+ return app
190
+
191
+
192
+ def filesystem(collection_id: str, app=None, **kwargs):
193
+ """Build a ready-to-use :class:`GlobusFileSystem` for one collection.
194
+
195
+ Convenience wrapper: logs in if needed, resolves the collection's
196
+ HTTPS URL, and wires up Transfer-backed listing.
197
+ """
198
+ import globus_sdk
199
+
200
+ from .core import GlobusFileSystem
201
+ from .credentials import AppCredentials
202
+ from .transfer import TransferMetadata
203
+
204
+ app = app or login(collection_id)
205
+ client = globus_sdk.TransferClient(app=app)
206
+ return GlobusFileSystem(
207
+ collection_id=collection_id,
208
+ credentials=AppCredentials(app),
209
+ metadata=TransferMetadata(client),
210
+ **kwargs,
211
+ )
globusfs/transfer.py ADDED
@@ -0,0 +1,143 @@
1
+ """Transfer-API-backed metadata: the half HTTPS cannot provide.
2
+
3
+ The Globus HTTPS interface serves bytes but has no directory listings,
4
+ and its ``HEAD`` responses are unclassifiable (a 404 from HEAD carries no
5
+ body, and the body is the only thing separating a backend fault from a
6
+ real miss). So ``ls``/``info`` come from the Transfer API instead, which
7
+ returns real name/size/type/last_modified via ``operation_ls`` and
8
+ ``operation_stat``.
9
+
10
+ The Transfer API also supplies true file sizes, which neutralizes a third
11
+ quirk: GCS answers suffix ranges (``bytes=-8``) with 416, the usual way
12
+ parquet readers seek to a footer. With the size known, readers address
13
+ the footer absolutely.
14
+
15
+ Kept in its own module so the byte path stays usable with no globus-sdk
16
+ installed -- anonymous public collections need nothing else.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from .errors import TransientBackendError, is_transient_body
24
+
25
+
26
+ def _require_sdk():
27
+ try:
28
+ import globus_sdk
29
+ except ImportError as exc: # pragma: no cover - trivial
30
+ raise ImportError(
31
+ "Listing Globus collections needs the Transfer API. "
32
+ "Install with: pip install 'globusfs[auth]'"
33
+ ) from exc
34
+ return globus_sdk
35
+
36
+
37
+ def _stat_to_info(entry: dict[str, Any], path: str) -> dict[str, Any]:
38
+ """Convert a Globus ``file`` document to an fsspec info dict.
39
+
40
+ Globus types are ``dir``/``file`` plus the unix special types
41
+ (``chr``, ``blk``, ``pipe``, ``other``). Anything that is not a
42
+ directory is reported to fsspec as a file, since fsspec has no richer
43
+ vocabulary and callers only ever read bytes.
44
+ """
45
+ gtype = entry.get("type")
46
+ return {
47
+ "name": path,
48
+ "size": entry.get("size"),
49
+ "type": "directory" if gtype == "dir" else "file",
50
+ "globus_type": gtype,
51
+ "last_modified": entry.get("last_modified"),
52
+ }
53
+
54
+
55
+ class TransferMetadata:
56
+ """Metadata lookups for one collection, via a ``TransferClient``.
57
+
58
+ Parameters
59
+ ----------
60
+ client:
61
+ A ``globus_sdk.TransferClient``. Injected rather than constructed
62
+ here so the caller owns login, and so tests can pass a fake.
63
+ """
64
+
65
+ def __init__(self, client: Any) -> None:
66
+ self._client = client
67
+
68
+ @classmethod
69
+ def from_authorizer(cls, authorizer: Any) -> TransferMetadata:
70
+ globus_sdk = _require_sdk()
71
+ return cls(globus_sdk.TransferClient(authorizer=authorizer))
72
+
73
+ def https_url(self, collection_id: str) -> str:
74
+ """Resolve a collection UUID to the base URL that serves bytes."""
75
+ ep = self._client.get_endpoint(collection_id)
76
+ url = ep.get("https_server")
77
+ if not url:
78
+ raise ValueError(
79
+ f"Collection {collection_id} reports no https_server, so it "
80
+ f"cannot serve files over HTTPS. Only Globus Connect Server "
81
+ f"collections with HTTPS enabled are readable this way."
82
+ )
83
+ return str(url).rstrip("/")
84
+
85
+ @staticmethod
86
+ def _abs(path: str) -> str:
87
+ """Anchor a path at the collection root.
88
+
89
+ Globus resolves a *relative* path against ``/~/`` (the mapped
90
+ user's home), so ``home`` becomes ``/~/home`` and 404s while
91
+ ``/home`` lists fine. Paths reaching here have already been
92
+ stripped of their protocol and leading slash by fsspec, so
93
+ absoluteness has to be restored or every nested listing fails.
94
+ """
95
+ return "/" + path.lstrip("/") if path else "/"
96
+
97
+ def info(self, collection_id: str, path: str) -> dict[str, Any]:
98
+ """Stat one path. Raises FileNotFoundError if genuinely absent."""
99
+ globus_sdk = _require_sdk()
100
+ try:
101
+ res = self._client.operation_stat(collection_id, path=self._abs(path))
102
+ except globus_sdk.TransferAPIError as exc:
103
+ raise self._translate(exc, collection_id, path) from exc
104
+ # GlobusHTTPResponse is not dict()-able (dict() iterates it as a
105
+ # sequence and fails); .data is the underlying document.
106
+ return _stat_to_info(res.data, path)
107
+
108
+ def ls(
109
+ self, collection_id: str, path: str, detail: bool = True
110
+ ) -> list[dict[str, Any]] | list[str]:
111
+ globus_sdk = _require_sdk()
112
+ try:
113
+ res = self._client.operation_ls(collection_id, path=self._abs(path))
114
+ except globus_sdk.TransferAPIError as exc:
115
+ raise self._translate(exc, collection_id, path) from exc
116
+
117
+ base = path.rstrip("/")
118
+ out = [
119
+ _stat_to_info(e, f"{base}/{e['name']}" if base else e["name"]) for e in res
120
+ ]
121
+ return out if detail else [e["name"] for e in out]
122
+
123
+ @staticmethod
124
+ def _translate(exc: Any, collection_id: str, path: str) -> Exception:
125
+ """Map a TransferAPIError to the right Python exception.
126
+
127
+ The same transient-vs-permanent care as the HTTPS path: a backend
128
+ fault must never surface as ``FileNotFoundError``, or callers will
129
+ conclude healthy data is missing.
130
+ """
131
+ code = getattr(exc, "code", "") or ""
132
+ message = getattr(exc, "message", "") or str(exc)
133
+
134
+ if is_transient_body(f"{code} {message}"):
135
+ return TransientBackendError(
136
+ f"{collection_id}:{path}: Globus backend fault ({code}). "
137
+ f"Transient, not a missing file."
138
+ )
139
+ if "NotFound" in code or "ClientError.NotFound" == code:
140
+ return FileNotFoundError(f"{collection_id}:{path}")
141
+ if getattr(exc, "http_status", None) == 404:
142
+ return FileNotFoundError(f"{collection_id}:{path}")
143
+ return exc
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.5
2
+ Name: globusfs
3
+ Version: 0.1.0
4
+ Summary: fsspec filesystem for Globus collections
5
+ Project-URL: Homepage, https://github.com/saforem2/globusfs
6
+ Project-URL: Source, https://github.com/saforem2/globusfs
7
+ Project-URL: Issues, https://github.com/saforem2/globusfs/issues
8
+ Author-email: Sam Foreman <saforem2@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Sam Foreman
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: data,filesystem,fsspec,globus,hpc
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Topic :: System :: Filesystems
36
+ Requires-Python: >=3.10
37
+ Requires-Dist: aiohttp
38
+ Requires-Dist: fsspec>=2023.1
39
+ Provides-Extra: auth
40
+ Requires-Dist: globus-sdk>=3.30; extra == 'auth'
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest; extra == 'dev'
43
+ Requires-Dist: pytest-asyncio; extra == 'dev'
44
+ Requires-Dist: requests; extra == 'dev'
45
+ Requires-Dist: ruff>=0.14; extra == 'dev'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # globusfs
49
+
50
+ An [fsspec](https://filesystem-spec.readthedocs.io/) filesystem for
51
+ [Globus](https://www.globus.org/) collections.
52
+
53
+ > **Status: working.** Verified against three live collections —
54
+ > including **ALCF Eagle** (`alcf#dtn_eagle`, 747 project directories),
55
+ > where `ls`, `glob`, `info`, `open()` with mid-file seek, and sparse
56
+ > ranged reads all work against production Lustre. Writes (`PUT`/`DELETE`)
57
+ > round-trip on Globus Tutorial Collection 1. Anonymous pyarrow column
58
+ > projection works on a public collection — one column of sixty, a few KB
59
+ > instead of 360 KB — while that collection was intermittently returning
60
+ > backend-fault 404s.
61
+
62
+ ```python
63
+ import globusfs
64
+
65
+ # Browser login once; tokens persist to ~/.globusfs/tokens.json
66
+ fs = globusfs.filesystem("<collection-uuid>")
67
+
68
+ fs.ls("/")
69
+ with fs.open("data/file.parquet", "rb") as f:
70
+ ...
71
+ ```
72
+
73
+ Public collections need no credentials — and no `globus-sdk`:
74
+
75
+ ```python
76
+ import fsspec, pyarrow.parquet as pq
77
+
78
+ fs = fsspec.filesystem(
79
+ "globus",
80
+ collection_id="isaac",
81
+ https_url="https://g-05a4b6.2d513.8443.data.globus.org",
82
+ )
83
+
84
+ with fs.open("isaac/ability/ALL_2007-01.parquet", "rb") as f:
85
+ # Reads only the bytes this column needs, over HTTP range requests.
86
+ table = pq.ParquetFile(f).read(columns=["author"])
87
+ ```
88
+
89
+ ## Why
90
+
91
+ Globus is how large scientific datasets actually move between facilities,
92
+ but there is no fsspec backend for it — so pyarrow, pandas, dask, and
93
+ grain can't read a Globus collection the way they read `s3://` or
94
+ `gs://`. This fills that gap with one backend that serves all of them.
95
+
96
+ ## How it works
97
+
98
+ Globus Connect Server exposes two services, and this needs both:
99
+
100
+ | Concern | Service | Why |
101
+ |---|---|---|
102
+ | Reading bytes | HTTPS collection endpoint | Serves full HTTP range semantics: 206, `Content-Range`, mid-file seeks, multipart |
103
+ | Listing / metadata | Transfer API | The HTTPS interface has no directory listings |
104
+
105
+ The read path subclasses `fsspec`'s `HTTPFileSystem`, which already
106
+ speaks exactly the range dialect GCS serves.
107
+
108
+ ### Server quirks this works around
109
+
110
+ Verified against a live collection:
111
+
112
+ - **Backend flakiness surfaces as a `404`.** GCS load-balances across
113
+ GridFTP backends; a failing one returns `ENDPOINT_ERROR` / `GCS Manager
114
+ Internal Error` rendered as HTTP 404 — byte-identical in status to a
115
+ genuinely missing file, and sticky for the life of a connection.
116
+ Observed failure rates on the public test collection swung from 0/20 to
117
+ 20/20 within minutes, hitting files, directories, and the collection
118
+ root alike. Retries need a *fresh* connection, and the only way to tell
119
+ a transient error from a real miss is to parse the body. **A client
120
+ that treats 404 as "absent" will report healthy data as missing.**
121
+ - **Suffix ranges (`bytes=-8`) return `416`**, which is how parquet
122
+ readers typically seek to the footer. Because `info()` knows the true
123
+ size, readers can use absolute offsets instead.
124
+ - **`HEAD` is unusable.** A HEAD 404 carries no body — and the body is
125
+ the *only* thing distinguishing a backend fault from a real miss. So
126
+ HEAD results are permanently ambiguous. Size and existence come from
127
+ the Transfer API, or from a ranged `GET` (which does return a body and
128
+ carries the total in `Content-Range`).
129
+ - **The HTTPS interface has no directory listings at all** — hence the
130
+ Transfer API for metadata.
131
+
132
+ ## Known ALCF collections
133
+
134
+ Resolved via `endpoint_search`; ALCF's docs list names, not UUIDs.
135
+
136
+ | Collection | UUID | Type |
137
+ |---|---|---|
138
+ | `alcf#dtn_eagle` | `05d2c76a-e867-4f67-aa57-76edeb0beda0` | mapped |
139
+ | `alcf#dtn_flare` | `f39a7a0f-5bfc-46ce-9615-ba9f8592814f` | mapped |
140
+ | `alcf#dtn_grand` | `3caddd4a-bb35-4c3d-9101-d9a0ad7f3a30` | mapped |
141
+ | Globus Tutorials on ALCF Eagle | `a6f165fa-aee2-4fe5-95f3-97429c28bf82` | guest, public |
142
+
143
+ Eagle's collection root is already `/eagle/projects`, so paths are
144
+ project-relative: `fs.ls("/datascience")`, not `/eagle/projects/datascience`.
145
+
146
+ Expect roughly **2 s per metadata or read operation** through the DTN —
147
+ fine for sparse reads and exploration, not for per-record access in a
148
+ training loop. See the note on training workloads below.
149
+
150
+ ## Credentials
151
+
152
+ Token acquisition is pluggable, because it varies more than anything
153
+ else: a public collection needs nothing, a portal already has a token,
154
+ an interactive user needs a browser.
155
+
156
+ | Provider | Use |
157
+ |---|---|
158
+ | `AnonymousCredentials` | Public collections (default) |
159
+ | `StaticToken` | A token you already hold |
160
+ | `CallableToken` | Fetch on demand — the pickle-safe option |
161
+ | `AppCredentials` | Wraps `globus_sdk` `UserApp`/`ClientApp` |
162
+
163
+ Two constraints worth knowing, both from the globus-sdk docs:
164
+
165
+ - **`GlobusApp` is not thread-safe**, but fsspec shares one filesystem
166
+ across threads. `AppCredentials` serializes every call through a lock.
167
+ - **fsspec pickles filesystems to worker processes.** A live token in the
168
+ constructor args would be copied into every worker payload, so
169
+ `StaticToken` refuses to pickle; use `CallableToken` reading from the
170
+ environment or shared storage so workers re-read rather than receive.
171
+
172
+ Also set `request_refresh_tokens=True` — it defaults to `False`, and
173
+ without it a long run dies when the access token expires mid-epoch.
174
+
175
+ ## A note on training workloads
176
+
177
+ This is built for *remote and sparse* reads: column projection,
178
+ exploration, data not yet staged. For distributed training, staging with
179
+ Globus Transfer to node-local scratch beats per-record HTTPS on every
180
+ axis — no per-record latency, no token expiry mid-epoch, and it works
181
+ with formats like ArrayRecord whose readers do their own seeking.
182
+
183
+ ## Tests
184
+
185
+ Characterization tests hit a real public collection and are marked
186
+ `network`:
187
+
188
+ ```bash
189
+ pytest # everything
190
+ pytest -m "not network" # offline only
191
+ ```
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,11 @@
1
+ globusfs/__init__.py,sha256=PmAnKEFosF5vDC4Dlnj26kkM2tJh58sB6eFIiVQPiCk,633
2
+ globusfs/core.py,sha256=rTK_9WNVez6yDmL0BJzxPLpRxvu5OfHaAcld19rjFec,19148
3
+ globusfs/credentials.py,sha256=HzpyvEiP2pzfoUwrWpsauyZmnkaoWCP2SeepY9BT2kI,5026
4
+ globusfs/errors.py,sha256=AJbjTPioc4KasMMVKErKrCkxkCz0vdadIorO6O8p1lU,2171
5
+ globusfs/login.py,sha256=gFCGvHEspPoM-3zvuguEiTWVTk_-kCKTAQFKlDu43so,8208
6
+ globusfs/transfer.py,sha256=dG2fU5wq_WFw0mv0hfB0AywIJoSKH9AyXSoDt_3XF1Y,5642
7
+ globusfs-0.1.0.dist-info/METADATA,sha256=SumkyMvKMrsBZ7S-Wr035QHMmpkpXaWgRE-BSx4RKpo,8037
8
+ globusfs-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ globusfs-0.1.0.dist-info/entry_points.txt,sha256=T56Qbm74xtAyb2hFOhxBmCoCeL38yrcr6h8TJamE654,50
10
+ globusfs-0.1.0.dist-info/licenses/LICENSE,sha256=XdTXLjEKBNTBqjyjVWs1MIQBryn4Fq5lPvzMMW9biKo,1068
11
+ globusfs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [fsspec.specs]
2
+ globus = globusfs:GlobusFileSystem
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Foreman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.