From 309c46d4cbcc07318e1f4a68adac9bdaaaf7d533 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 15:46:40 +0000 Subject: [PATCH 01/12] Test built sdist imports in nox build session --- noxfile.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/noxfile.py b/noxfile.py index 4458bf30..ed374417 100644 --- a/noxfile.py +++ b/noxfile.py @@ -178,6 +178,7 @@ def cover(session): @nox.session(python=PYTHON_DEFAULT_VERSION) def build(session): """Build the distribution.""" + session.run('rm', '-rf', 'build', 'dist', external=True) session.run('uv', 'build', external=True) # Set outputs for GitHub Actions @@ -190,6 +191,14 @@ def build(session): version = os.environ['GITHUB_REF'].replace('refs/tags/v', '') print(f'version={version}', file=github_output) + sdists = sorted(pathlib.Path('dist').glob('b2sdk-*.tar.gz')) + if len(sdists) != 1: + session.error(f'Expected exactly one source distribution, found {len(sdists)}: {sdists!r}') + + session.install(str(sdists[0])) + session.cd('dist') # avoid importing from the checkout instead of the built package + session.run('python', '-c', 'from b2sdk import v0, v1, v2, v3') + @nox.session(python=PYTHON_DEFAULT_VERSION) def doc(session): From 72d92026a47b2b4314fa8a9a187c272a5fa0d3bf Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 15:51:55 +0000 Subject: [PATCH 02/12] Add changelog entry for import smoke test --- changelog.d/+built-sdist-import-check.infrastructure.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/+built-sdist-import-check.infrastructure.md diff --git a/changelog.d/+built-sdist-import-check.infrastructure.md b/changelog.d/+built-sdist-import-check.infrastructure.md new file mode 100644 index 00000000..ba7469b9 --- /dev/null +++ b/changelog.d/+built-sdist-import-check.infrastructure.md @@ -0,0 +1 @@ +Add a build-time smoke test that installs the freshly built source distribution and imports the public API shims. From 07086732bbac5b791ff69d071b4e4aacf5891eb2 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 16:30:06 +0000 Subject: [PATCH 03/12] Skip changelog requirement for uv.lock-only PRs --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bff6598..10da809a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,18 @@ jobs: run: uv run nox -vs lint - name: Validate new changelog entries if: (contains(github.event.pull_request.labels.*.name, '-changelog') == false) && (github.event.pull_request.base.ref != '') - run: if [ -z "$(git diff --diff-filter=A --name-only origin/${{ github.event.pull_request.base.ref }} changelog.d)" ]; - then echo no changelog item added; exit 1; fi + run: | + mapfile -t changed_files < <(git diff --diff-filter=ACMR --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD) + + if [ "${#changed_files[@]}" -eq 1 ] && [ "${changed_files[0]}" = "uv.lock" ]; then + echo "Skipping changelog validation for lockfile-only change." + exit 0 + fi + + if [ -z "$(git diff --diff-filter=A --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD -- changelog.d)" ]; then + echo "No changelog item added." + exit 1 + fi - name: Changelog validation run: uv run nox -vs towncrier_check build: From 2fabc9dc962203ad0c8d2cffe95725539938744f Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 16:34:29 +0000 Subject: [PATCH 04/12] Assert built-package imports come from site-packages --- noxfile.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index ed374417..fedc60dd 100644 --- a/noxfile.py +++ b/noxfile.py @@ -197,7 +197,25 @@ def build(session): session.install(str(sdists[0])) session.cd('dist') # avoid importing from the checkout instead of the built package - session.run('python', '-c', 'from b2sdk import v0, v1, v2, v3') + session.run( + 'python', + '-c', + ( + 'import pathlib; ' + 'from b2sdk import v0, v1, v2, v3; ' + 'repo_root = pathlib.Path.cwd().parent.resolve(); ' + 'source_root = repo_root / "b2sdk"; ' + "module_files = {'v0': pathlib.Path(v0.__file__).resolve(), " + "'v1': pathlib.Path(v1.__file__).resolve(), " + "'v2': pathlib.Path(v2.__file__).resolve(), " + "'v3': pathlib.Path(v3.__file__).resolve()}; " + 'print(module_files); ' + "assert all(not path.is_relative_to(source_root) for path in module_files.values()), " + "f'Imported modules from checkout: {module_files!r}'; " + "assert all('site-packages' in path.parts for path in module_files.values()), " + "f'Imported modules from an unexpected location: {module_files!r}'" + ), + ) @nox.session(python=PYTHON_DEFAULT_VERSION) From 6a6517903d908c7f212eae654e7aaa366b2c0132 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 16:35:04 +0000 Subject: [PATCH 05/12] Bump idna from 3.11 to 3.15 in the uv group across 1 directory Bumps the uv group with 1 update in the / directory: [idna](https://github.com/kjd/idna). Updates `idna` from 3.11 to 3.15 - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15) --- updated-dependencies: - dependency-name: idna dependency-version: '3.15' dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 2cdfa526..b8bb3e64 100644 --- a/uv.lock +++ b/uv.lock @@ -557,11 +557,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] From 74dea44947a2eeb8a948c3ea47260e1590e5777d Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 22:12:03 +0000 Subject: [PATCH 06/12] Run CLI compatibility tests in CI --- .github/workflows/ci.yml | 55 +++++++++++++++++++++++ changelog.d/+cli-compat.infrastructure.md | 1 + 2 files changed, 56 insertions(+) create mode 100644 changelog.d/+cli-compat.infrastructure.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10da809a..ace4ac3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,61 @@ jobs: - name: Run integration tests if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' }} run: uv run nox -vs integration -- --dont-cleanup-old-buckets -v + cli-compat: + timeout-minutes: 90 + needs: cleanup_buckets + env: + B2_TEST_APPLICATION_KEY: ${{ secrets.B2_TEST_APPLICATION_KEY }} + B2_TEST_APPLICATION_KEY_ID: ${{ secrets.B2_TEST_APPLICATION_KEY_ID }} + INSTALL_SDK_FROM: ../b2-sdk-python + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python-version: "3.10" + - os: ubuntu-latest + python-version: "3.14" + - os: macos-latest + python-version: "3.12" + - os: windows-latest + python-version: "3.12" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: b2-sdk-python + - uses: actions/checkout@v4 + with: + repository: Backblaze/B2_Command_Line_Tool + path: B2_Command_Line_Tool + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v7 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + - name: Install test binary dependencies + if: startsWith(matrix.os, 'ubuntu') + run: | + sudo apt-get -y update + sudo apt-get -y install zsh fish + sudo chmod -R 755 /usr/share/zsh/vendor-completions /usr/share/zsh + - name: Install test binary dependencies (macOS) + if: startsWith(matrix.os, 'macos') + run: brew install fish + - name: Install dependencies + run: uv sync --directory ./B2_Command_Line_Tool --locked --group nox + - name: Run CLI unit tests + run: uv run --directory ./B2_Command_Line_Tool nox -vs unit -p ${{ matrix.python-version }} + - name: Run CLI integration tests (without secrets) + run: uv run --directory ./B2_Command_Line_Tool nox -vs integration -p ${{ matrix.python-version }} -- -m "not require_secrets" + - name: Run CLI integration tests (with secrets) + if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14' }} + run: uv run --directory ./B2_Command_Line_Tool nox -vs integration -p ${{ matrix.python-version }} -- -m "require_secrets" --cleanup doc: timeout-minutes: 30 needs: build diff --git a/changelog.d/+cli-compat.infrastructure.md b/changelog.d/+cli-compat.infrastructure.md new file mode 100644 index 00000000..7be7e94e --- /dev/null +++ b/changelog.d/+cli-compat.infrastructure.md @@ -0,0 +1 @@ +Run B2 CLI unit tests and integration tests in CI against the SDK checkout. From d48054dfe73e7912b597f26d5eb9a0328924997c Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 21 May 2026 22:19:56 +0000 Subject: [PATCH 07/12] Reduce CLI secret integration load --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ace4ac3f..ac5c8ff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,16 @@ jobs: run: uv run --directory ./B2_Command_Line_Tool nox -vs integration -p ${{ matrix.python-version }} -- -m "not require_secrets" - name: Run CLI integration tests (with secrets) if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14' }} - run: uv run --directory ./B2_Command_Line_Tool nox -vs integration -p ${{ matrix.python-version }} -- -m "require_secrets" --cleanup + working-directory: B2_Command_Line_Tool + run: | + .nox/integration-3-14/bin/python -m pytest \ + test/integration \ + -n 1 \ + --log-level INFO \ + -W ignore::DeprecationWarning:rst2ansi.visitor: \ + -m "require_secrets" \ + --cleanup \ + --sut .nox/integration-3-14/bin/b2v4 doc: timeout-minutes: 30 needs: build From fb6aa88a2f2617a40ba9e10a8caa058084ae9b0b Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Fri, 22 May 2026 02:36:26 +0000 Subject: [PATCH 08/12] Activate CLI test environment in CI smoke run --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac5c8ff6..e8e7ce08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,14 +195,16 @@ jobs: if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14' }} working-directory: B2_Command_Line_Tool run: | - .nox/integration-3-14/bin/python -m pytest \ + export VIRTUAL_ENV="$PWD/.nox/integration-3-14" + export PATH="$VIRTUAL_ENV/bin:$PATH" + python -m pytest \ test/integration \ -n 1 \ --log-level INFO \ -W ignore::DeprecationWarning:rst2ansi.visitor: \ -m "require_secrets" \ --cleanup \ - --sut .nox/integration-3-14/bin/b2v4 + --sut "$VIRTUAL_ENV/bin/b2v4" doc: timeout-minutes: 30 needs: build From 5629cfee3809898fd2255116227ea499b6495be8 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Tue, 26 May 2026 19:45:56 +0000 Subject: [PATCH 09/12] Add Read the Docs build config --- .readthedocs.yaml | 20 +++++++++++++++++++ .../+readthedocs-config.infrastructure.md | 1 + 2 files changed, 21 insertions(+) create mode 100644 .readthedocs.yaml create mode 100644 changelog.d/+readthedocs-config.infrastructure.md diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..b26b2e09 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,20 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.14" + apt_packages: + - graphviz + - plantuml + +sphinx: + configuration: doc/source/conf.py + fail_on_warning: true + +python: + install: + - method: uv + command: sync + groups: + - doc diff --git a/changelog.d/+readthedocs-config.infrastructure.md b/changelog.d/+readthedocs-config.infrastructure.md new file mode 100644 index 00000000..eb9fb5c6 --- /dev/null +++ b/changelog.d/+readthedocs-config.infrastructure.md @@ -0,0 +1 @@ +Add a checked-in Read the Docs build configuration so docs builds install the required doc dependencies and system packages. From 8e1ff756355cf3e23ad56537ce73a1246097dfdf Mon Sep 17 00:00:00 2001 From: Maciej Lech Date: Mon, 6 Jul 2026 18:21:18 +0200 Subject: [PATCH 10/12] Fix RTD builds --- .readthedocs.yaml | 20 -------------------- .readthedocs.yml | 21 ++++++++------------- 2 files changed, 8 insertions(+), 33 deletions(-) delete mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index b26b2e09..00000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 - -build: - os: ubuntu-24.04 - tools: - python: "3.14" - apt_packages: - - graphviz - - plantuml - -sphinx: - configuration: doc/source/conf.py - fail_on_warning: true - -python: - install: - - method: uv - command: sync - groups: - - doc diff --git a/.readthedocs.yml b/.readthedocs.yml index c2c1c68b..4ffc2bff 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,30 +2,25 @@ # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -# Required version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.12" + python: "3.14" apt_packages: - graphviz - jobs: - post_create_environment: - - python -m pip install uv==0.8.4 - - uv export --format requirements-txt --group doc --output-file requirements-doc.txt + - plantuml -# Build documentation in the docs/ directory with Sphinx sphinx: configuration: doc/source/conf.py + fail_on_warning: true -# Optionally build your docs in additional formats such as PDF and ePub formats: all -# Optionally set the version of Python and requirements required to build your docs python: install: - - requirements: requirements-doc.txt - - method: pip - path: . + - method: uv + command: sync + groups: + - doc From 740d0ba2f9c6607b9ab0d09d63b8cada1b0c055c Mon Sep 17 00:00:00 2001 From: Maciej Lech Date: Mon, 6 Jul 2026 18:31:20 +0200 Subject: [PATCH 11/12] Suppress epub mimetype warning from graphviz imagemaps --- doc/source/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/source/conf.py b/doc/source/conf.py index 7c8a742c..29af0074 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -57,6 +57,11 @@ 'sphinxcontrib.plantuml', ] +# graphviz emits .png.map imagemap files that the epub builder cannot classify; +# epub does not support imagemaps, so silence the un-actionable mimetype warning +# (otherwise fail_on_warning breaks the epub build from `formats: all`). +suppress_warnings = ['epub.unknown_project_files'] + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] From 512396c1486c78e41667927263716e1a557d16a4 Mon Sep 17 00:00:00 2001 From: Jan Sauer Date: Mon, 17 Aug 2026 15:20:59 +0800 Subject: [PATCH 12/12] Retry tests on all 5xx errors --- b2sdk/_internal/exception.py | 11 ++++- .../testing/helpers/bucket_manager.py | 10 ++++- test/integration/conftest.py | 33 +++++++++++++++ test/integration/test_upload.py | 42 ++++++++++++++++++- test/unit/test_exception.py | 3 ++ 5 files changed, 95 insertions(+), 4 deletions(-) diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index 11a57475..2cf50dd7 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -433,6 +433,15 @@ class ServiceError(TransientErrorMixin, B2Error): Used for HTTP status codes 500 through 599. """ + def __init__(self, status, code, message): + super().__init__() + self._status = status + self._code = code + self._message = message + + def __str__(self): + return f'{self._status} {self._code} {self._message}' + class CapExceeded(B2Error): def __str__(self): @@ -744,5 +753,5 @@ def interpret_b2_error( elif status == 429: return TooManyRequests(retry_after_seconds=response_headers.get('retry-after')) elif 500 <= status < 600: - return ServiceError('%d %s %s' % (status, code, message)) + return ServiceError(status, code, message) return UnknownError('%d %s %s' % (status, code, message)) diff --git a/b2sdk/_internal/testing/helpers/bucket_manager.py b/b2sdk/_internal/testing/helpers/bucket_manager.py index bc76ef3b..78c966d1 100644 --- a/b2sdk/_internal/testing/helpers/bucket_manager.py +++ b/b2sdk/_internal/testing/helpers/bucket_manager.py @@ -25,6 +25,7 @@ BucketIdNotFound, DuplicateBucketName, FileNotPresent, + ServiceError, TooManyRequests, ) from b2sdk._internal.file_lock import NO_RETENTION_FILE_SETTING, LegalHold, RetentionMode @@ -43,6 +44,11 @@ logger = logging.getLogger(__name__) +def _retry_bucket_test_operation(exc: BaseException) -> bool: + # Retry on TooManyRequests as well as all 5xx errors (covered by ServiceError) + return isinstance(exc, (TooManyRequests, ServiceError)) + + class BucketManager: def __init__( self, @@ -83,7 +89,7 @@ def new_bucket_info(self) -> dict: } @tenacity.retry( - retry=tenacity.retry_if_exception_type(TooManyRequests), + retry=tenacity.retry_if_exception(_retry_bucket_test_operation), wait=tenacity.wait_exponential(), stop=tenacity.stop_after_attempt(8), ) @@ -152,7 +158,7 @@ def clean_buckets(self, quick=False): print(bucket) @tenacity.retry( - retry=tenacity.retry_if_exception_type(TooManyRequests), + retry=tenacity.retry_if_exception(_retry_bucket_test_operation), wait=tenacity.wait_exponential(), stop=tenacity.stop_after_attempt(8), ) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index d700e2fd..178e7a77 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -7,9 +7,42 @@ # License https://www.backblaze.com/using_b2_code.html # ###################################################################### +from __future__ import annotations + import pytest +from b2sdk._internal.exception import ServiceError, TooManyRequests + +INTEGRATION_TEST_RETRY_COUNT = 4 + @pytest.fixture(scope='session', autouse=True) def auto_change_account_info_dir(change_account_info_dir): pass + + +@pytest.hookimpl(tryfirst=True) +def pytest_pyfunc_call(pyfuncitem): + testfunction = pyfuncitem.obj + funcargs = pyfuncitem.funcargs + testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} + + for attempt in range(INTEGRATION_TEST_RETRY_COUNT + 1): + try: + testfunction(**testargs) + return True + except ServiceError as exc: + # Retry on all 5xx errors, which ServiceError covers + if attempt >= INTEGRATION_TEST_RETRY_COUNT: + raise + print( + f'Retrying {pyfuncitem.nodeid} after transient service error {exc._status}:' + f' attempt {attempt + 1} of {INTEGRATION_TEST_RETRY_COUNT}' + ) + except TooManyRequests: + if attempt >= INTEGRATION_TEST_RETRY_COUNT: + raise + print( + f'Retrying {pyfuncitem.nodeid} after transient too many requests:' + f' attempt {attempt + 1} of {INTEGRATION_TEST_RETRY_COUNT}' + ) diff --git a/test/integration/test_upload.py b/test/integration/test_upload.py index 8dbebcae..15e8e01a 100644 --- a/test/integration/test_upload.py +++ b/test/integration/test_upload.py @@ -10,15 +10,27 @@ from __future__ import annotations import io +import logging +import secrets -from b2sdk._internal.b2http import B2Http +from b2sdk._internal.b2http import B2Http, HttpCallback from b2sdk._internal.encryption.setting import EncryptionKey, EncryptionSetting from b2sdk._internal.encryption.types import EncryptionAlgorithm, EncryptionMode +from b2sdk._internal.utils import hex_sha1_of_stream from b2sdk.v2 import B2RawHTTPApi from b2sdk.v3.testing import IntegrationTestBase from .test_raw_api import authorize_raw_api +logger = logging.getLogger(__name__) + + +class FailSomeUploads(HttpCallback): + def pre_request(self, method, url, headers): + if method == 'POST' and 'b2_upload_file' in url: + headers['X-Bz-Test-Mode'] = 'fail_some_uploads' + logger.info('Added X-Bz-Test-Mode=fail_some_uploads header to %s', url) + class TestUnboundStreamUpload(IntegrationTestBase): def assert_data_uploaded_via_stream(self, data: bytes, part_size: int | None = None): @@ -46,6 +58,34 @@ def test_streamed_large_buffer_small_part_size(self): class TestUploadLargeFile(IntegrationTestBase): + def test_raw_upload_with_intermittent_failures(self): + bucket = self.create_bucket() + raw_api = self.b2_api.session.raw_api + b2_http = raw_api.b2_http + account_info = self.b2_api.account_info + callback = FailSomeUploads() + b2_http.add_callback(callback) + try: + payload = b'payload' + file_name = f'fail-some-uploads-{secrets.token_hex(4)}' + upload_url = raw_api.get_upload_url( + account_info.get_api_url(), + account_info.get_account_auth_token(), + bucket.id_, + ) + raw_api.upload_file( + upload_url['uploadUrl'], + upload_url['authorizationToken'], + file_name, + len(payload), + 'text/plain', + hex_sha1_of_stream(io.BytesIO(payload), len(payload)), + {}, + io.BytesIO(payload), + ) + finally: + b2_http.callbacks.remove(callback) + def test_ssec_key_id(self): sse_c = EncryptionSetting( mode=EncryptionMode.SSE_C, diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py index 258e301a..5e44af7c 100644 --- a/test/unit/test_exception.py +++ b/test/unit/test_exception.py @@ -159,6 +159,9 @@ def test_bad_bucket_id(self): def test_service_error(self): error = interpret_b2_error(500, 'code', 'message', {}) assert isinstance(error, ServiceError) + assert error._status == 500 + assert error._code == 'code' + assert error._message == 'message' assert '500 code message' == str(error) def test_unknown_error(self):