Skip to content

Commit 0bb819c

Browse files
authored
pass glob prefix to driver fix: #1995 (#1996)
1 parent 49e6189 commit 0bb819c

2 files changed

Lines changed: 149 additions & 3 deletions

File tree

fsspec/asyn.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,22 @@ async def _run_coro(coro, i):
263263
break
264264

265265
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
266+
first_exc = None
266267
while done:
267-
result, k = await done.pop()
268-
results[k] = result
268+
task = done.pop()
269+
try:
270+
result, k = await task
271+
results[k] = result
272+
except Exception as exc:
273+
if first_exc is None:
274+
first_exc = exc
275+
276+
if first_exc is not None:
277+
for task in pending:
278+
task.cancel()
279+
if pending:
280+
await asyncio.gather(*pending, return_exceptions=True)
281+
raise first_exc
269282

270283
return results
271284

@@ -795,11 +808,18 @@ async def _glob(self, path, maxdepth=None, **kwargs):
795808
else:
796809
return {}
797810
elif "/" in path[:min_idx]:
811+
first_wildcard_idx = min_idx
798812
min_idx = path[:min_idx].rindex("/")
799-
root = path[: min_idx + 1]
813+
root = path[
814+
: min_idx + 1
815+
] # everything up to the last / before the first wildcard
816+
prefix = path[
817+
min_idx + 1 : first_wildcard_idx
818+
] # stem between last "/" and first wildcard
800819
depth = path[min_idx + 1 :].count("/") + 1
801820
else:
802821
root = ""
822+
prefix = path[:min_idx] # stem up to the first wildcard
803823
depth = path[min_idx + 1 :].count("/") + 1
804824

805825
if "**" in path:
@@ -810,6 +830,10 @@ async def _glob(self, path, maxdepth=None, **kwargs):
810830
else:
811831
depth = None
812832

833+
# Pass the filename stem as prefix= so backends that support it such as
834+
# gcsfs, s3fs and adlfs can filter server-side up to the first wildcard.
835+
if prefix:
836+
kwargs["prefix"] = prefix
813837
allpaths = await self._find(
814838
root, maxdepth=depth, withdirs=withdirs, detail=True, **kwargs
815839
)

fsspec/tests/test_async.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,125 @@ def test_rm_file_without_implementation():
244244
fs = fsspec.asyn.AsyncFileSystem()
245245
with pytest.raises(NotImplementedError):
246246
fs.rm_file("test/file.txt")
247+
248+
249+
# ---------------------------------------------------------------------------
250+
# Tests for the prefix= hint that _glob passes to _find
251+
# ---------------------------------------------------------------------------
252+
253+
_GLOB_PREFIX_FILES = [
254+
"data/2024/results.csv",
255+
"data/2024/report.txt",
256+
"data/2023/results.csv",
257+
"top_results.csv",
258+
"top_other.txt",
259+
"other/results.csv",
260+
]
261+
262+
263+
class _PrefixCapturingFS(fsspec.asyn.AsyncFileSystem):
264+
"""Minimal AsyncFileSystem that records every _find call's kwargs.
265+
266+
_find ignores the prefix hint and returns all files under *root* so that
267+
the client-side glob pattern-matching in _glob still works correctly.
268+
This simulates a "naive" backend that silently absorbs unknown kwargs.
269+
"""
270+
271+
protocol = "prefixmock"
272+
273+
def __init__(self, **kwargs):
274+
super().__init__(**kwargs)
275+
self.find_calls = []
276+
277+
async def _find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
278+
self.find_calls.append({"path": path, "kwargs": dict(kwargs)})
279+
root = (path.rstrip("/") + "/") if path else ""
280+
results = {
281+
f: {"name": f, "type": "file", "size": 0}
282+
for f in _GLOB_PREFIX_FILES
283+
if f.startswith(root)
284+
}
285+
return results if detail else list(results)
286+
287+
async def _info(self, path, **kwargs):
288+
for f in _GLOB_PREFIX_FILES:
289+
if f == path:
290+
return {"name": f, "type": "file", "size": 0}
291+
raise FileNotFoundError(path)
292+
293+
294+
@pytest.fixture
295+
def prefix_fs():
296+
return _PrefixCapturingFS(skip_instance_cache=True)
297+
298+
299+
@pytest.mark.parametrize(
300+
"pattern, expected_results, expected_prefix",
301+
[
302+
# root/stem* -> prefix="stem"
303+
(
304+
"data/2024/res*",
305+
["data/2024/results.csv"],
306+
"res",
307+
),
308+
# root/stem*.ext -> prefix="stem"
309+
(
310+
"data/2024/re*.txt",
311+
["data/2024/report.txt"],
312+
"re",
313+
),
314+
# stem* (no slash) -> prefix="stem"
315+
(
316+
"top_*",
317+
["top_other.txt", "top_results.csv"],
318+
"top_",
319+
),
320+
# ? wildcard: prefix is everything before the first ?
321+
(
322+
"top_r?sults.csv",
323+
["top_results.csv"],
324+
"top_r",
325+
),
326+
# [ wildcard: prefix is everything before the first [
327+
# re[rp]* translates to re[rp][^/]* so only report.txt matches (not results.csv)
328+
(
329+
"data/2024/re[rp]*",
330+
["data/2024/report.txt"],
331+
"re",
332+
),
333+
# root/* (wildcard immediately after /) -> empty prefix, NOT forwarded
334+
(
335+
"data/2024/*",
336+
["data/2024/report.txt", "data/2024/results.csv"],
337+
None,
338+
),
339+
# bare * (no prefix at all) -> NOT forwarded
340+
# * translates to [^/]+ so paths containing / are excluded
341+
(
342+
"*",
343+
["top_other.txt", "top_results.csv"],
344+
None,
345+
),
346+
],
347+
)
348+
def test_glob_prefix_hint(prefix_fs, pattern, expected_results, expected_prefix):
349+
"""_glob should extract the literal stem before the first wildcard and
350+
forward it as ``prefix=`` to ``_find``. When the stem is empty the kwarg
351+
must not be forwarded at all so that backends that reject unknown kwargs
352+
are not broken. The glob results must be correct regardless."""
353+
results = prefix_fs.glob(pattern)
354+
assert sorted(results) == sorted(expected_results)
355+
356+
assert len(prefix_fs.find_calls) == 1
357+
forwarded = prefix_fs.find_calls[0]["kwargs"]
358+
359+
if expected_prefix is None:
360+
assert "prefix" not in forwarded, (
361+
f"prefix= should not be forwarded for pattern {pattern!r}, "
362+
f"but got prefix={forwarded.get('prefix')!r}"
363+
)
364+
else:
365+
assert forwarded.get("prefix") == expected_prefix, (
366+
f"expected prefix={expected_prefix!r} for pattern {pattern!r}, "
367+
f"got {forwarded.get('prefix')!r}"
368+
)

0 commit comments

Comments
 (0)