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 +29 -0
- globusfs/core.py +486 -0
- globusfs/credentials.py +140 -0
- globusfs/errors.py +64 -0
- globusfs/login.py +211 -0
- globusfs/transfer.py +143 -0
- globusfs-0.1.0.dist-info/METADATA +195 -0
- globusfs-0.1.0.dist-info/RECORD +11 -0
- globusfs-0.1.0.dist-info/WHEEL +4 -0
- globusfs-0.1.0.dist-info/entry_points.txt +2 -0
- globusfs-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
globusfs/credentials.py
ADDED
|
@@ -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,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.
|