Skip to content

Commit 178eb55

Browse files
feat(bindings/python): Add start_after support for list (#6054)
* add start-with * ruff format * fmt * try add test lint ruff check --select I --fix . rm test * recover scan * fmt * recover async scan * fix path in gen * clippy * async test * add decorate * ruff check --select I --fix tests/test_async_list.py * line too long * add parent folder * fix test * sync test * fix name
1 parent 9c1155c commit 178eb55

File tree

6 files changed

+165
-21
lines changed

6 files changed

+165
-21
lines changed

bindings/python/python/opendal/__base.pyi

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"""
1919
> DO NOT EDIT IT MANUALLY <
2020
21-
This file is generated by opendal/dev/generate/python.rs.
21+
This file is generated by opendal/dev/src/generate/python.rs.
2222
`opendal.__base` doesn't exists.
2323
"""
2424

@@ -485,6 +485,7 @@ class _Base:
485485
access_key_id: str = ...,
486486
secret_access_key: str = ...,
487487
bucket: str = ...,
488+
enable_versioning: _bool = ...,
488489
) -> None: ...
489490

490491
@overload
@@ -622,6 +623,7 @@ class _Base:
622623
checksum_algorithm: str = ...,
623624
disable_write_with_if_match: _bool = ...,
624625
enable_write_with_append: _bool = ...,
626+
disable_list_objects_v2: _bool = ...,
625627
) -> None: ...
626628

627629
@overload

bindings/python/python/opendal/__init__.pyi

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,18 @@ class Operator(_Base):
135135
Returns:
136136
True if the object exists, False otherwise.
137137
"""
138-
def list(self, path: PathBuf) -> Iterable[Entry]:
138+
def list(self, path: PathBuf, *, start_after: str | None = None) -> Iterable[Entry]:
139139
"""List the objects at the given path.
140140
141141
Args:
142142
path (str|Path): The path to the directory.
143+
start_after (str | None): The key to start listing from.
143144
144145
Returns:
145146
An iterable of entries representing the objects in the directory.
146147
"""
147148
def scan(self, path: PathBuf) -> Iterable[Entry]:
148-
"""Scan the objects at the given path.
149+
"""Scan the objects at the given path recursively.
149150
150151
Args:
151152
path (str|Path): The path to the directory.
@@ -282,19 +283,28 @@ class AsyncOperator(_Base):
282283
Returns:
283284
True if the object exists, False otherwise.
284285
"""
285-
async def list(self, path: PathBuf) -> AsyncIterable[Entry]:
286+
async def list(
287+
self, path: PathBuf, *, start_after: str | None = None
288+
) -> AsyncIterable[Entry]:
286289
"""List the objects at the given path.
287290
288291
Args:
289292
path (str|Path): The path to the directory.
293+
start_after (str | None): The key to start listing from.
290294
291295
Returns:
292296
An iterable of entries representing the objects in the directory.
293297
"""
294-
async def scan(self, path: PathBuf) -> AsyncIterable[Entry]: ...
295-
async def presign_stat(
296-
self, path: PathBuf, expire_second: int
297-
) -> PresignedRequest:
298+
async def scan(self, path: PathBuf) -> AsyncIterable[Entry]:
299+
"""Scan the objects at the given path recursively.
300+
301+
Args:
302+
path (str|Path): The path to the directory.
303+
304+
Returns:
305+
An iterable of entries representing the objects in the directory.
306+
"""
307+
async def presign_stat(self, path: PathBuf, expire_second: int) -> PresignedRequest:
298308
"""Generate a presigned URL for stat operation.
299309
300310
Args:
@@ -304,9 +314,7 @@ class AsyncOperator(_Base):
304314
Returns:
305315
A presigned request object.
306316
"""
307-
async def presign_read(
308-
self, path: PathBuf, expire_second: int
309-
) -> PresignedRequest:
317+
async def presign_read(self, path: PathBuf, expire_second: int) -> PresignedRequest:
310318
"""Generate a presigned URL for read operation.
311319
312320
Args:

bindings/python/src/operator.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,14 @@ impl Operator {
212212
}
213213

214214
/// List current dir path.
215-
pub fn list(&self, path: PathBuf) -> PyResult<BlockingLister> {
215+
#[pyo3(signature = (path, *, start_after=None))]
216+
pub fn list(&self, path: PathBuf, start_after: Option<String>) -> PyResult<BlockingLister> {
216217
let path = path.to_string_lossy().to_string();
217-
let l = self.core.lister(&path).map_err(format_pyerr)?;
218+
let mut builder = self.core.lister_with(&path);
219+
if let Some(start_after) = start_after {
220+
builder = builder.start_after(&start_after);
221+
}
222+
let l = builder.call().map_err(format_pyerr)?;
218223
Ok(BlockingLister::new(l))
219224
}
220225

@@ -503,11 +508,21 @@ impl AsyncOperator {
503508
}
504509

505510
/// List current dir path.
506-
pub fn list<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
511+
#[pyo3(signature = (path, *, start_after=None))]
512+
pub fn list<'p>(
513+
&'p self,
514+
py: Python<'p>,
515+
path: PathBuf,
516+
start_after: Option<String>,
517+
) -> PyResult<Bound<'p, PyAny>> {
507518
let this = self.core.clone();
508519
let path = path.to_string_lossy().to_string();
509520
future_into_py(py, async move {
510-
let lister = this.lister(&path).await.map_err(format_pyerr)?;
521+
let mut builder = this.lister_with(&path);
522+
if let Some(start_after) = start_after {
523+
builder = builder.start_after(&start_after);
524+
}
525+
let lister = builder.await.map_err(format_pyerr)?;
511526
let pylister = Python::with_gil(|py| AsyncLister::new(lister).into_py_any(py))?;
512527

513528
Ok(pylister)
@@ -519,11 +534,8 @@ impl AsyncOperator {
519534
let this = self.core.clone();
520535
let path = path.to_string_lossy().to_string();
521536
future_into_py(py, async move {
522-
let lister = this
523-
.lister_with(&path)
524-
.recursive(true)
525-
.await
526-
.map_err(format_pyerr)?;
537+
let builder = this.lister_with(&path).recursive(true);
538+
let lister = builder.await.map_err(format_pyerr)?;
527539
let pylister: PyObject =
528540
Python::with_gil(|py| AsyncLister::new(lister).into_py_any(py))?;
529541
Ok(pylister)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import asyncio
19+
from uuid import uuid4
20+
21+
import pytest
22+
23+
24+
@pytest.mark.asyncio
25+
@pytest.mark.need_capability("read", "write", "copy", "list", "list_with_start_after")
26+
async def test_async_list_with_start_after(service_name, operator, async_operator):
27+
test_dir = f"test_async_list_dir_{uuid4()}/"
28+
await async_operator.create_dir(test_dir)
29+
30+
# 1. Prepare data
31+
files_to_create = [f"{test_dir}file_{i}" for i in range(5)]
32+
# Create files concurrently
33+
await asyncio.gather(
34+
*(async_operator.write(f, b"test_content") for f in files_to_create)
35+
)
36+
37+
# 2. Test basic list
38+
entries = []
39+
async for entry in await async_operator.list(test_dir):
40+
entries.append(entry.path)
41+
entries.sort() # Ensure order for comparison
42+
expected_files = sorted([test_dir, *files_to_create])
43+
assert entries == expected_files, (
44+
f"Basic list failed. Expected {expected_files}, got {entries}"
45+
)
46+
47+
# 3. Test list with start_after
48+
start_after_file = files_to_create[2] # e.g., test_dir/file_2
49+
entries_after = []
50+
# Note: start_after expects the *full path* relative to the operator root
51+
async for entry in await async_operator.list(
52+
test_dir, start_after=start_after_file
53+
):
54+
entries_after.append(entry.path)
55+
entries_after.sort() # Ensure order
56+
57+
# Expected files are those lexicographically after start_after_file
58+
expected_files_after = sorted([f for f in files_to_create if f > start_after_file])
59+
assert entries_after == expected_files_after, (
60+
f"Expected {expected_files_after} after {start_after_file}, got {entries_after}"
61+
)
62+
# 6. Cleanup
63+
await async_operator.remove_all(test_dir)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from uuid import uuid4
19+
20+
import pytest
21+
22+
23+
@pytest.mark.need_capability("read", "write", "copy", "list", "list_with_start_after")
24+
def test_sync_list_with_start_after(service_name, operator, async_operator):
25+
test_dir = f"test_sync_list_dir_{uuid4()}/"
26+
operator.create_dir(test_dir)
27+
28+
# 1. Prepare data
29+
files_to_create = [f"{test_dir}file_{i}" for i in range(5)]
30+
for f in files_to_create:
31+
operator.write(f, b"test_content")
32+
33+
# 2. Test basic list
34+
entries = []
35+
for entry in operator.list(test_dir):
36+
entries.append(entry.path)
37+
entries.sort() # Ensure order for comparison
38+
expected_files = sorted([test_dir, *files_to_create])
39+
assert entries == expected_files, (
40+
f"Basic list failed. Expected {expected_files}, got {entries}"
41+
)
42+
43+
# 3. Test list with start_after
44+
start_after_file = files_to_create[2] # e.g., test_dir/file_2
45+
entries_after = []
46+
# Note: start_after expects the *full path* relative to the operator root
47+
for entry in operator.list(
48+
test_dir, start_after=start_after_file
49+
):
50+
entries_after.append(entry.path)
51+
entries_after.sort() # Ensure order
52+
53+
# Expected files are those lexicographically after start_after_file
54+
expected_files_after = sorted([f for f in files_to_create if f > start_after_file])
55+
assert entries_after == expected_files_after, (
56+
f"Expected {expected_files_after} after {start_after_file}, got {entries_after}"
57+
)
58+
# 6. Cleanup
59+
operator.remove_all(test_dir)

dev/src/generate/python.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"""
1919
> DO NOT EDIT IT MANUALLY <
2020

21-
This file is generated by opendal/dev/generate/python.rs.
21+
This file is generated by opendal/dev/src/generate/python.rs.
2222
`opendal.__base` doesn't exists.
2323
"""
2424

0 commit comments

Comments
 (0)