From e9980a1a5b8e14e406f4bbd3c4968ef250a88b87 Mon Sep 17 00:00:00 2001 From: tianyizhuang Date: Mon, 27 Apr 2026 23:51:03 +0800 Subject: [PATCH 1/4] fix(bindings/python): add RateLimited and RangeNotSatisfied exception kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ErrorKind variants introduced in Rust core — RateLimited and RangeNotSatisfied — were not mapped in the Python bindings. Both were silently swallowed by the catch-all `_ => Unexpected::new_err(e)` in format_pyerr_impl, making it impossible for Python callers to distinguish rate-limit responses (e.g. S3 429) or out-of-range reads from generic unexpected errors. This commit adds: - create_exception! declarations for RateLimited and RangeNotSatisfied - Explicit arms in format_pyerr_impl for both new kinds - Registration in the exceptions module in lib.rs - .pyi stub entries (alphabetically sorted) - Tests verifying presence, raisability, and mutual distinctness of the new exception classes Co-Authored-By: Claude Sonnet 4.6 --- bindings/python/python/opendal/exceptions.pyi | 6 ++ bindings/python/src/errors.rs | 14 +++++ bindings/python/src/lib.rs | 4 +- bindings/python/tests/test_exceptions.py | 55 +++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/bindings/python/python/opendal/exceptions.pyi b/bindings/python/python/opendal/exceptions.pyi index 8a1e21e57940..806ebfba0f77 100644 --- a/bindings/python/python/opendal/exceptions.pyi +++ b/bindings/python/python/opendal/exceptions.pyi @@ -47,6 +47,12 @@ class NotFound(builtins.Exception): class PermissionDenied(builtins.Exception): r"""Permission denied.""" +class RateLimited(builtins.Exception): + r"""Rate limited.""" + +class RangeNotSatisfied(builtins.Exception): + r"""Range not satisfied.""" + class Unexpected(builtins.Exception): r"""Unexpected errors.""" diff --git a/bindings/python/src/errors.rs b/bindings/python/src/errors.rs index 4601b59b7c76..59847be57728 100644 --- a/bindings/python/src/errors.rs +++ b/bindings/python/src/errors.rs @@ -77,6 +77,18 @@ create_exception!( PyException, "Condition not match" ); +create_exception!( + opendal.exceptions, + RateLimited, + PyException, + "Rate limited" +); +create_exception!( + opendal.exceptions, + RangeNotSatisfied, + PyException, + "Range not satisfied" +); fn format_pyerr_impl(err: &ocore::Error) -> PyErr { let e = format!("{err:?}"); @@ -91,6 +103,8 @@ fn format_pyerr_impl(err: &ocore::Error) -> PyErr { ocore::ErrorKind::AlreadyExists => AlreadyExists::new_err(e), ocore::ErrorKind::IsSameFile => IsSameFile::new_err(e), ocore::ErrorKind::ConditionNotMatch => ConditionNotMatch::new_err(e), + ocore::ErrorKind::RateLimited => RateLimited::new_err(e), + ocore::ErrorKind::RangeNotSatisfied => RangeNotSatisfied::new_err(e), _ => Unexpected::new_err(e), } } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 13c6eccaa309..7388975d7074 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -97,7 +97,9 @@ fn _opendal(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { NotADirectory, AlreadyExists, IsSameFile, - ConditionNotMatch + ConditionNotMatch, + RateLimited, + RangeNotSatisfied ] )?; Ok(()) diff --git a/bindings/python/tests/test_exceptions.py b/bindings/python/tests/test_exceptions.py index f07cbeabf64b..e1838a9a927b 100644 --- a/bindings/python/tests/test_exceptions.py +++ b/bindings/python/tests/test_exceptions.py @@ -18,6 +18,8 @@ import builtins import inspect +import pytest + from opendal import exceptions @@ -25,3 +27,56 @@ def test_exceptions(): for _name, obj in inspect.getmembers(exceptions): if inspect.isclass(obj): assert issubclass(obj, builtins.Exception) + + +def test_all_expected_exceptions_present(): + """Verify every expected exception class is present in opendal.exceptions.""" + expected = [ + "Error", + "Unexpected", + "Unsupported", + "ConfigInvalid", + "NotFound", + "PermissionDenied", + "IsADirectory", + "NotADirectory", + "AlreadyExists", + "IsSameFile", + "ConditionNotMatch", + "RateLimited", + "RangeNotSatisfied", + ] + for name in expected: + assert hasattr(exceptions, name), f"exceptions.{name} is missing" + cls = getattr(exceptions, name) + assert inspect.isclass(cls), f"exceptions.{name} is not a class" + assert issubclass(cls, builtins.Exception), ( + f"exceptions.{name} does not inherit from Exception" + ) + + +def test_rate_limited_is_catchable(): + """RateLimited can be raised and caught like a standard exception.""" + with pytest.raises(exceptions.RateLimited): + raise exceptions.RateLimited("too many requests") + + +def test_range_not_satisfied_is_catchable(): + """RangeNotSatisfied can be raised and caught like a standard exception.""" + with pytest.raises(exceptions.RangeNotSatisfied): + raise exceptions.RangeNotSatisfied("requested range not satisfiable") + + +def test_exceptions_are_distinct(): + """RateLimited and RangeNotSatisfied must not accidentally catch each other.""" + with pytest.raises(exceptions.RateLimited): + try: + raise exceptions.RateLimited("rate limited") + except exceptions.RangeNotSatisfied: + pytest.fail("RangeNotSatisfied should not catch RateLimited") + + with pytest.raises(exceptions.RangeNotSatisfied): + try: + raise exceptions.RangeNotSatisfied("range error") + except exceptions.RateLimited: + pytest.fail("RateLimited should not catch RangeNotSatisfied") From e5441b59a9f498c927fd810481d54f4dde29af42 Mon Sep 17 00:00:00 2001 From: tianyizhuang Date: Tue, 28 Apr 2026 01:49:26 +0800 Subject: [PATCH 2/4] fix(bindings/python): fix ruff EM101 linting errors in test_exceptions.py Assign exception message strings to variables before raising to comply with ruff EM101 rule (no string literals in raise statements). --- bindings/python/tests/test_exceptions.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bindings/python/tests/test_exceptions.py b/bindings/python/tests/test_exceptions.py index e1838a9a927b..3017a47f78af 100644 --- a/bindings/python/tests/test_exceptions.py +++ b/bindings/python/tests/test_exceptions.py @@ -58,25 +58,29 @@ def test_all_expected_exceptions_present(): def test_rate_limited_is_catchable(): """RateLimited can be raised and caught like a standard exception.""" with pytest.raises(exceptions.RateLimited): - raise exceptions.RateLimited("too many requests") + msg = "too many requests" + raise exceptions.RateLimited(msg) def test_range_not_satisfied_is_catchable(): """RangeNotSatisfied can be raised and caught like a standard exception.""" with pytest.raises(exceptions.RangeNotSatisfied): - raise exceptions.RangeNotSatisfied("requested range not satisfiable") + msg = "requested range not satisfiable" + raise exceptions.RangeNotSatisfied(msg) def test_exceptions_are_distinct(): """RateLimited and RangeNotSatisfied must not accidentally catch each other.""" with pytest.raises(exceptions.RateLimited): try: - raise exceptions.RateLimited("rate limited") + msg = "rate limited" + raise exceptions.RateLimited(msg) except exceptions.RangeNotSatisfied: pytest.fail("RangeNotSatisfied should not catch RateLimited") with pytest.raises(exceptions.RangeNotSatisfied): try: - raise exceptions.RangeNotSatisfied("range error") + msg = "range error" + raise exceptions.RangeNotSatisfied(msg) except exceptions.RateLimited: pytest.fail("RateLimited should not catch RangeNotSatisfied") From b21431e77e1238cf4dc515158f6cb1cb659c5b17 Mon Sep 17 00:00:00 2001 From: tianyizhuang Date: Tue, 28 Apr 2026 01:53:14 +0800 Subject: [PATCH 3/4] fix(bindings/python): fix cargo fmt formatting in errors.rs and lib.rs - Convert RateLimited create_exception! macro to single-line format to match existing short-doc patterns (NotFound, IsSameFile) - Add trailing comma after RangeNotSatisfied in add_pyexceptions! macro array in lib.rs (required by edition=2024 rustfmt) --- bindings/python/src/errors.rs | 7 +------ bindings/python/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/bindings/python/src/errors.rs b/bindings/python/src/errors.rs index 59847be57728..2bf65fe89458 100644 --- a/bindings/python/src/errors.rs +++ b/bindings/python/src/errors.rs @@ -77,12 +77,7 @@ create_exception!( PyException, "Condition not match" ); -create_exception!( - opendal.exceptions, - RateLimited, - PyException, - "Rate limited" -); +create_exception!(opendal.exceptions, RateLimited, PyException, "Rate limited"); create_exception!( opendal.exceptions, RangeNotSatisfied, diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 7388975d7074..bf413eb7501f 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -99,7 +99,7 @@ fn _opendal(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { IsSameFile, ConditionNotMatch, RateLimited, - RangeNotSatisfied + RangeNotSatisfied, ] )?; Ok(()) From 96fc3cb4696c8ed3b02c27cde47cb9043193f909 Mon Sep 17 00:00:00 2001 From: tianyizhuang Date: Tue, 28 Apr 2026 01:55:22 +0800 Subject: [PATCH 4/4] fix(bindings/python): fix ruff PT012 and remove unrelated release_java.yml change - Move variable assignments outside pytest.raises blocks to comply with PT012 (single simple statement per raises block) - Replace try/except distinctness test with direct issubclass checks, which is clearer and avoids PT012 violations - Revert unrelated -DskipStagingRepositoryClose=true change in release_java.yml that was not part of this PR --- .github/workflows/release_java.yml | 1 - bindings/python/tests/test_exceptions.py | 19 ++++--------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release_java.yml b/.github/workflows/release_java.yml index cf9f6ec2cbb3..9d21368501f6 100644 --- a/.github/workflows/release_java.yml +++ b/.github/workflows/release_java.yml @@ -169,7 +169,6 @@ jobs: run: | ./mvnw org.sonatype.plugins:nexus-staging-maven-plugin:deploy-staged \ -DaltStagingDirectory=$LOCAL_STAGING_DIR \ - -DskipStagingRepositoryClose=true \ -DserverId=apache.releases.https \ -DnexusUrl=https://repository.apache.org env: diff --git a/bindings/python/tests/test_exceptions.py b/bindings/python/tests/test_exceptions.py index 3017a47f78af..82bcb2147d60 100644 --- a/bindings/python/tests/test_exceptions.py +++ b/bindings/python/tests/test_exceptions.py @@ -57,30 +57,19 @@ def test_all_expected_exceptions_present(): def test_rate_limited_is_catchable(): """RateLimited can be raised and caught like a standard exception.""" + msg = "too many requests" with pytest.raises(exceptions.RateLimited): - msg = "too many requests" raise exceptions.RateLimited(msg) def test_range_not_satisfied_is_catchable(): """RangeNotSatisfied can be raised and caught like a standard exception.""" + msg = "requested range not satisfiable" with pytest.raises(exceptions.RangeNotSatisfied): - msg = "requested range not satisfiable" raise exceptions.RangeNotSatisfied(msg) def test_exceptions_are_distinct(): """RateLimited and RangeNotSatisfied must not accidentally catch each other.""" - with pytest.raises(exceptions.RateLimited): - try: - msg = "rate limited" - raise exceptions.RateLimited(msg) - except exceptions.RangeNotSatisfied: - pytest.fail("RangeNotSatisfied should not catch RateLimited") - - with pytest.raises(exceptions.RangeNotSatisfied): - try: - msg = "range error" - raise exceptions.RangeNotSatisfied(msg) - except exceptions.RateLimited: - pytest.fail("RateLimited should not catch RangeNotSatisfied") + assert not issubclass(exceptions.RateLimited, exceptions.RangeNotSatisfied) + assert not issubclass(exceptions.RangeNotSatisfied, exceptions.RateLimited)