├── .github ├── dependabot.yml ├── no-response.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README.release.md ├── b2.spec.template ├── b2 ├── LICENSE ├── __init__.py └── _internal │ ├── __init__.py │ ├── _cli │ ├── __init__.py │ ├── arg_parser_types.py │ ├── argcompleters.py │ ├── autocomplete_cache.py │ ├── autocomplete_install.py │ ├── b2api.py │ ├── b2args.py │ ├── const.py │ ├── obj_dumps.py │ ├── obj_loads.py │ └── shell.py │ ├── _utils │ ├── __init__.py │ ├── python_compat.py │ └── uri.py │ ├── arg_parser.py │ ├── b2v3 │ ├── __init__.py │ ├── __main__.py │ ├── registry.py │ ├── rm.py │ └── sync.py │ ├── b2v4 │ ├── __init__.py │ ├── __main__.py │ └── registry.py │ ├── console_tool.py │ ├── json_encoder.py │ ├── version.py │ └── version_listing.py ├── changelog.d └── .gitkeep ├── contrib ├── color-b2-logs.sh ├── debug_logs.ini └── macos │ └── entitlements.plist ├── doc ├── bash_completion.md └── source │ ├── commands.rst │ ├── conf.py │ ├── index.rst │ ├── quick_start.rst │ └── replication.rst ├── docker ├── Dockerfile.template └── entrypoint.sh ├── noxfile.py ├── pdm.lock ├── pyinstaller-hooks ├── hook-b2.py └── hook-prettytable.py ├── pyproject.toml ├── setup.cfg └── test ├── __init__.py ├── conftest.py ├── helpers.py ├── integration ├── __init__.py ├── cleanup_buckets.py ├── conftest.py ├── helpers.py ├── persistent_bucket.py ├── test_autocomplete.py ├── test_b2_command_line.py ├── test_help.py └── test_tqdm_closer.py ├── static ├── __init__.py └── test_licenses.py └── unit ├── __init__.py ├── _cli ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── dummy_command.py │ └── module_loading_b2sdk.py ├── test_autocomplete_cache.py ├── test_autocomplete_install.py ├── test_obj_dumps.py ├── test_obj_loads.py ├── test_pickle.py ├── test_shell.py └── unpickle.py ├── _utils └── test_uri.py ├── conftest.py ├── console_tool ├── __init__.py ├── conftest.py ├── test_authorize_account.py ├── test_download_file.py ├── test_file_hide.py ├── test_file_info.py ├── test_file_server_side_copy.py ├── test_get_url.py ├── test_help.py ├── test_install_autocomplete.py ├── test_ls.py ├── test_notification_rules.py ├── test_rm.py ├── test_upload_file.py └── test_upload_unbound_stream.py ├── helpers.py ├── test_apiver.py ├── test_arg_parser.py ├── test_base.py ├── test_console_tool.py ├── test_copy.py └── test_represent_file_metadata.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | # This setting does not affect security updates 8 | open-pull-requests-limit: 0 -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an Issue is closed for lack of response 4 | daysUntilClose: 14 5 | 6 | # Label requiring a response 7 | responseRequiredLabel: more-information-needed 8 | 9 | # Comment to post when closing an Issue for lack of response. Set to `false` to disable 10 | closeComment: > 11 | This issue has been automatically closed because there has been no response 12 | to our request for more information from the original author. With only the 13 | information that is currently in the issue, we don't have enough information 14 | to take action. Please reach out if you have or find the answers we need so 15 | that we can investigate or assist you further. 16 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | push: 5 | tags: 'v*' # push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | env: 8 | CD: "true" 9 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 10 | PYTHON_DEFAULT_VERSION: "3.12" 11 | 12 | jobs: 13 | deploy: 14 | env: 15 | B2_PYPI_PASSWORD: ${{ secrets.B2_PYPI_PASSWORD }} 16 | runs-on: ubuntu-latest 17 | outputs: 18 | version: ${{ steps.build.outputs.version }} 19 | prerelease: ${{ steps.prerelease_check.outputs.prerelease }} 20 | # publish_docker: ${{ steps.prerelease_check.outputs.prerelease == 'false' && secrets.DOCKERHUB_USERNAME != '' }} # doesn't work, hence the workaround 21 | publish_docker: ${{ steps.prerelease_check.outputs.publish_docker }} 22 | steps: 23 | - name: Determine if pre-release 24 | id: prerelease_check 25 | run: | 26 | export IS_PRERELEASE=$([[ ${{ github.ref }} =~ [^0-9]$ ]] && echo true || echo false) 27 | echo "prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT 28 | export PUBLISH_DOCKER=$([[ $IS_PRERELEASE == 'false' && "${{ secrets.DOCKERHUB_USERNAME }}" != '' ]] && echo true || echo false) 29 | echo "publish_docker=$PUBLISH_DOCKER" >> $GITHUB_OUTPUT 30 | - uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 0 33 | - name: Set up Python ${{ env.PYTHON_DEFAULT_VERSION }} 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ env.PYTHON_DEFAULT_VERSION }} 37 | - name: Install dependencies 38 | run: python -m pip install --upgrade nox pdm 39 | - name: Build the distribution 40 | id: build 41 | run: nox -vs build 42 | - name: Read the Changelog 43 | id: read-changelog 44 | uses: mindsers/changelog-reader-action@v2 45 | with: 46 | version: ${{ steps.build.outputs.version }} 47 | - name: Create GitHub release and upload the distribution 48 | id: create-release 49 | uses: softprops/action-gh-release@v2 50 | with: 51 | name: ${{ steps.build.outputs.version }} 52 | body: ${{ steps.read-changelog.outputs.changes }} 53 | draft: false 54 | prerelease: ${{ steps.prerelease_check.outputs.prerelease }} 55 | files: ${{ steps.build.outputs.asset_path }} 56 | - name: Upload the distribution to PyPI 57 | if: ${{ env.B2_PYPI_PASSWORD != '' && steps.prerelease_check.outputs.prerelease == 'false' }} 58 | uses: pypa/gh-action-pypi-publish@v1.3.1 59 | with: 60 | user: __token__ 61 | password: ${{ secrets.B2_PYPI_PASSWORD }} 62 | deploy-linux-bundle: 63 | needs: deploy 64 | runs-on: ubuntu-latest 65 | container: 66 | image: "python:3.12" # can not use ${{ env.PYTHON_DEFAULT_VERSION }} here 67 | env: 68 | DEBIAN_FRONTEND: noninteractive 69 | steps: 70 | - uses: actions/checkout@v4 71 | with: 72 | fetch-depth: 0 73 | - name: Install dependencies 74 | run: | 75 | apt-get -y update 76 | apt-get -y install patchelf 77 | python -m pip install --upgrade nox pdm 78 | git config --global --add safe.directory '*' 79 | - name: Bundle the distribution 80 | id: bundle 81 | run: nox -vs bundle 82 | - name: Sign the bundle 83 | id: sign 84 | run: nox -vs sign 85 | - name: Generate hashes 86 | id: hashes 87 | run: nox -vs make_dist_digest 88 | - name: Upload the bundle to the GitHub release 89 | uses: softprops/action-gh-release@v2 90 | with: 91 | name: ${{ needs.deploy.outputs.version }} 92 | draft: false 93 | prerelease: ${{ needs.deploy.outputs.prerelease }} 94 | files: ${{ steps.sign.outputs.asset_path }} 95 | deploy-windows-bundle: 96 | needs: deploy 97 | env: 98 | B2_WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.B2_WINDOWS_CODE_SIGNING_CERTIFICATE }} 99 | B2_WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.B2_WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }} 100 | runs-on: windows-2019 101 | steps: 102 | - uses: actions/checkout@v4 103 | with: 104 | fetch-depth: 0 105 | - name: Set up Python ${{ env.PYTHON_DEFAULT_VERSION }} 106 | uses: actions/setup-python@v5 107 | with: 108 | python-version: ${{ env.PYTHON_DEFAULT_VERSION }} 109 | - name: Install dependencies 110 | run: python -m pip install --upgrade nox pdm 111 | - name: Bundle the distribution 112 | id: bundle 113 | shell: bash 114 | run: nox -vs bundle 115 | - name: Import certificate 116 | id: windows_import_cert 117 | if: ${{ env.B2_WINDOWS_CODE_SIGNING_CERTIFICATE != '' }} 118 | uses: timheuer/base64-to-file@v1 119 | with: 120 | fileName: 'cert.pfx' 121 | encodedString: ${{ secrets.B2_WINDOWS_CODE_SIGNING_CERTIFICATE }} 122 | - name: Sign the bundle 123 | if: ${{ env.B2_WINDOWS_CODE_SIGNING_CERTIFICATE != '' }} 124 | id: sign 125 | shell: bash 126 | run: nox -vs sign -- '${{ steps.windows_import_cert.outputs.filePath }}' '${{ env.B2_WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}' 127 | - name: Generate hashes 128 | id: hashes 129 | run: nox -vs make_dist_digest 130 | - name: Create GitHub release and upload the distribution 131 | id: create-release 132 | uses: softprops/action-gh-release@v2 133 | with: 134 | name: ${{ needs.deploy.outputs.version }} 135 | draft: false 136 | prerelease: ${{ needs.deploy.outputs.prerelease }} 137 | files: 138 | ${{ steps.sign.outputs.asset_path || steps.bundle.outputs.asset_path }} 139 | deploy-docker: 140 | needs: deploy 141 | if: ${{ needs.deploy.outputs.publish_docker == 'true' }} 142 | runs-on: ubuntu-latest 143 | env: 144 | DEBIAN_FRONTEND: noninteractive 145 | steps: 146 | - uses: actions/checkout@v4 147 | with: 148 | fetch-depth: 0 149 | - name: Set up Python ${{ env.PYTHON_DEFAULT_VERSION }} 150 | uses: actions/setup-python@v5 151 | with: 152 | python-version: ${{ env.PYTHON_DEFAULT_VERSION }} 153 | - name: Install dependencies 154 | run: python -m pip install --upgrade nox pdm 155 | - name: Build Dockerfile 156 | run: nox -vs generate_dockerfile 157 | - name: Set up QEMU 158 | uses: docker/setup-qemu-action@v3 159 | - name: Set up Docker Buildx 160 | uses: docker/setup-buildx-action@v3 161 | - name: Prepare Docker tags 162 | id: docker_tags_prep 163 | run: | 164 | DOCKER_TAGS=backblazeit/b2:${{ needs.deploy.outputs.version }} 165 | if [ "${{ needs.deploy.outputs.prerelease }}" != "true" ]; then 166 | DOCKER_TAGS="$DOCKER_TAGS,backblazeit/b2:latest" 167 | fi 168 | echo DOCKER_TAGS=$DOCKER_TAGS 169 | echo "docker_tags=$DOCKER_TAGS" >> $GITHUB_OUTPUT 170 | - name: Login to Docker Hub 171 | uses: docker/login-action@v3 172 | with: 173 | username: ${{ secrets.DOCKERHUB_USERNAME }} 174 | password: ${{ secrets.DOCKERHUB_TOKEN }} 175 | - name: Build and push 176 | uses: docker/build-push-action@v3 177 | with: 178 | context: . 179 | push: true 180 | tags: ${{ steps.docker_tags_prep.outputs.docker_tags }} 181 | platforms: linux/amd64,linux/arm64 182 | - name: Update Docker Hub Description 183 | uses: peter-evans/dockerhub-description@v4 184 | with: 185 | username: ${{ secrets.DOCKERHUB_USERNAME }} 186 | password: ${{ secrets.DOCKERHUB_TOKEN }} 187 | repository: backblazeit/b2 188 | short-description: "Official Backblaze B2 CLI docker image" 189 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .codacy-coverage/ 3 | .coverage 4 | .eggs/ 5 | .idea 6 | .nox/ 7 | .pdm-build/ 8 | .pdm-python 9 | .python-version 10 | b2_cli.log 11 | b2.egg-info 12 | build 13 | coverage.xml 14 | dist 15 | venv 16 | .venv 17 | doc/source/main_help.rst 18 | doc/source/subcommands 19 | Dockerfile 20 | b2/licenses_output.txt 21 | *.spec -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | jobs: 13 | post_create_environment: 14 | - pip install pdm 15 | - pdm export --format requirements --prod --group doc --output requirements-doc.txt --no-hashes 16 | 17 | # Build documentation in the docs/ directory with Sphinx 18 | sphinx: 19 | configuration: doc/source/conf.py 20 | 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | formats: all 23 | 24 | # Optionally set the version of Python and requirements required to build your docs 25 | python: 26 | install: 27 | - requirements: requirements-doc.txt 28 | - method: pip 29 | path: . -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to B2 Command Line Tool 2 | 3 | We encourage outside contributors to perform changes to our codebase. Many such changes have been merged already. 4 | In order to make it easier to contribute, core developers of this project: 5 | 6 | * provide guidance (through the issue reporting system) 7 | * provide tool assisted code review (through the Pull Request system) 8 | * maintain a set of unit tests 9 | * maintain a set of integration tests (run with a production cloud) 10 | * maintain development automation tools using [nox](https://github.com/theacodes/nox) that can easily: 11 | * format the code using [ruff](https://github.com/astral-sh/ruff) 12 | * run linters to find subtle/potential issues with maintainability 13 | * run the test suite on multiple Python versions using [pytest](https://github.com/pytest-dev/pytest) 14 | * maintain Continuous Integration (by using GitHub Actions) that: 15 | * runs all sorts of linters 16 | * checks if the Python distribution can be built 17 | * runs all tests on a matrix of supported versions of Python (including PyPy) and 3 operating systems 18 | (Linux, Mac OS X, and Windows) 19 | * checks if the documentation can be built properly 20 | * maintain other Continuous Integration tools (coverage tracker) 21 | 22 | ## Versioning 23 | 24 | This package's versions adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and the versions are 25 | established by reading git tags, i.e. no code or manifest file changes are required when working on PRs. 26 | 27 | ## Changelog 28 | 29 | Each PR needs to have at least one changelog (aka news) item added. This is done by creating files in `changelog.d`. 30 | `towncrier` is used for compiling these files into [CHANGELOG.md](CHANGELOG.md). There are several types of changes 31 | (news): 32 | 33 | 1. fixed 34 | 2. changed 35 | 3. added 36 | 4. deprecated 37 | 5. removed 38 | 6. infrastructure 39 | 7. doc 40 | 41 | 42 | The `changelog.d` file name convention is: 43 | 44 | 1. If the PR closes a github issue: `{issue_number}.{type}.md` e.g. `157.fixed.md`. Note that the 45 | change description still has to be complete, linking an issue is just there for convenience, a change like 46 | `fixed #157` will not be accepted. 47 | 2. If the PR is not related to a github issue: `+{unique_string}.{type}.md` e.g. `+foobar.fixed.md`. 48 | 49 | These files can either be created manually, or using `towncrier` e.g. 50 | 51 | towncrier create -c 'Add proper changelog example to CONTRIBUTING guide' 157.added.md 52 | 53 | `towncrier create` also takes care of duplicates automatically (if there is more than 1 news fragment of one type 54 | for a given github issue). 55 | 56 | ## Developer Info 57 | 58 | You'll need to have [nox](https://github.com/theacodes/nox) and [pdm](https://pdm-project.org/) installed: 59 | 60 | * `pip install nox pdm` 61 | 62 | With `nox`, you can run different sessions (default are `lint` and `test`): 63 | 64 | * `format` -> Format the code. 65 | * `lint` -> Run linters. 66 | * `test` (`test-3.8`, `test-3.9`, `test-3.10`, `test-3.11`) -> Run test suite. 67 | * `cover` -> Perform coverage analysis. 68 | * `build` -> Build the distribution. 69 | * `generate_dockerfile` -> generate dockerfile 70 | * `docker_test` -> run integration tests against a docker image 71 | * `build_and_test_docker` -> build a docker image and integration tests against it 72 | * `doc` -> Build the documentation. 73 | * `doc_cover` -> Perform coverage analysis for the documentation. 74 | 75 | For example: 76 | 77 | ```bash 78 | $ nox -s format 79 | nox > Running session format 80 | nox > Creating virtual environment (virtualenv) using python3.11 in .nox/format 81 | ... 82 | 83 | $ nox -s format 84 | nox > Running session format 85 | nox > Re-using existing virtual environment at .nox/format. 86 | ... 87 | 88 | $ nox --no-venv -s format 89 | nox > Running session format 90 | ... 91 | ``` 92 | 93 | Sessions `test` ,`unit`, and `integration` can run on many Python versions. 94 | 95 | Sessions other than that use the last given Python version. 96 | 97 | You can change it: 98 | 99 | ```bash 100 | export NOX_PYTHONS=3.9,3.10 101 | ``` 102 | 103 | With the above setting, session `test` will run on Python 3.9 and 3.10, and all other sessions on Python 3.10. 104 | 105 | Given Python interpreters should be installed in the operating system or via [pyenv](https://github.com/pyenv/pyenv). 106 | 107 | ## Managing dependencies 108 | 109 | We use [pdm](https://pdm-project.org/) for managing dependencies and developing locally. 110 | If you want to change any of the project requirements (or requirement bounds) in `pyproject.toml`, 111 | make sure that `pdm.lock` file reflects those changes by using `pdm add`, `pdm update` or other 112 | commands - see [documentation](https://pdm-project.org/latest/). You can verify that lock file 113 | is up to date by running the linter. 114 | 115 | ## Linting 116 | 117 | To run all available linters: 118 | 119 | ```bash 120 | nox -s lint 121 | ``` 122 | 123 | ## Testing 124 | 125 | To run all tests on every available Python version: 126 | 127 | ```bash 128 | nox -s test 129 | ``` 130 | 131 | To run all tests on a specific version: 132 | 133 | ```bash 134 | nox -s test-3.11 135 | ``` 136 | 137 | To run just unit tests: 138 | 139 | ```bash 140 | nox -s unit-3.11 141 | ``` 142 | 143 | To run just integration tests: 144 | 145 | ```bash 146 | export B2_TEST_APPLICATION_KEY=your_app_key 147 | export B2_TEST_APPLICATION_KEY_ID=your_app_key_id 148 | nox -s integration-3.11 149 | ``` 150 | 151 | ## Documentation 152 | 153 | To build the documentation and watch for changes (including the source code): 154 | 155 | ```bash 156 | nox -s doc 157 | ``` 158 | 159 | To just build the documentation: 160 | 161 | ```bash 162 | nox --non-interactive -s doc 163 | ``` 164 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Backblaze wants developers and organization to copy and re-use our 2 | code examples, so we make the samples available by several different 3 | licenses. One option is the MIT license (below). Other options are 4 | available here: 5 | 6 | https://www.backblaze.com/using_b2_code.html 7 | 8 | 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2015 Backblaze 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 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # B2 Command Line Tool 2 | 3 | [![CI](https://github.com/Backblaze/B2_Command_Line_Tool/actions/workflows/ci.yml/badge.svg)](https://github.com/Backblaze/B2_Command_Line_Tool/actions/workflows/ci.yml) [![License](https://img.shields.io/pypi/l/b2.svg?label=License)](https://pypi.python.org/pypi/b2) [![python versions](https://img.shields.io/pypi/pyversions/b2.svg?label=python%20versions)](https://pypi.python.org/pypi/b2) [![PyPI version](https://img.shields.io/pypi/v/b2.svg?label=PyPI%20version)](https://pypi.python.org/pypi/b2) [![Docs](https://readthedocs.org/projects/b2-command-line-tool/badge/?version=master)](https://b2-command-line-tool.readthedocs.io/en/master/?badge=master) 4 | 5 | The command-line tool that gives easy access to all of the capabilities of B2 Cloud Storage. 6 | 7 | This program provides command-line access to the B2 service. 8 | 9 | ## Documentation 10 | 11 | The latest documentation is available on [Read the Docs](https://b2-command-line-tool.readthedocs.io/). 12 | 13 | ## Installation 14 | 15 | For detailed instructions on how to install the command line tool see our [quick start guide](https://www.backblaze.com/b2/docs/quick_command_line.html). 16 | 17 | ### Homebrew 18 | 19 | [Homebrew](https://brew.sh/) is widely used in the Mac community, particularly amongst developers. We recommend using the [B2 CLI Homebrew](https://formulae.brew.sh/formula/b2-tools) formula as the quickest setup method for Mac users: 20 | 21 | ```bash 22 | brew install b2-tools 23 | ``` 24 | 25 | ### Binaries 26 | 27 | Stand-alone binaries are available for Linux and Windows; this is the most straightforward way to use the command-line tool and is sufficient in most use cases. The latest versions are available for download from the [Releases page](https://github.com/Backblaze/B2_Command_Line_Tool/releases). 28 | 29 | ### Python Package Index 30 | 31 | You can also install it in your Python environment ([virtualenv](https://pypi.org/project/virtualenv/) is recommended) from PyPI with: 32 | 33 | ```bash 34 | pip install b2[full] 35 | ``` 36 | 37 | The extra dependencies improve debugging experience and, potentially, performance of `b2` CLI, but are not strictly required. 38 | You can install the `b2` without them: 39 | 40 | ```bash 41 | pip install b2 42 | ``` 43 | 44 | ### Docker 45 | 46 | For a truly platform independent solution, use the official docker image: 47 | 48 | ```bash 49 | docker run backblazeit/b2:latest ... 50 | ``` 51 | 52 | See examples in [Usage/Docker image](#docker-image) 53 | 54 | ### Installing from source 55 | 56 | Not recommended, unless you want to check if a current pre-release code solves a bug affecting you. 57 | 58 | ```bash 59 | pip install git+https://github.com/Backblaze/B2_Command_Line_Tool.git 60 | ``` 61 | 62 | If you wish to contribute to or otherwise modify source code, please see our [contributing guidelines](CONTRIBUTING.md). 63 | 64 | ## Usage 65 | 66 | ``` 67 | b2 account Account management subcommands. 68 | b2 bucket Bucket management subcommands. 69 | b2 file File management subcommands. 70 | b2 install-autocomplete Install autocomplete for supported shells. 71 | b2 key Application keys management subcommands. 72 | b2 license Print the license information for this tool. 73 | b2 ls List files in a given folder. 74 | b2 replication Replication rule management subcommands. 75 | b2 rm Remove a "folder" or a set of files matching a pattern. 76 | b2 sync Copy multiple files from source to destination. 77 | b2 version Print the version number of this tool. 78 | ``` 79 | 80 | The environment variable `B2_ACCOUNT_INFO` specifies the SQLite 81 | file to use for caching authentication information. 82 | The default file to use is: `~/.b2_account_info`. 83 | 84 | To get more details on a specific command use `b2 --help`. 85 | 86 | When authorizing with application keys, this tool requires that the key 87 | have the `listBuckets` capability so that it can take the bucket names 88 | you provide on the command line and translate them into bucket IDs for the 89 | B2 Storage service. Each different command may required additional 90 | capabilities. You can find the details for each command in the help for 91 | that command. 92 | 93 | ### Docker image 94 | 95 | Thanks to [ApiVer methodology](#apiver-cli-versions-b2-vs-b2v3-b2v4-etc), 96 | you should be perfectly fine using `b2:latest` version even in long-term support scripts, 97 | but make sure to explicitly use `b2v4` command from the docker image as shown below. 98 | 99 | #### Authorization 100 | 101 | User can either authorize on each command (`bucket list` is just a example here) 102 | 103 | ```bash 104 | B2_APPLICATION_KEY= B2_APPLICATION_KEY_ID= docker run --rm -e B2_APPLICATION_KEY -e B2_APPLICATION_KEY_ID backblazeit/b2:latest b2v4 bucket list 105 | ``` 106 | 107 | or authorize once and keep the credentials persisted: 108 | 109 | ```bash 110 | docker run --rm -it -v b2:/root backblazeit/b2:latest b2v4 account authorize 111 | docker run --rm -v b2:/root backblazeit/b2:latest b2v4 bucket list # remember to include `-v` - authorization details are there 112 | ``` 113 | 114 | #### Downloading and uploading 115 | 116 | When uploading a single file, data can be passed to the container via a pipe: 117 | 118 | ```bash 119 | cat source_file.txt | docker run -i --rm -v b2:/root backblazeit/b2:latest b2v4 upload-unbound-stream bucket_name - target_file_name 120 | ``` 121 | 122 | or by mounting local files in the docker container: 123 | 124 | ```bash 125 | docker run --rm -v b2:/root -v /home/user/path/to/data:/data backblazeit/b2:latest b2v4 file upload bucket_name /data/source_file.txt target_file_name 126 | ``` 127 | 128 | ## ApiVer CLI versions (`b2` vs `b2v3`, `b2v4`, etc.) 129 | 130 | Summary: 131 | 132 | * in terminal, for best UX, use the latest apiver interface provided by `b2` command 133 | * for long-term support, i.e. in scripts, use `b2v4` command 134 | 135 | Explanation: 136 | 137 | We use the `ApiVer` methodology so we can continue to evolve the `b2` command line tool, 138 | while also providing all the bugfixes to the old interface versions. 139 | 140 | If you use the `b2` command, you're working with the latest stable interface. 141 | It provides all the bells and whistles, latest features, and the best performance. 142 | While it's a great version to work with directly, but when writing a reliable, long-running script, 143 | you want to ensure that your script won't break when we release a new version of the `b2` command. 144 | 145 | In that case instead of using the `b2` command, you should use a version-bound interface e.g.: `b2v4`. 146 | This command will always provide the same ApiVer 3 interface, regardless of the semantic version of the `b2` command. 147 | Even if the `b2` command goes into the ApiVer `4`, `6` or even `10` with some major changes, 148 | `b2v4` will still provide the same interface, same commands, and same parameters, with all the security and bug fixes. 149 | Over time, it might get slower as we may need to emulate some older behaviors, but we'll ensure that it won't break. 150 | 151 | You may find the next interface under `_b2v5`, but please note, as suggested by `_` prefix, 152 | it is not yet stable and is not yet covered by guarantees listed above. 153 | 154 | ## Contrib 155 | 156 | ### Detailed logs 157 | 158 | Verbose logs to stdout can be enabled with the `--verbose` flag. 159 | 160 | A hidden flag `--debug-logs` can be used to enable logging to a `b2_cli.log` file (with log rotation at midnight) in current working directory. Please pay attention not to launch the tool from the directory that you are syncing, or the logs will get synced to the remote server (unless that is really what you want to achieve). 161 | 162 | For advanced users, a hidden option `--log-config ` can be used to enable logging in a user-defined format and verbosity. Check out the [example log configuration](contrib/debug_logs.ini). 163 | 164 | ## Release History 165 | 166 | Please refer to the [changelog](CHANGELOG.md). 167 | 168 | ## Developer Info 169 | 170 | Please see our [contributing guidelines](CONTRIBUTING.md). 171 | -------------------------------------------------------------------------------- /README.release.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | - Run `nox -s make_release_commit -- X.Y.Z` where `X.Y.Z` is the version you're releasing 4 | - Copy the main usage string (from `b2 --help`) to `README.md`. Handy command for consistent format: `COLUMNS=4000 b2 --help | awk '/^usages:/ {p=1; next} p {sub(/^ */, "", $0); print}'` 5 | 6 | -------------------------------------------------------------------------------- /b2.spec.template: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | from PyInstaller.utils.hooks import collect_data_files, copy_metadata 4 | 5 | block_cipher = None 6 | 7 | # Data from "python-dateutil" is added because of 8 | # https://github.com/Backblaze/B2_Command_Line_Tool/issues/689 9 | datas = copy_metadata('b2') + collect_data_files('dateutil') 10 | 11 | a = Analysis(['b2/_internal/${VERSION}/__main__.py'], 12 | pathex=['.'], 13 | binaries=[], 14 | datas=datas, 15 | hiddenimports=['pkg_resources.extern', 'pkg_resources.py2_warn'], 16 | hookspath=['pyinstaller-hooks'], 17 | runtime_hooks=[], 18 | win_no_prefer_redirects=False, 19 | win_private_assemblies=False, 20 | cipher=block_cipher, 21 | noarchive=False) 22 | 23 | pyz = PYZ(a.pure, 24 | a.zipped_data, 25 | cipher=block_cipher) 26 | 27 | exe = EXE(pyz, 28 | a.scripts, 29 | a.binaries, 30 | a.zipfiles, 31 | a.datas, 32 | [], 33 | name='${NAME}', 34 | debug=False, 35 | bootloader_ignore_signals=False, 36 | strip=False, 37 | upx=True, 38 | upx_exclude=[], 39 | runtime_tmpdir=None, 40 | console=True) 41 | -------------------------------------------------------------------------------- /b2/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE -------------------------------------------------------------------------------- /b2/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/__init__.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # Set default logging handler to avoid "No handler found" warnings. 12 | import logging # noqa 13 | 14 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 15 | 16 | import b2._internal.version # noqa: E402 17 | 18 | __version__ = b2._internal.version.VERSION 19 | assert __version__ # PEP-0396 20 | -------------------------------------------------------------------------------- /b2/_internal/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /b2/_internal/_cli/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | """ 11 | _cli package contains internals of the command-line interface to the B2. 12 | 13 | It is not intended to be used as a library. 14 | """ 15 | -------------------------------------------------------------------------------- /b2/_internal/_cli/arg_parser_types.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/arg_parser_types.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import argparse 12 | import functools 13 | import re 14 | 15 | import arrow 16 | from b2sdk.v2 import RetentionPeriod 17 | 18 | _arrow_version = tuple(int(p) for p in arrow.__version__.split('.')) 19 | 20 | 21 | def parse_comma_separated_list(s): 22 | """ 23 | Parse comma-separated list. 24 | """ 25 | return [word.strip() for word in s.split(',')] 26 | 27 | 28 | def parse_millis_from_float_timestamp(s): 29 | """ 30 | Parse timestamp, e.g. 1367900664 or 1367900664.152 31 | """ 32 | parsed = arrow.get(float(s)) 33 | if _arrow_version < (1, 0, 0): 34 | return int(parsed.format('XSSS')) 35 | else: 36 | return int(parsed.format('x')[:13]) 37 | 38 | 39 | def parse_range(s): 40 | """ 41 | Parse optional integer range 42 | """ 43 | bytes_range = None 44 | if s is not None: 45 | bytes_range = s.split(',') 46 | if len(bytes_range) != 2: 47 | raise argparse.ArgumentTypeError('the range must have 2 values: start,end') 48 | bytes_range = ( 49 | int(bytes_range[0]), 50 | int(bytes_range[1]), 51 | ) 52 | 53 | return bytes_range 54 | 55 | 56 | def parse_default_retention_period(s): 57 | unit_part = '(' + ')|('.join(RetentionPeriod.KNOWN_UNITS) + ')' 58 | m = re.match(r'^(?P\d+) (?P%s)$' % (unit_part), s) 59 | if not m: 60 | raise argparse.ArgumentTypeError( 61 | 'default retention period must be in the form of "X days|years "' 62 | ) 63 | return RetentionPeriod(**{m.group('unit'): int(m.group('duration'))}) 64 | 65 | 66 | def wrap_with_argument_type_error(func, translator=str, exc_type=ValueError): 67 | """ 68 | Wrap function that may raise an exception into a function that raises ArgumentTypeError error. 69 | """ 70 | 71 | @functools.wraps(func) 72 | def wrapper(*args, **kwargs): 73 | try: 74 | return func(*args, **kwargs) 75 | except exc_type as e: 76 | raise argparse.ArgumentTypeError(translator(e)) 77 | 78 | return wrapper 79 | -------------------------------------------------------------------------------- /b2/_internal/_cli/argcompleters.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/argcompleters.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import itertools 11 | 12 | # We import all the necessary modules lazily in completers in order 13 | # to avoid upfront cost of the imports when argcompleter is used for 14 | # autocompletions. 15 | from itertools import islice 16 | 17 | 18 | def bucket_name_completer(prefix, parsed_args, **kwargs): 19 | from b2sdk.v2 import unprintable_to_hex 20 | 21 | from b2._internal._cli.b2api import _get_b2api_for_profile 22 | 23 | api = _get_b2api_for_profile(getattr(parsed_args, 'profile', None)) 24 | res = [ 25 | unprintable_to_hex(bucket_name_alias) 26 | for bucket_name_alias in itertools.chain.from_iterable( 27 | (bucket.name, f'b2://{bucket.name}') for bucket in api.list_buckets(use_cache=True) 28 | ) 29 | ] 30 | return res 31 | 32 | 33 | def file_name_completer(prefix, parsed_args, **kwargs): 34 | """ 35 | Completes file names in a bucket. 36 | 37 | To limit delay & cost only lists files returned from by single call to b2_list_file_names 38 | """ 39 | from b2sdk.v2 import LIST_FILE_NAMES_MAX_LIMIT, unprintable_to_hex 40 | 41 | from b2._internal._cli.b2api import _get_b2api_for_profile 42 | 43 | api = _get_b2api_for_profile(parsed_args.profile) 44 | bucket = api.get_bucket_by_name(parsed_args.bucketName) 45 | file_versions = bucket.ls( 46 | getattr(parsed_args, 'folderName', None) or '', 47 | latest_only=True, 48 | recursive=False, 49 | fetch_count=LIST_FILE_NAMES_MAX_LIMIT, 50 | folder_to_list_can_be_a_file=True, 51 | ) 52 | return [ 53 | unprintable_to_hex(folder_name or file_version.file_name) 54 | for file_version, folder_name in islice(file_versions, LIST_FILE_NAMES_MAX_LIMIT) 55 | ] 56 | 57 | 58 | def b2uri_file_completer(prefix: str, parsed_args, **kwargs): 59 | """ 60 | Complete B2 URI pointing to a file-like object in a bucket. 61 | """ 62 | from b2sdk.v2 import LIST_FILE_NAMES_MAX_LIMIT, unprintable_to_hex 63 | 64 | from b2._internal._cli.b2api import _get_b2api_for_profile 65 | from b2._internal._utils.python_compat import removeprefix 66 | from b2._internal._utils.uri import parse_b2_uri 67 | 68 | api = _get_b2api_for_profile(getattr(parsed_args, 'profile', None)) 69 | if prefix.startswith('b2://'): 70 | prefix_without_scheme = removeprefix(prefix, 'b2://') 71 | if '/' not in prefix_without_scheme: 72 | return [ 73 | f'b2://{unprintable_to_hex(bucket.name)}/' 74 | for bucket in api.list_buckets(use_cache=True) 75 | ] 76 | 77 | b2_uri = parse_b2_uri(prefix) 78 | bucket = api.get_bucket_by_name(b2_uri.bucket_name) 79 | file_versions = bucket.ls( 80 | f'{b2_uri.path}*', 81 | latest_only=True, 82 | recursive=True, 83 | fetch_count=LIST_FILE_NAMES_MAX_LIMIT, 84 | with_wildcard=True, 85 | ) 86 | return [ 87 | unprintable_to_hex(f'b2://{bucket.name}/{file_version.file_name}') 88 | for file_version, folder_name in islice(file_versions, LIST_FILE_NAMES_MAX_LIMIT) 89 | if file_version 90 | ] 91 | elif prefix.startswith('b2id://'): 92 | # listing all files from all buckets is unreasonably expensive 93 | return ['b2id://'] 94 | else: 95 | return [ 96 | 'b2://', 97 | 'b2id://', 98 | ] 99 | -------------------------------------------------------------------------------- /b2/_internal/_cli/autocomplete_cache.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/autocomplete_cache.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import abc 13 | import argparse 14 | import itertools 15 | import os 16 | import pathlib 17 | import pickle 18 | from typing import Callable 19 | 20 | import argcomplete 21 | import platformdirs 22 | 23 | from b2._internal.arg_parser import DeprecatedActionMarker 24 | from b2._internal.version import VERSION 25 | 26 | 27 | def identity(x): 28 | return x 29 | 30 | 31 | class StateTracker(abc.ABC): 32 | @abc.abstractmethod 33 | def current_state_identifier(self) -> str: 34 | raise NotImplementedError() 35 | 36 | 37 | class PickleStore(abc.ABC): 38 | @abc.abstractmethod 39 | def get_pickle(self, identifier: str) -> bytes | None: 40 | raise NotImplementedError() 41 | 42 | @abc.abstractmethod 43 | def set_pickle(self, identifier: str, data: bytes) -> None: 44 | raise NotImplementedError() 45 | 46 | 47 | class VersionTracker(StateTracker): 48 | def current_state_identifier(self) -> str: 49 | return VERSION 50 | 51 | 52 | class HomeCachePickleStore(PickleStore): 53 | _dir: pathlib.Path 54 | 55 | def __init__(self, dir_path: pathlib.Path | None = None) -> None: 56 | self._dir = dir_path 57 | 58 | def _cache_dir(self) -> pathlib.Path: 59 | if not self._dir: 60 | self._dir = ( 61 | pathlib.Path(platformdirs.user_cache_dir(appname='b2', appauthor='backblaze')) 62 | / 'autocomplete' 63 | ) 64 | return self._dir 65 | 66 | def _fname(self, identifier: str) -> str: 67 | return f'b2-autocomplete-cache-{identifier}.pickle' 68 | 69 | def get_pickle(self, identifier: str) -> bytes | None: 70 | path = self._cache_dir() / self._fname(identifier) 71 | if path.exists(): 72 | with open(path, 'rb') as f: 73 | return f.read() 74 | 75 | def set_pickle(self, identifier: str, data: bytes) -> None: 76 | """Sets the pickle for identifier if it doesn't exist. 77 | When a new pickle is added, old ones are removed.""" 78 | 79 | dir_path = self._cache_dir() 80 | dir_path.mkdir(parents=True, exist_ok=True) 81 | path = dir_path / self._fname(identifier) 82 | for file in dir_path.glob('b2-autocomplete-cache-*.pickle'): 83 | file.unlink() 84 | with open(path, 'wb') as f: 85 | f.write(data) 86 | 87 | 88 | class AutocompleteCache: 89 | _tracker: StateTracker 90 | _store: PickleStore 91 | _unpickle: Callable[[bytes], argparse.ArgumentParser] 92 | 93 | def __init__( 94 | self, 95 | tracker: StateTracker, 96 | store: PickleStore, 97 | unpickle: Callable[[bytes], argparse.ArgumentParser] | None = None, 98 | ): 99 | self._tracker = tracker 100 | self._store = store 101 | self._unpickle = unpickle or pickle.loads 102 | 103 | def _is_autocomplete_run(self) -> bool: 104 | return '_ARGCOMPLETE' in os.environ 105 | 106 | def autocomplete_from_cache( 107 | self, uncached_args: dict | None = None, raise_exc: bool = False 108 | ) -> None: 109 | if not self._is_autocomplete_run(): 110 | return 111 | 112 | try: 113 | identifier = self._tracker.current_state_identifier() 114 | pickle_data = self._store.get_pickle(identifier) 115 | if pickle_data: 116 | parser = self._unpickle(pickle_data) 117 | argcomplete.autocomplete(parser, **(uncached_args or {})) 118 | except Exception: 119 | if raise_exc: 120 | raise 121 | # Autocomplete from cache failed but maybe we can autocomplete from scratch 122 | return 123 | 124 | def _clean_parser(self, parser: argparse.ArgumentParser) -> None: 125 | parser.register('type', None, identity) 126 | 127 | def _get_deprecated_actions(actions): 128 | return [action for action in actions if isinstance(action, DeprecatedActionMarker)] 129 | 130 | for action in _get_deprecated_actions(parser._actions): 131 | parser._actions.remove(action) 132 | for option_string in action.option_strings: 133 | del parser._option_string_actions[option_string] 134 | 135 | for action in parser._actions: 136 | if action.type not in [str, int]: 137 | action.type = None 138 | 139 | for group in itertools.chain(parser._action_groups, parser._mutually_exclusive_groups): 140 | for action in _get_deprecated_actions(group._group_actions): 141 | group._group_actions.remove(action) 142 | 143 | for key in parser._defaults: 144 | group.set_defaults(**{key: None}) 145 | 146 | parser.description = None 147 | if parser._subparsers: 148 | for group_action in parser._subparsers._group_actions: 149 | for parser in group_action.choices.values(): 150 | self._clean_parser(parser) 151 | 152 | def cache_and_autocomplete( 153 | self, parser: argparse.ArgumentParser, uncached_args: dict | None = None 154 | ) -> None: 155 | if not self._is_autocomplete_run(): 156 | return 157 | 158 | try: 159 | identifier = self._tracker.current_state_identifier() 160 | self._clean_parser(parser) 161 | self._store.set_pickle(identifier, pickle.dumps(parser)) 162 | finally: 163 | argcomplete.autocomplete(parser, **(uncached_args or {})) 164 | 165 | 166 | AUTOCOMPLETE = AutocompleteCache(tracker=VersionTracker(), store=HomeCachePickleStore()) 167 | -------------------------------------------------------------------------------- /b2/_internal/_cli/b2api.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/b2api.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import os 12 | from typing import Optional 13 | 14 | from b2sdk.v2 import ( 15 | AuthInfoCache, 16 | B2Api, 17 | B2HttpApiConfig, 18 | InMemoryAccountInfo, 19 | InMemoryCache, 20 | SqliteAccountInfo, 21 | ) 22 | from b2sdk.v2.exception import MissingAccountData 23 | 24 | from b2._internal._cli.const import B2_USER_AGENT_APPEND_ENV_VAR 25 | 26 | 27 | def _get_b2api_for_profile( 28 | profile: Optional[str] = None, 29 | raise_if_does_not_exist: bool = False, 30 | **kwargs, 31 | ) -> B2Api: 32 | if raise_if_does_not_exist: 33 | account_info_file = SqliteAccountInfo.get_user_account_info_path(profile=profile) 34 | if not os.path.exists(account_info_file): 35 | raise MissingAccountData(account_info_file) 36 | 37 | account_info = SqliteAccountInfo(profile=profile) 38 | b2api = B2Api( 39 | api_config=_get_b2httpapiconfig(), 40 | account_info=account_info, 41 | cache=AuthInfoCache(account_info), 42 | **kwargs, 43 | ) 44 | 45 | if os.getenv('CI', False) and os.getenv( 46 | 'GITHUB_REPOSITORY', 47 | '', 48 | ).endswith('/B2_Command_Line_Tool'): 49 | b2http = b2api.session.raw_api.b2_http 50 | b2http.CONNECTION_TIMEOUT = 3 + 6 + 1 51 | b2http.TIMEOUT = 12 52 | b2http.TIMEOUT_FOR_COPY = 24 53 | b2http.TIMEOUT_FOR_UPLOAD = 24 54 | b2http.TRY_COUNT_DATA = 2 55 | b2http.TRY_COUNT_DOWNLOAD = 2 56 | b2http.TRY_COUNT_HEAD = 2 57 | b2http.TRY_COUNT_OTHER = 2 58 | return b2api 59 | 60 | 61 | def _get_inmemory_b2api(**kwargs) -> B2Api: 62 | return B2Api(InMemoryAccountInfo(), cache=InMemoryCache(), **kwargs) 63 | 64 | 65 | def _get_b2httpapiconfig(): 66 | return B2HttpApiConfig( 67 | user_agent_append=os.environ.get(B2_USER_AGENT_APPEND_ENV_VAR), 68 | ) 69 | -------------------------------------------------------------------------------- /b2/_internal/_cli/const.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/const.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # Optional Env variable to use for getting account info while authorizing 12 | B2_APPLICATION_KEY_ID_ENV_VAR = 'B2_APPLICATION_KEY_ID' 13 | B2_APPLICATION_KEY_ENV_VAR = 'B2_APPLICATION_KEY' 14 | 15 | # Optional Env variable to use for adding custom string to the User Agent 16 | B2_USER_AGENT_APPEND_ENV_VAR = 'B2_USER_AGENT_APPEND' 17 | B2_ENVIRONMENT_ENV_VAR = 'B2_ENVIRONMENT' 18 | B2_DESTINATION_SSE_C_KEY_B64_ENV_VAR = 'B2_DESTINATION_SSE_C_KEY_B64' 19 | B2_DESTINATION_SSE_C_KEY_ID_ENV_VAR = 'B2_DESTINATION_SSE_C_KEY_ID' 20 | B2_SOURCE_SSE_C_KEY_B64_ENV_VAR = 'B2_SOURCE_SSE_C_KEY_B64' 21 | 22 | # Threads defaults 23 | DEFAULT_THREADS = 10 24 | 25 | # Constants used in the B2 API 26 | CREATE_BUCKET_TYPES = ('allPublic', 'allPrivate') 27 | 28 | B2_ESCAPE_CONTROL_CHARACTERS = 'B2_ESCAPE_CONTROL_CHARACTERS' 29 | 30 | # Set to 1 when running under B2 CLI as a Docker container 31 | B2_CLI_DOCKER_ENV_VAR = 'B2_CLI_DOCKER' 32 | -------------------------------------------------------------------------------- /b2/_internal/_cli/obj_dumps.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/obj_dumps.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import io 11 | 12 | from b2sdk.v2 import ( 13 | unprintable_to_hex, 14 | ) 15 | 16 | _simple_repr_map = { 17 | False: 'false', 18 | None: 'null', 19 | True: 'true', 20 | } 21 | _simple_repr_map_values = set(_simple_repr_map.values()) | {'yes', 'no'} 22 | 23 | 24 | def _yaml_simple_repr(obj): 25 | """ 26 | Like YAML for simple types, but also escapes control characters for safety. 27 | """ 28 | if isinstance(obj, (int, float)) and not isinstance(obj, bool): 29 | return str(obj) 30 | simple_repr = _simple_repr_map.get(obj) 31 | if simple_repr: 32 | return simple_repr 33 | obj_repr = unprintable_to_hex(str(obj)) 34 | if isinstance(obj, str) and ( 35 | obj == '' or obj_repr.lower() in _simple_repr_map_values or obj_repr.isdigit() 36 | ): 37 | obj_repr = repr(obj) # add quotes to distinguish from numbers and booleans 38 | return obj_repr 39 | 40 | 41 | def _id_name_first_key(item): 42 | try: 43 | return ('id', 'name').index(str(item[0]).lower()), item[0], item[1] 44 | except ValueError: 45 | return 2, item[0], item[1] 46 | 47 | 48 | def _dump(data, indent=0, skip=False, *, output): 49 | prefix = ' ' * indent 50 | if isinstance(data, dict): 51 | for idx, (key, value) in enumerate(sorted(data.items(), key=_id_name_first_key)): 52 | output.write(f"{'' if skip and idx == 0 else prefix}{_yaml_simple_repr(key)}: ") 53 | if isinstance(value, (dict, list)): 54 | output.write('\n') 55 | _dump(value, indent + 2, output=output) 56 | else: 57 | _dump(value, 0, True, output=output) 58 | elif isinstance(data, list): 59 | for idx, item in enumerate(data): 60 | output.write(f"{'' if skip and idx == 0 else prefix}- ") 61 | _dump(item, indent + 2, True, output=output) 62 | else: 63 | output.write(f"{'' if skip else prefix}{_yaml_simple_repr(data)}\n") 64 | 65 | 66 | def readable_yaml_dump(data, output: io.TextIOBase) -> None: 67 | """ 68 | Print YAML-like human-readable representation of the data. 69 | 70 | :param data: The data to be printed. Can be a list, dict, or any basic datatype. 71 | :param output: An output stream derived from io.TextIOBase where the data is to be printed. 72 | """ 73 | _dump(data, output=output) 74 | -------------------------------------------------------------------------------- /b2/_internal/_cli/obj_loads.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/obj_loads.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import argparse 13 | import copy 14 | import io 15 | import json 16 | import logging 17 | import sys 18 | from typing import TypeVar 19 | 20 | from b2sdk.v2 import get_b2sdk_doc_urls 21 | 22 | try: 23 | import pydantic 24 | from pydantic import TypeAdapter, ValidationError 25 | 26 | if sys.version_info < (3, 10): 27 | raise ImportError('pydantic integration is not supported on python<3.10') 28 | # we could support it partially with help of https://github.com/pydantic/pydantic/issues/7873 29 | # but that creates yet another edge case, on old version of Python 30 | except ImportError: 31 | pydantic = None 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | def convert_error_to_human_readable(validation_exc: ValidationError) -> str: 37 | buf = io.StringIO() 38 | for error in validation_exc.errors(): 39 | loc = '.'.join(str(loc) for loc in error['loc']) 40 | buf.write(f' In field {loc!r} input was `{error["input"]!r}`, error: {error["msg"]}\n') 41 | return buf.getvalue() 42 | 43 | 44 | def describe_type(type_) -> str: 45 | urls = get_b2sdk_doc_urls(type_) 46 | if urls: 47 | url_links = ', '.join(f'{name} <{url}>' for name, url in urls.items()) 48 | return f'{type_.__name__} ({url_links})' 49 | return type_.__name__ 50 | 51 | 52 | T = TypeVar('T') 53 | 54 | _UNDEF = object() 55 | 56 | 57 | def type_with_config(type_: type[T], config: pydantic.ConfigDict) -> type[T]: 58 | type_ = copy.copy(type_) 59 | if not hasattr(type_, '__config__'): 60 | type_.__pydantic_config__ = config 61 | else: 62 | type_.__config__ = type_.__config__.copy() 63 | type_.__config__.update(config) 64 | return type_ 65 | 66 | 67 | def validated_loads(data: str, expected_type: type[T] | None = None) -> T: 68 | val = _UNDEF 69 | if expected_type is not None and pydantic is not None: 70 | expected_type = type_with_config(expected_type, pydantic.ConfigDict(extra='allow')) 71 | try: 72 | ta = TypeAdapter(expected_type) 73 | except TypeError: 74 | # TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' 75 | # This is thrown on python<3.10 even with eval_type_backport 76 | logger.debug( 77 | f'Failed to create TypeAdapter for {expected_type!r} using pydantic, falling back to json.loads', 78 | exc_info=True, 79 | ) 80 | val = _UNDEF 81 | else: 82 | try: 83 | val = ta.validate_json(data) 84 | except ValidationError as e: 85 | errors = convert_error_to_human_readable(e) 86 | raise argparse.ArgumentTypeError( 87 | f'Invalid value inputted, expected {describe_type(expected_type)}, got {data!r}, more detail below:\n{errors}' 88 | ) from e 89 | 90 | if val is _UNDEF: 91 | try: 92 | val = json.loads(data) 93 | except json.JSONDecodeError as e: 94 | raise argparse.ArgumentTypeError(f'{data!r} is not a valid JSON value') from e 95 | return val 96 | -------------------------------------------------------------------------------- /b2/_internal/_cli/shell.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_cli/shell.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import os 12 | import os.path 13 | import shutil 14 | from typing import Optional 15 | 16 | 17 | def detect_shell() -> Optional[str]: 18 | """Detect the shell we are running in.""" 19 | shell_var = os.environ.get('SHELL') 20 | if shell_var: 21 | return os.path.basename(shell_var) 22 | return None 23 | 24 | 25 | def resolve_short_call_name(binary_path: str) -> str: 26 | """ 27 | Resolve the short name of the binary. 28 | 29 | If binary is in PATH, return only basename, otherwise return a full path. 30 | This method is to be used with sys.argv[0] to resolve handy name for the user instead of full path. 31 | """ 32 | if shutil.which(binary_path) == binary_path: 33 | return os.path.basename(binary_path) 34 | return binary_path 35 | -------------------------------------------------------------------------------- /b2/_internal/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_utils/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /b2/_internal/_utils/python_compat.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_utils/python_compat.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | """ 11 | Utilities for compatibility with older Python versions. 12 | """ 13 | 14 | import sys 15 | 16 | if sys.version_info < (3, 9): 17 | 18 | def removeprefix(s: str, prefix: str) -> str: 19 | return s[len(prefix) :] if s.startswith(prefix) else s 20 | 21 | else: 22 | removeprefix = str.removeprefix 23 | -------------------------------------------------------------------------------- /b2/_internal/_utils/uri.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/_utils/uri.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import dataclasses 13 | import pathlib 14 | import urllib.parse 15 | from functools import singledispatchmethod 16 | from pathlib import Path 17 | from typing import Sequence 18 | 19 | from b2sdk.v2 import ( 20 | B2Api, 21 | DownloadVersion, 22 | FileVersion, 23 | Filter, 24 | ) 25 | from b2sdk.v2.exception import B2Error 26 | 27 | from b2._internal._utils.python_compat import removeprefix 28 | 29 | 30 | class B2URIBase: 31 | pass 32 | 33 | 34 | @dataclasses.dataclass(frozen=True) 35 | class B2URI(B2URIBase): 36 | """ 37 | B2 URI designating a particular object by name & bucket or "subdirectory" in a bucket. 38 | 39 | Please note, both files and directories are symbolical concept, not a real one in B2, i.e. 40 | there is no such thing as "directory" in B2, but it is possible to mimic it by using object names with non-trailing 41 | slashes. 42 | To make it possible, it is highly discouraged to use trailing slashes in object names. 43 | 44 | Please note `path` attribute should exclude prefixing slash, i.e. `path` should be empty string for the root of the bucket. 45 | """ 46 | 47 | bucket_name: str 48 | path: str = '' 49 | 50 | def __str__(self) -> str: 51 | return f'b2://{self.bucket_name}/{self.path}' 52 | 53 | def is_dir(self) -> bool | None: 54 | """ 55 | Return if the path is a directory. 56 | 57 | Please note this is symbolical. 58 | It is possible for file to have a trailing slash, but it is HIGHLY discouraged, and not supported by B2 CLI. 59 | At the same time it is possible for a directory to not have a trailing slash, 60 | which is discouraged, but allowed by B2 CLI. 61 | This is done to mimic unix-like Path. 62 | 63 | In practice, this means that `.is_dir() == True` will always be interpreted as "this is a directory", 64 | but reverse is not necessary true, and `not uri.is_dir()` should be merely interpreted as 65 | "this is a directory or a file". 66 | 67 | :return: True if the path is a directory, None if it is unknown 68 | """ 69 | return not self.path or self.path.endswith('/') or None 70 | 71 | 72 | @dataclasses.dataclass(frozen=True) 73 | class B2FileIdURI(B2URIBase): 74 | """ 75 | B2 URI designating a particular file by its id. 76 | """ 77 | 78 | file_id: str 79 | 80 | def __str__(self) -> str: 81 | return f'b2id://{self.file_id}' 82 | 83 | 84 | def parse_uri(uri: str, *, allow_all_buckets: bool = False) -> Path | B2URI | B2FileIdURI: 85 | """ 86 | Parse URI. 87 | 88 | :param uri: string to parse 89 | :param allow_all_buckets: if True, allow `b2://` without a bucket name to refer to all buckets 90 | :return: B2 URI or Path 91 | :raises ValueError: if the URI is invalid 92 | """ 93 | if not uri: 94 | raise ValueError('URI cannot be empty') 95 | parsed = urllib.parse.urlsplit(uri) 96 | if parsed.scheme == '': 97 | return pathlib.Path(uri) 98 | return _parse_b2_uri(uri, parsed, allow_all_buckets=allow_all_buckets) 99 | 100 | 101 | def parse_b2_uri( 102 | uri: str, *, allow_all_buckets: bool = False, allow_b2id: bool = True 103 | ) -> B2URI | B2FileIdURI: 104 | """ 105 | Parse B2 URI. 106 | 107 | :param uri: string to parse 108 | :param allow_all_buckets: if True, allow `b2://` without a bucket name to refer to all buckets 109 | :param allow_b2id: if True, allow `b2id://` to refer to a file by its id 110 | :return: B2 URI 111 | :raises ValueError: if the URI is invalid 112 | """ 113 | parsed = urllib.parse.urlsplit(uri) 114 | return _parse_b2_uri(uri, parsed, allow_all_buckets=allow_all_buckets, allow_b2id=allow_b2id) 115 | 116 | 117 | def _parse_b2_uri( 118 | uri, 119 | parsed: urllib.parse.SplitResult, 120 | *, 121 | allow_all_buckets: bool = False, 122 | allow_b2id: bool = True, 123 | ) -> B2URI | B2FileIdURI: 124 | if parsed.scheme in ('b2', 'b2id'): 125 | path = urllib.parse.urlunsplit(parsed._replace(scheme='', netloc='')) 126 | if not parsed.netloc: 127 | if allow_all_buckets: 128 | if path: 129 | raise ValueError( 130 | f"Invalid B2 URI: all buckets URI doesn't allow non-empty path, but {path!r} was provided" 131 | ) 132 | return B2URI(bucket_name='') 133 | raise ValueError(f'Invalid B2 URI: {uri!r}') 134 | elif parsed.password or parsed.username: 135 | raise ValueError( 136 | 'Invalid B2 URI: credentials passed using `user@password:` syntax is not supported in URI' 137 | ) 138 | 139 | if parsed.scheme == 'b2': 140 | return B2URI(bucket_name=parsed.netloc, path=removeprefix(path, '/')) 141 | elif parsed.scheme == 'b2id' and allow_b2id: 142 | return B2FileIdURI(file_id=parsed.netloc) 143 | else: 144 | raise ValueError(f'Unsupported URI scheme: {parsed.scheme!r}') 145 | 146 | 147 | class B2URIAdapter: 148 | """ 149 | Adapter for using B2URI with B2Api. 150 | 151 | When this matures enough methods from here should be moved to b2sdk B2Api class. 152 | """ 153 | 154 | def __init__(self, api: B2Api): 155 | self.api = api 156 | 157 | def __getattr__(self, name): 158 | return getattr(self.api, name) 159 | 160 | @singledispatchmethod 161 | def download_file_by_uri(self, uri, *args, **kwargs): 162 | raise NotImplementedError(f'Unsupported URI type: {type(uri)}') 163 | 164 | @download_file_by_uri.register 165 | def _(self, uri: B2URI, *args, **kwargs): 166 | bucket = self.get_bucket_by_name(uri.bucket_name) 167 | return bucket.download_file_by_name(uri.path, *args, **kwargs) 168 | 169 | @download_file_by_uri.register 170 | def _(self, uri: B2FileIdURI, *args, **kwargs): 171 | return self.download_file_by_id(uri.file_id, *args, **kwargs) 172 | 173 | @singledispatchmethod 174 | def get_file_info_by_uri(self, uri, *args, **kwargs): 175 | raise NotImplementedError(f'Unsupported URI type: {type(uri)}') 176 | 177 | @get_file_info_by_uri.register 178 | def _(self, uri: B2URI, *args, **kwargs) -> DownloadVersion: 179 | return self.get_file_info_by_name(uri.bucket_name, uri.path, *args, **kwargs) 180 | 181 | @get_file_info_by_uri.register 182 | def _(self, uri: B2FileIdURI, *args, **kwargs) -> FileVersion: 183 | return self.get_file_info(uri.file_id, *args, **kwargs) 184 | 185 | @singledispatchmethod 186 | def get_download_url_by_uri(self, uri, *args, **kwargs): 187 | raise NotImplementedError(f'Unsupported URI type: {type(uri)}') 188 | 189 | @get_download_url_by_uri.register 190 | def _(self, uri: B2URI, *args, **kwargs) -> str: 191 | return self.get_download_url_for_file_name(uri.bucket_name, uri.path, *args, **kwargs) 192 | 193 | @get_download_url_by_uri.register 194 | def _(self, uri: B2FileIdURI, *args, **kwargs) -> str: 195 | return self.get_download_url_for_fileid(uri.file_id, *args, **kwargs) 196 | 197 | @singledispatchmethod 198 | def ls(self, uri, *args, **kwargs): 199 | raise NotImplementedError(f'Unsupported URI type: {type(uri)}') 200 | 201 | @ls.register 202 | def _(self, uri: B2URI, *args, filters: Sequence[Filter] = (), **kwargs): 203 | bucket = self.api.get_bucket_by_name(uri.bucket_name) 204 | try: 205 | yield from bucket.ls(uri.path, *args, filters=filters, **kwargs) 206 | except ValueError as error: 207 | # Wrap these errors into B2Error. At the time of writing there's 208 | # exactly one – `with_wildcard` being passed without `recursive` option. 209 | raise B2Error(error.args[0]) 210 | 211 | @ls.register 212 | def _(self, uri: B2FileIdURI, *args, **kwargs): 213 | yield self.get_file_info_by_uri(uri), None 214 | 215 | @singledispatchmethod 216 | def copy_by_uri(self, uri, *args, **kwargs): 217 | raise NotImplementedError(f'Unsupported URI type: {type(uri)}') 218 | 219 | @copy_by_uri.register 220 | def _(self, source: B2FileIdURI, destination: B2URI, *args, **kwargs): 221 | destination_bucket = self.get_bucket_by_name(destination.bucket_name) 222 | return destination_bucket.copy(source.file_id, destination.path, *args, **kwargs) 223 | 224 | @copy_by_uri.register 225 | def _(self, source: B2URI, destination: B2URI, *args, **kwargs): 226 | file_info = self.get_file_info_by_uri(source) 227 | return self.copy_by_uri(B2FileIdURI(file_info.id_), destination, *args, **kwargs) 228 | -------------------------------------------------------------------------------- /b2/_internal/b2v3/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v3/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # Note: importing console_tool in any shape or form in here will break sys.argv. 12 | -------------------------------------------------------------------------------- /b2/_internal/b2v3/__main__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v3/__main__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from b2._internal.b2v3.registry import main 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /b2/_internal/b2v3/registry.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v3/registry.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # ruff: noqa: F405 12 | from b2._internal.b2v4.registry import * # noqa 13 | from b2._internal._cli.b2api import _get_b2api_for_profile 14 | from b2._internal.arg_parser import enable_camel_case_arguments 15 | from .rm import Rm, B2URIMustPointToFolderMixin 16 | from .sync import Sync 17 | 18 | enable_camel_case_arguments() 19 | 20 | 21 | class ConsoleTool(ConsoleTool): 22 | # same as original console tool, but does not use InMemoryAccountInfo and InMemoryCache 23 | # when auth env vars are used 24 | 25 | @classmethod 26 | def _initialize_b2_api(cls, args: argparse.Namespace, kwargs: dict) -> B2Api: 27 | return _get_b2api_for_profile(profile=args.profile, **kwargs) 28 | 29 | 30 | def main() -> None: 31 | # this is a copy of v4 `main()` but with custom console tool class 32 | 33 | ct = ConsoleTool(stdout=sys.stdout, stderr=sys.stderr) 34 | exit_status = ct.run_command(sys.argv) 35 | logger.info('\\\\ %s %s %s //', SEPARATOR, ('exit=%s' % exit_status).center(8), SEPARATOR) 36 | 37 | # I haven't tracked down the root cause yet, but in Python 2.7, the futures 38 | # packages is hanging on exit sometimes, waiting for a thread to finish. 39 | # This happens when using sync to upload files. 40 | sys.stdout.flush() 41 | sys.stderr.flush() 42 | 43 | logging.shutdown() 44 | 45 | os._exit(exit_status) 46 | 47 | 48 | class Ls(B2URIMustPointToFolderMixin, B2URIBucketNFolderNameArgMixin, BaseLs): 49 | """ 50 | {BaseLs} 51 | 52 | Examples 53 | 54 | .. note:: 55 | 56 | Note the use of quotes, to ensure that special 57 | characters are not expanded by the shell. 58 | 59 | 60 | List csv and tsv files (in any directory, in the whole bucket): 61 | 62 | .. code-block:: 63 | 64 | {NAME} ls --recursive --withWildcard bucketName "*.[ct]sv" 65 | 66 | 67 | List all info.txt files from directories `b?`, where `?` is any character: 68 | 69 | .. code-block:: 70 | 71 | {NAME} ls --recursive --withWildcard bucketName "b?/info.txt" 72 | 73 | 74 | List all pdf files from directories b0 to b9 (including sub-directories): 75 | 76 | .. code-block:: 77 | 78 | {NAME} ls --recursive --withWildcard bucketName "b[0-9]/*.pdf" 79 | 80 | 81 | List all buckets: 82 | 83 | .. code-block:: 84 | 85 | {NAME} ls 86 | 87 | Requires capability: 88 | 89 | - **listFiles** 90 | - **listBuckets** (if bucket name is not provided) 91 | """ 92 | 93 | ALLOW_ALL_BUCKETS = True 94 | 95 | 96 | class HyphenFilenameMixin: 97 | def get_input_stream(self, filename): 98 | if filename == '-' and os.path.exists('-'): 99 | self._print_stderr( 100 | "WARNING: Filename `-` won't be supported in the future and will always be treated as stdin alias." 101 | ) 102 | return '-' 103 | return super().get_input_stream(filename) 104 | 105 | 106 | class UploadUnboundStream(HyphenFilenameMixin, UploadUnboundStream): 107 | __doc__ = UploadUnboundStream.__doc__ 108 | 109 | 110 | class UploadFile(HyphenFilenameMixin, UploadFile): 111 | __doc__ = UploadFile.__doc__ 112 | 113 | 114 | B2.register_subcommand(AuthorizeAccount) 115 | B2.register_subcommand(CancelAllUnfinishedLargeFiles) 116 | B2.register_subcommand(CancelLargeFile) 117 | B2.register_subcommand(ClearAccount) 118 | B2.register_subcommand(CopyFileById) 119 | B2.register_subcommand(CreateBucket) 120 | B2.register_subcommand(CreateKey) 121 | B2.register_subcommand(DeleteBucket) 122 | B2.register_subcommand(DeleteFileVersion) 123 | B2.register_subcommand(DeleteKey) 124 | B2.register_subcommand(DownloadFile) 125 | B2.register_subcommand(DownloadFileById) 126 | B2.register_subcommand(DownloadFileByName) 127 | B2.register_subcommand(Cat) 128 | B2.register_subcommand(GetAccountInfo) 129 | B2.register_subcommand(GetBucket) 130 | B2.register_subcommand(FileInfo2) 131 | B2.register_subcommand(GetFileInfo) 132 | B2.register_subcommand(GetDownloadAuth) 133 | B2.register_subcommand(GetDownloadUrlWithAuth) 134 | B2.register_subcommand(HideFile) 135 | B2.register_subcommand(ListBuckets) 136 | B2.register_subcommand(ListKeys) 137 | B2.register_subcommand(ListParts) 138 | B2.register_subcommand(ListUnfinishedLargeFiles) 139 | B2.register_subcommand(Ls) 140 | B2.register_subcommand(Rm) 141 | B2.register_subcommand(GetUrl) 142 | B2.register_subcommand(MakeUrl) 143 | B2.register_subcommand(MakeFriendlyUrl) 144 | B2.register_subcommand(Sync) 145 | B2.register_subcommand(UpdateBucket) 146 | B2.register_subcommand(UploadFile) 147 | B2.register_subcommand(UploadUnboundStream) 148 | B2.register_subcommand(UpdateFileLegalHold) 149 | B2.register_subcommand(UpdateFileRetention) 150 | B2.register_subcommand(ReplicationSetup) 151 | B2.register_subcommand(ReplicationDelete) 152 | B2.register_subcommand(ReplicationPause) 153 | B2.register_subcommand(ReplicationUnpause) 154 | B2.register_subcommand(ReplicationStatus) 155 | B2.register_subcommand(Version) 156 | B2.register_subcommand(License) 157 | B2.register_subcommand(InstallAutocomplete) 158 | B2.register_subcommand(NotificationRules) 159 | B2.register_subcommand(Key) 160 | B2.register_subcommand(Replication) 161 | B2.register_subcommand(Account) 162 | B2.register_subcommand(BucketCmd) 163 | B2.register_subcommand(File) 164 | -------------------------------------------------------------------------------- /b2/_internal/b2v3/rm.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v3/rm.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import dataclasses 13 | import typing 14 | 15 | from b2._internal.b2v4.registry import B2URIBucketNFolderNameArgMixin, BaseRm 16 | 17 | if typing.TYPE_CHECKING: 18 | import argparse 19 | 20 | from b2._internal._utils.uri import B2URI 21 | 22 | 23 | class B2URIMustPointToFolderMixin: 24 | """ 25 | Extension to B2URI*Mixins to ensure that the b2:// URIs point to a folder. 26 | 27 | This is directly related to how b2sdk.v3.Bucket.ls() treats paths ending with a slash as folders, where as 28 | paths not ending with a slash are treated as potential files. 29 | 30 | For b2v3 we need to support old behavior which never attempted to treat the path as a file. 31 | """ 32 | 33 | def get_b2_uri_from_arg(self, args: argparse.Namespace) -> B2URI: 34 | b2_uri = super().get_b2_uri_from_arg(args) 35 | if b2_uri.path and not args.with_wildcard and not b2_uri.path.endswith('/'): 36 | b2_uri = dataclasses.replace(b2_uri, path=b2_uri.path + '/') 37 | return b2_uri 38 | 39 | 40 | # NOTE: We need to keep v3 Rm in separate file, because we need to import it in 41 | # unit tests without registering any commands. 42 | class Rm(B2URIMustPointToFolderMixin, B2URIBucketNFolderNameArgMixin, BaseRm): 43 | """ 44 | {BaseRm} 45 | 46 | Examples. 47 | 48 | .. note:: 49 | 50 | Note the use of quotes, to ensure that special 51 | characters are not expanded by the shell. 52 | 53 | 54 | .. note:: 55 | 56 | Use with caution. Running examples presented below can cause data-loss. 57 | 58 | 59 | Remove all csv and tsv files (in any directory, in the whole bucket): 60 | 61 | .. code-block:: 62 | 63 | {NAME} rm --recursive --withWildcard bucketName "*.[ct]sv" 64 | 65 | 66 | Remove all info.txt files from buckets bX, where X is any character: 67 | 68 | .. code-block:: 69 | 70 | {NAME} rm --recursive --withWildcard bucketName "b?/info.txt" 71 | 72 | 73 | Remove all pdf files from buckets b0 to b9 (including sub-directories): 74 | 75 | .. code-block:: 76 | 77 | {NAME} rm --recursive --withWildcard bucketName "b[0-9]/*.pdf" 78 | 79 | 80 | Requires capability: 81 | 82 | - **listFiles** 83 | - **deleteFiles** 84 | - **bypassGovernance** (if --bypass-governance is used) 85 | """ 86 | -------------------------------------------------------------------------------- /b2/_internal/b2v3/sync.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v3/sync.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from b2._internal.b2v4.registry import Sync as SyncV4 12 | 13 | 14 | class Sync(SyncV4): 15 | __doc__ = SyncV4.__doc__ 16 | FAIL_ON_REPORTER_ERRORS_OR_WARNINGS = False 17 | -------------------------------------------------------------------------------- /b2/_internal/b2v4/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v4/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # Note: importing console_tool in any shape or form in here will break sys.argv. 12 | -------------------------------------------------------------------------------- /b2/_internal/b2v4/__main__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v4/__main__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from b2._internal.b2v4.registry import main 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /b2/_internal/b2v4/registry.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/b2v4/registry.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # ruff: noqa: F405 12 | from b2._internal.console_tool import * # noqa 13 | 14 | B2.register_subcommand(AuthorizeAccount) 15 | B2.register_subcommand(CancelAllUnfinishedLargeFiles) 16 | B2.register_subcommand(CancelLargeFile) 17 | B2.register_subcommand(ClearAccount) 18 | B2.register_subcommand(CopyFileById) 19 | B2.register_subcommand(CreateBucket) 20 | B2.register_subcommand(CreateKey) 21 | B2.register_subcommand(DeleteBucket) 22 | B2.register_subcommand(DeleteFileVersion) 23 | B2.register_subcommand(DeleteKey) 24 | B2.register_subcommand(DownloadFile) 25 | B2.register_subcommand(DownloadFileById) 26 | B2.register_subcommand(DownloadFileByName) 27 | B2.register_subcommand(Cat) 28 | B2.register_subcommand(GetAccountInfo) 29 | B2.register_subcommand(GetBucket) 30 | B2.register_subcommand(FileInfo2) 31 | B2.register_subcommand(GetFileInfo) 32 | B2.register_subcommand(GetDownloadAuth) 33 | B2.register_subcommand(GetDownloadUrlWithAuth) 34 | B2.register_subcommand(HideFile) 35 | B2.register_subcommand(ListBuckets) 36 | B2.register_subcommand(ListKeys) 37 | B2.register_subcommand(ListParts) 38 | B2.register_subcommand(ListUnfinishedLargeFiles) 39 | B2.register_subcommand(Ls) 40 | B2.register_subcommand(Rm) 41 | B2.register_subcommand(GetUrl) 42 | B2.register_subcommand(MakeUrl) 43 | B2.register_subcommand(MakeFriendlyUrl) 44 | B2.register_subcommand(Sync) 45 | B2.register_subcommand(UpdateBucket) 46 | B2.register_subcommand(UploadFile) 47 | B2.register_subcommand(UploadUnboundStream) 48 | B2.register_subcommand(UpdateFileLegalHold) 49 | B2.register_subcommand(UpdateFileRetention) 50 | B2.register_subcommand(ReplicationSetup) 51 | B2.register_subcommand(ReplicationDelete) 52 | B2.register_subcommand(ReplicationPause) 53 | B2.register_subcommand(ReplicationUnpause) 54 | B2.register_subcommand(ReplicationStatus) 55 | B2.register_subcommand(Version) 56 | B2.register_subcommand(License) 57 | B2.register_subcommand(InstallAutocomplete) 58 | B2.register_subcommand(NotificationRules) 59 | B2.register_subcommand(Key) 60 | B2.register_subcommand(Replication) 61 | B2.register_subcommand(Account) 62 | B2.register_subcommand(BucketCmd) 63 | B2.register_subcommand(File) 64 | -------------------------------------------------------------------------------- /b2/_internal/json_encoder.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/json_encoder.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import json 12 | from enum import Enum 13 | 14 | from b2sdk.v2 import Bucket, DownloadVersion, FileIdAndName, FileVersion 15 | 16 | 17 | class B2CliJsonEncoder(json.JSONEncoder): 18 | """ 19 | Makes it possible to serialize b2sdk objects 20 | (specifically bucket['options'] set and FileVersionInfo/FileIdAndName) to json. 21 | 22 | >>> json.dumps(set([1,2,3,'a','b','c']), cls=json_encoder.B2CliJsonEncoder) 23 | '[1, 2, 3, "c", "b", "a"]' 24 | >>> 25 | """ 26 | 27 | def default(self, obj): 28 | if isinstance(obj, set): 29 | return list(obj) 30 | elif isinstance(obj, (DownloadVersion, FileVersion, FileIdAndName, Bucket)): 31 | return obj.as_dict() 32 | elif isinstance(obj, Enum): 33 | return obj.value 34 | return super().default(obj) 35 | -------------------------------------------------------------------------------- /b2/_internal/version.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/version.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | try: 12 | from importlib.metadata import version 13 | except ModuleNotFoundError: 14 | from importlib_metadata import version 15 | 16 | VERSION = version('b2') 17 | -------------------------------------------------------------------------------- /b2/_internal/version_listing.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: b2/_internal/version_listing.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import pathlib 12 | import re 13 | from typing import List 14 | 15 | RE_VERSION = re.compile(r'[_]*b2v(\d+)') 16 | 17 | 18 | def get_versions() -> List[str]: 19 | return [path.name for path in sorted(pathlib.Path(__file__).parent.glob('*b2v*'))] 20 | 21 | 22 | def get_int_version(version: str) -> int: 23 | match = RE_VERSION.match(version) 24 | assert match, f'Version {version} does not match pattern {RE_VERSION.pattern}' 25 | return int(match.group(1)) 26 | 27 | 28 | CLI_VERSIONS = get_versions() 29 | UNSTABLE_CLI_VERSION = max(CLI_VERSIONS, key=get_int_version) 30 | LATEST_STABLE_VERSION = max( 31 | [elem for elem in CLI_VERSIONS if not elem.startswith('_')], key=get_int_version 32 | ) 33 | -------------------------------------------------------------------------------- /changelog.d/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Backblaze/B2_Command_Line_Tool/601e7f38d62b374e047e5364fb22d86799bd84a5/changelog.d/.gitkeep -------------------------------------------------------------------------------- /contrib/color-b2-logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | awk -F '\t' '{print $1 " " $4 " " $5 " " $6}' | colorex --green=DEBUG \ 3 | --bgreen=INFO \ 4 | --bred=ERROR \ 5 | --byellow=WARNING \ 6 | --bmagenta='calling [\w\.]+' \ 7 | --bblue='INFO // =+ [0-9\.]+ =+ \\' \ 8 | --bblue='INFO // =+ [0-9\.]+ =+ \\' \ 9 | --bblue='starting command .* with arguments:' \ 10 | --bblue='starting command .* \(arguments hidden\)' \ 11 | --red=Traceback \ 12 | --green='\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d,\d\d\d' \ 13 | --cyan='b2\.sync' 14 | -------------------------------------------------------------------------------- /contrib/debug_logs.ini: -------------------------------------------------------------------------------- 1 | ############################################################ 2 | [loggers] 3 | keys=root,b2 4 | 5 | [logger_root] 6 | level=DEBUG 7 | handlers=fileHandler 8 | 9 | [logger_b2] 10 | level=DEBUG 11 | handlers=fileHandler 12 | qualname=b2 13 | propagate=0 14 | 15 | 16 | 17 | ############################################################ 18 | [handlers] 19 | keys=fileHandler 20 | 21 | [handler_fileHandler] 22 | class=logging.handlers.TimedRotatingFileHandler 23 | level=DEBUG 24 | formatter=simpleFormatter 25 | args=('b2_cli.log', 'midnight') 26 | 27 | 28 | 29 | ############################################################ 30 | [formatters] 31 | keys=simpleFormatter 32 | 33 | [formatter_simpleFormatter] 34 | format=%(asctime)s %(process)d %(thread)d %(name)s %(levelname)s %(message)s 35 | datefmt= 36 | 37 | 38 | 39 | ############################################################ 40 | -------------------------------------------------------------------------------- /contrib/macos/entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.disable-library-validation 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /doc/bash_completion.md: -------------------------------------------------------------------------------- 1 | Install bash completion by running: 2 | ```sh 3 | b2 install-autocomplete 4 | ``` 5 | 6 | For support of other shells see https://pypi.org/project/argcomplete/#activating-global-completion . 7 | -------------------------------------------------------------------------------- /doc/source/commands.rst: -------------------------------------------------------------------------------- 1 | ######################################### 2 | Commands 3 | ######################################### 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :glob: 8 | 9 | subcommands/* 10 | 11 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. note:: **Event Notifications** feature is now in **Private Preview**. See https://www.backblaze.com/blog/announcing-event-notifications/ for details. 2 | 3 | ######################################### 4 | Overview 5 | ######################################### 6 | 7 | .. include:: main_help.rst 8 | 9 | ######################################### 10 | Documentation index 11 | ######################################### 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :glob: 16 | 17 | quick_start 18 | 19 | commands 20 | 21 | replication 22 | 23 | ######################################### 24 | Indices and tables 25 | ######################################### 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /doc/source/quick_start.rst: -------------------------------------------------------------------------------- 1 | .. _quick_start: 2 | 3 | ######################## 4 | Quick Start Guide 5 | ######################## 6 | 7 | .. _prepare_b2cli: 8 | 9 | *********************** 10 | Prepare B2 cli 11 | *********************** 12 | 13 | .. code-block:: sh 14 | 15 | $ b2 account authorize 4ab123456789 001aabbccddeeff123456789012345678901234567 16 | Using https://api.backblazeb2.com 17 | 18 | .. tip:: 19 | Get credentials from `B2 website `_ 20 | 21 | .. warning:: 22 | Local users might be able to access your process list and read command arguments. To avoid exposing credentials, 23 | you can provide application key ID and application key using environment variables ``B2_APPLICATION_KEY_ID`` and ``B2_APPLICATION_KEY`` respectively. 24 | Those will be picked up automatically, so after defining those you'll just need to run ``b2 account authorize`` with no extra parameters. 25 | 26 | .. code-block:: sh 27 | 28 | $ export B2_APPLICATION_KEY_ID="$(`. 8 | 9 | *********************** 10 | Automatic setup 11 | *********************** 12 | 13 | Setup replication 14 | ================= 15 | 16 | .. code-block:: sh 17 | 18 | $ b2 replication setup --destination-profile myprofile2 my-bucket my-bucket2 19 | 20 | You can optionally choose source rule priority and source rule name. See :ref:`replication setup command `. 21 | 22 | .. note:: 23 | ``replication setup`` will reuse or provision a source key with no prefix and full reading capabilities and a destination key with no prefix and full writing capabilities 24 | 25 | .. _replication_manual_setup: 26 | 27 | *************** 28 | Manual setup 29 | *************** 30 | 31 | Setup source key 32 | ================ 33 | 34 | .. code-block:: sh 35 | 36 | $ b2 key create my-bucket-rplsrc readFiles,readFileLegalHolds,readFileRetentions 37 | 0014ab1234567890000000123 K001ZA12345678901234567890ABCDE 38 | 39 | 40 | Setup source replication 41 | ======================== 42 | 43 | .. code-block:: sh 44 | 45 | $ b2 bucket update --replication '{ 46 | "asReplicationSource": { 47 | "replicationRules": [ 48 | { 49 | "destinationBucketId": "85644d98debc657d880b0e1e", 50 | "fileNamePrefix": "files-to-share/", 51 | "includeExistingFiles": false, 52 | "isEnabled": true, 53 | "priority": 128, 54 | "replicationRuleName": "my-replication-rule-name" 55 | } 56 | ], 57 | "sourceApplicationKeyId": "0014ab1234567890000000123" 58 | } 59 | }' my-bucket 60 | 61 | 62 | Setup destination key 63 | ===================== 64 | 65 | .. code-block:: sh 66 | 67 | $ b2 key create --profile myprofile2 my-bucket-rpldst writeFiles,writeFileLegalHolds,writeFileRetentions,deleteFiles 68 | 0024ab2345678900000000234 K001YYABCDE12345678901234567890 69 | 70 | 71 | Setup destination replication 72 | ============================= 73 | 74 | .. code-block:: sh 75 | 76 | $ b2 bucket update --profile myprofile2 --replication '{ 77 | "asReplicationDestination": { 78 | "sourceToDestinationKeyMapping": { 79 | "0014ab1234567890000000123": "0024ab2345678900000000234" 80 | } 81 | } 82 | }' my-bucket 83 | -------------------------------------------------------------------------------- /docker/Dockerfile.template: -------------------------------------------------------------------------------- 1 | FROM python:${python_version}-slim as builder 2 | 3 | RUN apt-get update -y && apt-get install git patchelf -y && pip install -U pdm 4 | 5 | WORKDIR /b2 6 | COPY ./b2 /b2/b2 7 | COPY pyproject.toml pdm.lock LICENSE README.md /b2/ 8 | 9 | ENV PDM_BUILD_SCM_VERSION=${version} 10 | RUN pdm install --prod --group license 11 | RUN pdm run b2 license --dump --with-packages 12 | # Run pdm in PEP 582 mode, install packaged to __pypackages__, not virtualenv 13 | RUN rm -r .venv && mkdir __pypackages__ && pdm install --prod --group full --no-editable 14 | 15 | FROM python:${python_version}-slim 16 | 17 | LABEL vendor=${vendor} 18 | LABEL name="${name}" 19 | LABEL description="${description}" 20 | LABEL version="${version}" 21 | LABEL url="${url}" 22 | LABEL vcs-url="${vcs_url}" 23 | LABEL vcs-ref="${vcs_ref}" 24 | LABEL build-date-iso8601="${build_date}" 25 | 26 | ENV B2_CLI_DOCKER=1 27 | ENV PYTHONPATH=/opt/b2 28 | COPY ./docker/entrypoint.sh /entrypoint.sh 29 | COPY --from=builder /b2/__pypackages__/${python_version}/lib /opt/b2 30 | COPY --from=builder /b2/__pypackages__/${python_version}/bin/* /bin/ 31 | 32 | WORKDIR /root 33 | ENTRYPOINT ["/entrypoint.sh"] 34 | CMD ["--help"] 35 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | set -euo pipefail 3 | 4 | if [[ "$1" =~ ^_?b2(v[0-9]+)?$ ]]; then 5 | B2_COMMAND="$1" 6 | shift 7 | else 8 | B2_COMMAND="b2" 9 | fi 10 | 11 | exec "$B2_COMMAND" "$@" 12 | -------------------------------------------------------------------------------- /pyinstaller-hooks/hook-b2.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: pyinstaller-hooks/hook-b2.py 4 | # 5 | # Copyright 2022 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from pathlib import Path 12 | 13 | license_file = Path('b2/licenses_output.txt') 14 | assert license_file.exists() 15 | datas = [ 16 | # When '.' was provided here, the license file was copied to the root of the executable. 17 | # Before ApiVer, it pasted the file to the `b2/` directory. 18 | # I have no idea why it worked before or how it works now. 19 | # If you mean to debug it in the future, know that `pyinstaller` provides a special 20 | # attribute in the `sys` module whenever it runs. 21 | # 22 | # Example: 23 | # import sys 24 | # if hasattr(sys, '_MEIPASS'): 25 | # self._print(f'{NAME}') 26 | # self._print(f'{sys._MEIPASS}') 27 | # elems = [elem for elem in pathlib.Path(sys._MEIPASS).glob('**/*')] 28 | # self._print(f'{elems}') 29 | # 30 | # If used at the very start of the `_run` of `Licenses` command, it will print 31 | # all the files that were unpacked from the executable. 32 | (str(license_file), 'b2/'), 33 | ] 34 | -------------------------------------------------------------------------------- /pyinstaller-hooks/hook-prettytable.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: pyinstaller-hooks/hook-prettytable.py 4 | # 5 | # Copyright 2022 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from PyInstaller.utils.hooks import collect_all 12 | 13 | # prettytable is excluded because `prettytable` module in provided by `PTable` package; 14 | # pyinstaller fails to resolve this, thus we do it manually here 15 | excludedimports = ['prettytable'] 16 | datas, binaries, hiddenimports = collect_all('prettytable') 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "b2" 3 | description = "Command Line Tool for Backblaze B2" 4 | authors = [ 5 | { name = "Backblaze Inc", email = "support@backblaze.com" }, 6 | ] 7 | dynamic = ["version"] 8 | requires-python = ">=3.8" 9 | keywords = ["backblaze b2 cloud storage"] 10 | license = {text = "MIT"} 11 | readme = "README.md" 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Intended Audience :: Developers", 15 | "Topic :: Software Development :: Libraries", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | dependencies = [ 26 | "argcomplete>=3.5.2,<4", 27 | "arrow>=1.0.2,<2.0.0", 28 | "b2sdk>=2.8.1,<3", 29 | "docutils>=0.18.1", 30 | "idna~=3.4; platform_system == 'Java'", 31 | "importlib-metadata>=3.3; python_version < '3.8'", 32 | "phx-class-registry>=4.0,<5", 33 | "rst2ansi==0.1.5", 34 | "tabulate==0.9.0", 35 | "tqdm>=4.65.0,<5", 36 | "platformdirs>=3.11.0,<5", 37 | "setuptools>=60; python_version < '3.10'", # required by phx-class-registry<4.1 38 | ] 39 | 40 | [project.optional-dependencies] 41 | # doc and licence are actually dev requirements, not optional 42 | # requirements. They should be removed from this section when 43 | # a breaking version is released. 44 | doc = [ 45 | "sadisplay>=0.4.9; python_version >= '3.9'", 46 | "sphinx>=7.2,<8; python_version >= '3.9'", 47 | "sphinx-argparse; python_version >= '3.9'", 48 | "sphinx-autobuild; python_version >= '3.9'", 49 | "sphinx-rtd-theme>=1.3,<2; python_version >= '3.9'", 50 | "sphinxcontrib-plantuml; python_version >= '3.9'" 51 | ] 52 | license = [ 53 | "pip>=23.1.0", 54 | "pip-licenses==3.5.5; python_version < '3.9'", 55 | "pip-licenses~=5.0; python_version >= '3.9'", 56 | "pipdeptree>=2.9,<3; python_version >= '3.9'", 57 | "prettytable~=3.7; python_version < '3.9'", 58 | "prettytable~=3.9; python_version >= '3.9'", 59 | ] 60 | full = [ 61 | "pydantic>=2.0.1,<3" 62 | ] 63 | 64 | [project.urls] 65 | Homepage = "https://github.com/Backblaze/B2_Command_Line_Tool" 66 | 67 | [project.scripts] 68 | b2 = "b2._internal.b2v4.__main__:main" 69 | b2v3 = "b2._internal.b2v3.__main__:main" 70 | b2v4 = "b2._internal.b2v4.__main__:main" 71 | 72 | [build-system] 73 | requires = ["pdm-backend"] 74 | build-backend = "pdm.backend" 75 | 76 | [tool.liccheck] 77 | authorized_licenses = [ 78 | "bsd", 79 | "new bsd", 80 | "bsd license", 81 | "new bsd license", 82 | "simplified bsd", 83 | "bsd-3-clause", 84 | "apache", 85 | "apache 2.0", 86 | "apache software", 87 | "apache software license", 88 | "lgpl", 89 | "gnu lgpl", 90 | "gnu library or lesser general public license (lgpl)", 91 | "lgpl with exceptions or zpl", 92 | "isc license", 93 | "isc license (iscl)", 94 | "mit", 95 | "mit and python-2.0", 96 | "mit license", 97 | "mozilla public license 2.0 (mpl 2.0)", 98 | "mpl-2.0", 99 | "psf", 100 | "psf-2.0", 101 | "python software foundation", 102 | "python software foundation license", 103 | "zpl 2.1", 104 | ] 105 | unauthorized_licences = [ 106 | "affero", 107 | "agpl", 108 | "gpl v3", 109 | "gpl v2", 110 | "gpl", 111 | ] 112 | dependencies = true 113 | optional_dependencies = ["doc", "full", "license"] 114 | 115 | [tool.ruff] 116 | 117 | # TODO add D 118 | select = ["E", "F", "I", "UP"] 119 | # TODO: remove E501 once docstrings are formatted 120 | ignore = [ 121 | "D100", "D105", "D107", "D200", "D202", "D203", "D205", "D212", "D400", "D401", "D415", 122 | "D101", "D102", "D103", "D104", # TODO remove once we have docstring for all public methods 123 | "E501", # TODO: remove E501 once docstrings are formatted 124 | "UP031", 125 | ] 126 | line-length = 100 127 | target-version = "py38" 128 | 129 | [tool.ruff.format] 130 | quote-style = "single" 131 | 132 | [tool.ruff.per-file-ignores] 133 | "__init__.py" = ["F401"] 134 | "test/**" = ["D", "F403", "F405"] 135 | "b2/console_tool.py" = ["E402"] 136 | 137 | [tool.towncrier] 138 | directory = "changelog.d" 139 | filename = "CHANGELOG.md" 140 | start_string = "\n" 141 | underlines = ["", "", ""] 142 | title_format = "## [{version}](https://github.com/Backblaze/B2_Command_Line_Tool/releases/tag/v{version}) - {project_date}" 143 | issue_format = "[#{issue}](https://github.com/Backblaze/B2_Command_Line_Tool/issues/{issue})" 144 | 145 | [[tool.towncrier.type]] 146 | directory = "removed" 147 | name = "Removed" 148 | showcontent = true 149 | 150 | [[tool.towncrier.type]] 151 | directory = "changed" 152 | name = "Changed" 153 | showcontent = true 154 | 155 | [[tool.towncrier.type]] 156 | directory = "fixed" 157 | name = "Fixed" 158 | showcontent = true 159 | 160 | [[tool.towncrier.type]] 161 | directory = "deprecated" 162 | name = "Deprecated" 163 | showcontent = true 164 | 165 | [[tool.towncrier.type]] 166 | directory = "added" 167 | name = "Added" 168 | showcontent = true 169 | 170 | [[tool.towncrier.type]] 171 | directory = "doc" 172 | name = "Doc" 173 | showcontent = true 174 | 175 | [[tool.towncrier.type]] 176 | directory = "infrastructure" 177 | name = "Infrastructure" 178 | showcontent = true 179 | 180 | [tool.pdm] 181 | distribution = "true" 182 | 183 | [tool.pdm.build] 184 | includes = ["b2"] 185 | 186 | [tool.pdm.version] 187 | source = "scm" 188 | 189 | [tool.pdm.scripts] 190 | assert_prod_python = "pdm run python -c 'import sys; assert sys.version_info >= (3, 11)'" 191 | lock_prod_no_cross_platform = "pdm lock --lockfile pdm.prod.lock --group full --group test --strategy no_cross_platform" 192 | lock_bundle = {composite=["assert_prod_python", "lock_prod_no_cross_platform"]} 193 | 194 | [tool.pdm.dev-dependencies] 195 | format = [ 196 | "ruff~=0.8.4", 197 | ] 198 | lint = [ 199 | "ruff~=0.8.4", 200 | "pytest==8.3.3", 201 | "liccheck>=0.9.2", 202 | "setuptools>=60", # required by liccheck 203 | ] 204 | release = [ 205 | "towncrier==23.11.0; python_version >= '3.8'", 206 | ] 207 | test = [ 208 | "coverage==7.2.7", 209 | "pexpect==4.9.0", 210 | "pytest==8.3.3", 211 | "pytest-cov==3.0.0", 212 | "pytest-forked==1.6.0", 213 | "pytest-xdist==2.5.0", 214 | "pytest-watcher==0.4.3", 215 | "backoff==2.1.2", 216 | "more-itertools==8.13.0", 217 | ] 218 | bundle = [ 219 | "pyinstaller<6,>=5.13; python_version < \"3.13\"", 220 | "pyinstaller-hooks-contrib>=2023.6", 221 | "patchelf-wrapper==1.2.0; platform_system == \"Linux\"", 222 | "staticx~=0.13.9; platform_system == \"Linux\"", 223 | ] 224 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | markers = 3 | require_secrets: mark a test as requiring secrets such as API keys 4 | 5 | [coverage:run] 6 | branch=true 7 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/__init__.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/conftest.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import sys 12 | 13 | import pytest 14 | 15 | from b2._internal._utils.python_compat import removeprefix 16 | 17 | 18 | @pytest.hookimpl 19 | def pytest_configure(config): 20 | config.addinivalue_line( 21 | 'markers', 22 | 'apiver(from_ver, to_ver): run tests only on certain apiver versions', 23 | ) 24 | 25 | 26 | @pytest.fixture(scope='session') 27 | def apiver(request): 28 | """Get apiver as a v-prefixed string, e.g. "v2".""" 29 | return removeprefix(request.config.getoption('--cli', '').lstrip('_'), 'b2') or None 30 | 31 | 32 | @pytest.fixture(scope='session') 33 | def apiver_int(apiver) -> int: 34 | return int(apiver[1:]) if apiver else -1 35 | 36 | 37 | @pytest.fixture(autouse=True) 38 | def run_on_apiver_handler(request, apiver_int): 39 | """ 40 | Auto-fixture that allows skipping tests based on the CLI apiver versions. 41 | 42 | Usage: 43 | @pytest.mark.apiver(1, 3) 44 | def test_foo(): 45 | # Test is run only for versions 1 and 3 46 | ... 47 | 48 | @pytest.mark.apiver(from_ver=2, to_ver=5) 49 | def test_bar(): 50 | # Test is run only for versions 2, 3, 4 and 5 51 | ... 52 | 53 | Note that it requires the `cli_int_version` fixture to be defined. 54 | Both unit tests and integration tests handle it a little bit different, thus 55 | two different fixtures are provided. 56 | """ 57 | node = request.node.get_closest_marker('apiver') 58 | if not node: 59 | return 60 | 61 | if not node.args and not node.kwargs: 62 | return 63 | 64 | assert apiver_int >= 0, 'apiver_int fixture is not defined' 65 | 66 | if node.args: 67 | if apiver_int in node.args: 68 | # Run the test. 69 | return 70 | 71 | if node.kwargs: 72 | from_ver = node.kwargs.get('from_ver', 0) 73 | to_ver = node.kwargs.get('to_ver', sys.maxsize) 74 | 75 | if from_ver <= apiver_int <= to_ver: 76 | # Run the test. 77 | return 78 | 79 | pytest.skip('Not supported on this apiver version') 80 | -------------------------------------------------------------------------------- /test/helpers.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/helpers.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import platform 11 | 12 | import pytest 13 | 14 | _MISSING = object() 15 | 16 | 17 | def skip_on_windows(*args, reason='Not supported on Windows', **kwargs): 18 | return pytest.mark.skipif( 19 | platform.system() == 'Windows', 20 | reason=reason, 21 | )(*args, **kwargs) 22 | 23 | 24 | def b2_uri_args_v3(bucket_name, path=_MISSING): 25 | if path is _MISSING: 26 | return [bucket_name] 27 | else: 28 | return [bucket_name, path] 29 | 30 | 31 | def b2_uri_args_v4(bucket_name, path=_MISSING): 32 | if path is _MISSING: 33 | path = '' 34 | return [f'b2://{bucket_name}/{path}'] 35 | 36 | 37 | def deep_cast_dict(actual, expected): 38 | """ 39 | For composite objects `actual` and `expected`, return a copy of `actual` (with all dicts and lists deeply copied) 40 | with all keys of dicts not appearing in `expected` (comparing dicts on any level) removed. Useful for assertions 41 | in tests ignoring extra keys. 42 | """ 43 | if isinstance(expected, dict) and isinstance(actual, dict): 44 | return {k: deep_cast_dict(actual[k], expected[k]) for k in expected if k in actual} 45 | 46 | elif isinstance(expected, list) and isinstance(actual, list): 47 | return [deep_cast_dict(a, e) for a, e in zip(actual, expected)] 48 | 49 | return actual 50 | 51 | 52 | def assert_dict_equal_ignore_extra(actual, expected): 53 | assert deep_cast_dict(actual, expected) == expected 54 | -------------------------------------------------------------------------------- /test/integration/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/__init__.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | """ 11 | B2 CLI integrations tests 12 | 13 | This package contains tests that require interaction with remote server. 14 | Integration tests should be runnable against any arbitrary command implementing `b2` CLI, supplied via `--sut` flag. 15 | """ 16 | -------------------------------------------------------------------------------- /test/integration/cleanup_buckets.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/cleanup_buckets.py 4 | # 5 | # Copyright 2022 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | 12 | def test_cleanup_buckets(b2_api): 13 | # this is not a test, but it is intended to be called 14 | # via pytest because it reuses fixtures which have everything 15 | # set up 16 | pass # b2_api calls b2_api.clean_buckets() in its finalizer 17 | -------------------------------------------------------------------------------- /test/integration/persistent_bucket.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/persistent_bucket.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import hashlib 11 | import os 12 | from dataclasses import dataclass 13 | from functools import cached_property 14 | from typing import List 15 | 16 | import backoff 17 | from b2sdk.v2 import Bucket 18 | from b2sdk.v2.exception import DuplicateBucketName, NonExistentBucket 19 | 20 | from test.integration.helpers import BUCKET_NAME_LENGTH, Api 21 | 22 | PERSISTENT_BUCKET_NAME_PREFIX = 'constst' 23 | 24 | 25 | @dataclass 26 | class PersistentBucketAggregate: 27 | bucket_name: str 28 | subfolder: str 29 | 30 | @cached_property 31 | def virtual_bucket_name(self): 32 | return f'{self.bucket_name}/{self.subfolder}' 33 | 34 | 35 | def get_persistent_bucket_name(b2_api: Api) -> str: 36 | bucket_base = os.environ.get('GITHUB_REPOSITORY_ID', b2_api.api.get_account_id()) 37 | bucket_hash = hashlib.sha256(bucket_base.encode()).hexdigest() 38 | return f'{PERSISTENT_BUCKET_NAME_PREFIX}-{bucket_hash}'[:BUCKET_NAME_LENGTH] 39 | 40 | 41 | @backoff.on_exception( 42 | backoff.expo, 43 | DuplicateBucketName, 44 | max_tries=3, 45 | jitter=backoff.full_jitter, 46 | ) 47 | def get_or_create_persistent_bucket(b2_api: Api) -> Bucket: 48 | bucket_name = get_persistent_bucket_name(b2_api) 49 | try: 50 | bucket = b2_api.api.get_bucket_by_name(bucket_name) 51 | except NonExistentBucket: 52 | bucket = b2_api.api.create_bucket( 53 | bucket_name, 54 | bucket_type='allPublic', 55 | lifecycle_rules=[ 56 | { 57 | 'daysFromHidingToDeleting': 1, 58 | 'daysFromUploadingToHiding': 1, 59 | 'fileNamePrefix': '', 60 | } 61 | ], 62 | ) 63 | # add the new bucket name to the list of bucket names 64 | b2_api.bucket_name_log.append(bucket_name) 65 | return bucket 66 | 67 | 68 | def prune_used_files(b2_api: Api, bucket: Bucket, folders: List[str]): 69 | b2_api.clean_bucket( 70 | bucket_object=bucket, only_files=True, only_folders=folders, ignore_retentions=True 71 | ) 72 | -------------------------------------------------------------------------------- /test/integration/test_autocomplete.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/test_autocomplete.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import sys 12 | 13 | import pexpect 14 | import pytest 15 | 16 | from test.helpers import skip_on_windows 17 | 18 | TIMEOUT = 120 # CI can be slow at times when parallelization is extreme 19 | 20 | BASHRC_CONTENT = """\ 21 | # ~/.bashrc dummy file 22 | 23 | echo "Just testing if we don't replace existing script" > /dev/null 24 | # >>> just a test section >>> 25 | # regardless what is in there already 26 | # <<< just a test section <<< 27 | """ 28 | 29 | 30 | def patched_spawn(*args, **kwargs): 31 | """ 32 | Patch pexpect.spawn to improve error messages 33 | """ 34 | 35 | instance = pexpect.spawn(*args, **kwargs) 36 | 37 | def _patch_expect(func): 38 | def _wrapper(pattern_list, **kwargs): 39 | try: 40 | return func(pattern_list, **kwargs) 41 | except pexpect.exceptions.TIMEOUT as exc: 42 | raise pexpect.exceptions.TIMEOUT( 43 | f'Timeout reached waiting for `{pattern_list}` to be autocompleted' 44 | ) from exc 45 | except pexpect.exceptions.EOF as exc: 46 | raise pexpect.exceptions.EOF( 47 | f'Received EOF waiting for `{pattern_list}` to be autocompleted' 48 | ) from exc 49 | except Exception as exc: 50 | raise RuntimeError( 51 | f'Unexpected error waiting for `{pattern_list}` to be autocompleted' 52 | ) from exc 53 | 54 | return _wrapper 55 | 56 | instance.expect = _patch_expect(instance.expect) 57 | instance.expect_exact = _patch_expect(instance.expect_exact) 58 | 59 | # capture child shell's output for debugging 60 | instance.logfile = sys.stdout.buffer 61 | 62 | return instance 63 | 64 | 65 | @pytest.fixture(scope='session') 66 | def bashrc(homedir): 67 | bashrc_path = homedir / '.bashrc' 68 | bashrc_path.write_text(BASHRC_CONTENT) 69 | yield bashrc_path 70 | 71 | 72 | @pytest.fixture(scope='module') 73 | def cli_command(request) -> str: 74 | return request.config.getoption('--sut') 75 | 76 | 77 | @pytest.fixture(scope='module') 78 | def autocomplete_installed(env, homedir, bashrc, cli_version, cli_command, is_running_on_docker): 79 | if is_running_on_docker: 80 | pytest.skip('Not supported on Docker') 81 | 82 | shell = patched_spawn( 83 | f'bash -i -c "{cli_command} install-autocomplete"', env=env, logfile=sys.stderr.buffer 84 | ) 85 | try: 86 | shell.expect_exact('Autocomplete successfully installed for bash', timeout=TIMEOUT) 87 | finally: 88 | shell.close() 89 | shell.wait() 90 | assert (homedir / '.bash_completion.d' / cli_version).is_file() 91 | assert bashrc.read_text().startswith(BASHRC_CONTENT) 92 | 93 | 94 | @pytest.fixture 95 | def shell(env): 96 | shell = patched_spawn('bash -i', env=env, maxread=1000) 97 | shell.setwinsize(100, 1000) # required to see all suggestions in tests 98 | yield shell 99 | shell.close() 100 | 101 | 102 | @skip_on_windows 103 | def test_autocomplete_b2_commands(autocomplete_installed, is_running_on_docker, shell, cli_version): 104 | if is_running_on_docker: 105 | pytest.skip('Not supported on Docker') 106 | shell.send(f'{cli_version} \t\t') 107 | shell.expect_exact(['authorize-account', 'download-file', 'get-bucket'], timeout=TIMEOUT) 108 | 109 | 110 | @skip_on_windows 111 | def test_autocomplete_b2_only_matching_commands( 112 | autocomplete_installed, is_running_on_docker, shell, cli_version 113 | ): 114 | if is_running_on_docker: 115 | pytest.skip('Not supported on Docker') 116 | shell.send(f'{cli_version} delete-\t\t') 117 | 118 | shell.expect_exact('file', timeout=TIMEOUT) # common part of remaining cmds is autocompleted 119 | with pytest.raises(pexpect.exceptions.TIMEOUT): # no other commands are suggested 120 | shell.expect_exact('get-bucket', timeout=0.5) 121 | 122 | 123 | @skip_on_windows 124 | def test_autocomplete_b2__download_file__b2uri( 125 | autocomplete_installed, 126 | shell, 127 | b2_tool, 128 | bucket_name, 129 | file_name, 130 | is_running_on_docker, 131 | cli_version, 132 | ): 133 | """Test that autocomplete suggests bucket names and file names.""" 134 | if is_running_on_docker: 135 | pytest.skip('Not supported on Docker') 136 | shell.send(f'{cli_version} file download \t\t') 137 | shell.expect_exact('b2://', timeout=TIMEOUT) 138 | shell.send('b2://\t\t') 139 | shell.expect_exact(bucket_name, timeout=TIMEOUT) 140 | shell.send(f'{bucket_name}/\t\t') 141 | shell.expect_exact(file_name, timeout=TIMEOUT) 142 | -------------------------------------------------------------------------------- /test/integration/test_help.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/test_help.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import platform 11 | import re 12 | import subprocess 13 | 14 | 15 | def test_help(cli_version): 16 | p = subprocess.run( 17 | [cli_version, '--help'], 18 | check=True, 19 | capture_output=True, 20 | text=True, 21 | ) 22 | 23 | # verify help contains apiver binary name 24 | expected_name = cli_version 25 | if platform.system() == 'Windows': 26 | expected_name += '.exe' 27 | assert re.match(r'^_?b2(v\d+)?(\.exe)?$', expected_name) # test sanity check 28 | assert f'{expected_name} --help' in p.stdout 29 | -------------------------------------------------------------------------------- /test/integration/test_tqdm_closer.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/integration/test_tqdm_closer.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import re 11 | import sys 12 | 13 | import pytest 14 | 15 | 16 | @pytest.mark.skipif( 17 | (sys.platform != 'darwin') or ((sys.version_info.major, sys.version_info.minor) < (3, 11)), 18 | reason='Tqdm closing error only occurs on OSX and python 3.11 or newer', 19 | ) 20 | def test_tqdm_closer(b2_tool, bucket, file_name): 21 | # test that stderr doesn't contain any warning, in particular warnings about multiprocessing resource tracker 22 | # leaking semaphores 23 | b2_tool.should_succeed( 24 | [ 25 | 'file', 26 | 'cat', 27 | f'b2://{bucket.name}/{file_name}', 28 | ] 29 | ) 30 | 31 | # test that disabling _TqdmCloser does produce a resource tracker warning. Should the following check ever fail, 32 | # that would mean that either Tqdm or python fixed the issue and _TqdmCloser can be disabled for fixed versions 33 | b2_tool.should_succeed( 34 | [ 35 | 'file', 36 | 'cat', 37 | f'b2://{bucket.name}/{file_name}', 38 | ], 39 | additional_env={'B2_TEST_DISABLE_TQDM_CLOSER': '1'}, 40 | expected_stderr_pattern=re.compile( 41 | r'UserWarning: resource_tracker: There appear to be \d+ leaked semaphore' 42 | r' objects to clean up at shutdown' 43 | ), 44 | ) 45 | -------------------------------------------------------------------------------- /test/static/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/static/__init__.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /test/static/test_licenses.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/static/test_licenses.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from datetime import datetime 11 | from glob import glob 12 | from itertools import islice 13 | 14 | import pytest 15 | 16 | FIXER_CMD = 'python test/static/test_licenses.py' 17 | LICENSE_HEADER_TMPL = """\ 18 | ###################################################################### 19 | # 20 | # File: {path} 21 | # 22 | # Copyright {year} Backblaze Inc. All Rights Reserved. 23 | # 24 | # License https://www.backblaze.com/using_b2_code.html 25 | # 26 | ###################################################################### 27 | """ 28 | 29 | 30 | def get_file_header_errors(file_path_glob: str) -> dict[str, str]: 31 | failed_files = {} 32 | for file in glob(file_path_glob, recursive=True): 33 | if file.startswith('build/'): 34 | # built files naturally have a different file path than source files 35 | continue 36 | with open(file) as fd: 37 | file = file.replace( 38 | '\\', '/' 39 | ) # glob('**/*.py') on Windows returns "b2\console_tool.py" (wrong slash) 40 | head = ''.join(islice(fd, 9)) 41 | if 'All Rights Reserved' not in head: 42 | failed_files[file] = 'Missing "All Rights Reserved" in the header' 43 | elif file not in head: 44 | failed_files[file] = 'Wrong file name in the header' 45 | return failed_files 46 | 47 | 48 | def test_files_headers(): 49 | failed_files = get_file_header_errors('**/*.py') 50 | if failed_files: 51 | error_msg = '; '.join(f'{path}:{error}' for path, error in failed_files.items()) 52 | pytest.fail(f'Bad file headers in files (you may want to run {FIXER_CMD!r}): {error_msg}') 53 | 54 | 55 | def insert_header(file_path: str): 56 | with open(file_path, 'r+') as fd: 57 | content = fd.read() 58 | fd.seek(0) 59 | fd.write( 60 | LICENSE_HEADER_TMPL.format( 61 | path=file_path, 62 | year=datetime.now().year, 63 | ) 64 | ) 65 | fd.write(content) 66 | 67 | 68 | def _main(): 69 | failed_files = get_file_header_errors('**/*.py') 70 | for filepath in failed_files: 71 | insert_header(filepath) 72 | 73 | 74 | if __name__ == '__main__': 75 | _main() 76 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/__init__.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | """ 11 | B2 CLI tests 12 | 13 | This package contains all test that do not need to interact with remote server. 14 | """ 15 | -------------------------------------------------------------------------------- /test/unit/_cli/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/__init__.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /test/unit/_cli/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/fixtures/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | -------------------------------------------------------------------------------- /test/unit/_cli/fixtures/dummy_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ###################################################################### 3 | # 4 | # File: test/unit/_cli/fixtures/dummy_command.py 5 | # 6 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 7 | # 8 | # License https://www.backblaze.com/using_b2_code.html 9 | # 10 | ###################################################################### 11 | import argparse 12 | 13 | 14 | def main(): 15 | parser = argparse.ArgumentParser(description='Dummy command') 16 | parser.add_argument('--foo', help='foo help') 17 | parser.add_argument('--bar', help='bar help') 18 | args = parser.parse_args() 19 | print(args.foo) 20 | print(args.bar) 21 | 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /test/unit/_cli/fixtures/module_loading_b2sdk.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/fixtures/module_loading_b2sdk.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | # This is a helper module for test_autocomplete_cache.py 12 | 13 | from b2sdk.v2 import B2Api # noqa 14 | 15 | 16 | def function(): 17 | pass 18 | -------------------------------------------------------------------------------- /test/unit/_cli/test_autocomplete_install.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/test_autocomplete_install.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import pathlib 11 | import shutil 12 | 13 | import pytest 14 | 15 | from b2._internal._cli.autocomplete_install import ( 16 | SHELL_REGISTRY, 17 | add_or_update_shell_section, 18 | ) 19 | from test.helpers import skip_on_windows 20 | 21 | section = 'test_section' 22 | managed_by = 'pytest' 23 | content = 'test content' 24 | 25 | 26 | @pytest.fixture 27 | def test_file(tmp_path): 28 | yield tmp_path / 'test_file.sh' 29 | 30 | 31 | def test_add_or_update_shell_section_new_section(test_file): 32 | test_file.write_text('# preexisting content\n\n') 33 | 34 | add_or_update_shell_section(test_file, section, managed_by, content) 35 | 36 | assert ( 37 | test_file.read_text() 38 | == f"""# preexisting content 39 | 40 | 41 | # >>> {section} >>> 42 | # This section is managed by {managed_by} . Manual edit may break automated updates. 43 | {content} 44 | # <<< {section} <<< 45 | """ 46 | ) 47 | 48 | 49 | def test_add_or_update_shell_section_existing_section(test_file): 50 | old_content = 'old content' 51 | new_content = 'new content' 52 | 53 | # Write the initial file with an existing section 54 | test_file.write_text( 55 | f"""# preexisting content 56 | 57 | # >>> {section} >>> 58 | # This section is managed by {managed_by} . Manual edit may break automated updates. 59 | {old_content} 60 | # <<< {section} <<< 61 | """ 62 | ) 63 | 64 | # Add the new content to the section 65 | add_or_update_shell_section(test_file, section, managed_by, new_content) 66 | 67 | assert ( 68 | test_file.read_text() 69 | == f"""# preexisting content 70 | 71 | # >>> {section} >>> 72 | # This section is managed by {managed_by} . Manual edit may break automated updates. 73 | {new_content} 74 | # <<< {section} <<< 75 | """ 76 | ) 77 | 78 | 79 | def test_add_or_update_shell_section_no_file(test_file): 80 | # Add the new content to the section, which should create the file 81 | add_or_update_shell_section(test_file, section, managed_by, content) 82 | 83 | assert ( 84 | test_file.read_text() 85 | == f""" 86 | # >>> {section} >>> 87 | # This section is managed by {managed_by} . Manual edit may break automated updates. 88 | {content} 89 | # <<< {section} <<< 90 | """ 91 | ) 92 | 93 | 94 | @pytest.fixture 95 | def dummy_command(homedir, monkeypatch, env): 96 | name = 'dummy_command' 97 | bin_path = homedir / 'bin' / name 98 | bin_path.parent.mkdir(parents=True, exist_ok=True) 99 | bin_path.symlink_to(pathlib.Path(__file__).parent / 'fixtures' / f'{name}.py') 100 | monkeypatch.setenv('PATH', f"{homedir}/bin:{env['PATH']}") 101 | yield name 102 | 103 | 104 | @pytest.mark.parametrize('shell', ['bash', 'zsh', 'fish']) 105 | @skip_on_windows 106 | def test_autocomplete_installer(homedir, env, shell, caplog, dummy_command): 107 | caplog.set_level(10) 108 | shell_installer = SHELL_REGISTRY.get(shell, prog=dummy_command) 109 | 110 | shell_bin = shutil.which(shell) 111 | if shell_bin is None: 112 | pytest.skip(f'{shell} is not installed') 113 | 114 | assert shell_installer.is_enabled() is False 115 | shell_installer.install() 116 | assert shell_installer.is_enabled() is True 117 | -------------------------------------------------------------------------------- /test/unit/_cli/test_obj_dumps.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/test_obj_dumps.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from io import StringIO 11 | 12 | import pytest 13 | 14 | from b2._internal._cli.obj_dumps import readable_yaml_dump 15 | 16 | # Test cases as tuples: (input_data, expected_output) 17 | test_cases = [ 18 | ({'key': 'value'}, 'key: value\n'), 19 | ([{'a': 1, 'b': 2}], '- a: 1\n b: 2\n'), 20 | ([1, 2, 'false'], "- 1\n- 2\n- 'false'\n"), 21 | ({'true': True, 'null': None}, "'null': null\n'true': true\n"), 22 | ([1.0, 0.567], '- 1.0\n- 0.567\n'), 23 | ([''], "- ''\n"), 24 | ( 25 | # make sure id and name are first, rest should be sorted alphabetically 26 | [ 27 | {'b': 2, 'a': 1, 'name': 4, 'id': 3}, 28 | ], 29 | '- id: 3\n name: 4\n a: 1\n b: 2\n', 30 | ), 31 | ( # nested data 32 | [ 33 | { 34 | 'name': 'John Doe', 35 | 'age': 30, 36 | 'addresses': [ 37 | { 38 | 'street': '123 Elm St', 39 | 'city': 'Somewhere', 40 | }, 41 | { 42 | 'street': '456 Oak St', 43 | }, 44 | ], 45 | 'address': { 46 | 'street': '789 Pine St', 47 | 'city': 'Anywhere', 48 | 'zip': '67890', 49 | }, 50 | } 51 | ], 52 | ( 53 | '- name: John Doe\n' 54 | ' address: \n' 55 | ' city: Anywhere\n' 56 | ' street: 789 Pine St\n' 57 | " zip: '67890'\n" 58 | ' addresses: \n' 59 | ' - city: Somewhere\n' 60 | ' street: 123 Elm St\n' 61 | ' - street: 456 Oak St\n' 62 | ' age: 30\n' 63 | ), 64 | ), 65 | ] 66 | 67 | 68 | @pytest.mark.parametrize('input_data,expected', test_cases) 69 | def test_readable_yaml_dump(input_data, expected): 70 | output = StringIO() 71 | readable_yaml_dump(input_data, output) 72 | assert output.getvalue() == expected 73 | -------------------------------------------------------------------------------- /test/unit/_cli/test_obj_loads.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/test_obj_loads.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import argparse 13 | 14 | import pytest 15 | 16 | try: 17 | from typing_extensions import TypedDict 18 | except ImportError: 19 | from typing import TypedDict 20 | 21 | from b2._internal._cli.obj_loads import pydantic, validated_loads 22 | 23 | 24 | @pytest.mark.parametrize( 25 | 'input_, expected_val', 26 | [ 27 | # json 28 | ('{"a": 1}', {'a': 1}), 29 | ('{"a": 1, "b": 2}', {'a': 1, 'b': 2}), 30 | ('{"a": 1, "b": 2, "c": 3}', {'a': 1, 'b': 2, 'c': 3}), 31 | ], 32 | ) 33 | def test_validated_loads(input_, expected_val): 34 | assert validated_loads(input_) == expected_val 35 | 36 | 37 | @pytest.mark.parametrize( 38 | 'input_, error_msg', 39 | [ 40 | # not valid json nor yaml 41 | ('{', "'{' is not a valid JSON value"), 42 | ], 43 | ) 44 | def test_validated_loads__invalid_syntax(input_, error_msg): 45 | with pytest.raises(argparse.ArgumentTypeError, match=error_msg): 46 | validated_loads(input_) 47 | 48 | 49 | @pytest.fixture 50 | def typed_dict_cls(): 51 | class MyTypedDict(TypedDict): 52 | a: int | None 53 | b: str 54 | 55 | return MyTypedDict 56 | 57 | 58 | def test_validated_loads__typed_dict(typed_dict_cls): 59 | input_ = '{"a": 1, "b": "2", "extra": null}' 60 | expected_val = {'a': 1, 'b': '2', 'extra': None} 61 | assert validated_loads(input_, typed_dict_cls) == expected_val 62 | 63 | 64 | @pytest.mark.skipif(pydantic is None, reason='pydantic is not enabled') 65 | def test_validated_loads__typed_dict_types_validation(typed_dict_cls): 66 | input_ = '{"a": "abc", "b": 2}' 67 | with pytest.raises(argparse.ArgumentTypeError): 68 | validated_loads(input_, typed_dict_cls) 69 | -------------------------------------------------------------------------------- /test/unit/_cli/test_pickle.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/test_pickle.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import pickle 11 | 12 | import pytest 13 | 14 | from b2._internal._cli import autocomplete_cache 15 | 16 | from .unpickle import unpickle 17 | 18 | 19 | def test_pickle_store(tmp_path): 20 | dir = tmp_path 21 | store = autocomplete_cache.HomeCachePickleStore(dir) 22 | 23 | store.set_pickle('test_1', b'test_data_1') 24 | assert store.get_pickle('test_1') == b'test_data_1' 25 | assert store.get_pickle('test_2') is None 26 | assert len(list(dir.rglob('*.pickle'))) == 1 27 | 28 | store.set_pickle('test_2', b'test_data_2') 29 | assert store.get_pickle('test_2') == b'test_data_2' 30 | assert store.get_pickle('test_1') is None 31 | assert len(list(dir.rglob('*.pickle'))) == 1 32 | 33 | 34 | def test_unpickle(): 35 | """This tests ensures that Unpickler works as expected: 36 | prevents successful unpickling of objects that depend on loading 37 | modules from b2sdk.""" 38 | from .fixtures.module_loading_b2sdk import function 39 | 40 | pickled = pickle.dumps(function) 41 | with pytest.raises(RuntimeError): 42 | unpickle(pickled) 43 | -------------------------------------------------------------------------------- /test/unit/_cli/test_shell.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/test_shell.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import os 12 | from unittest import mock 13 | 14 | from b2._internal._cli import shell 15 | 16 | 17 | @mock.patch.dict(os.environ, {'SHELL': '/bin/bash'}) 18 | def test_detect_shell(): 19 | assert shell.detect_shell() == 'bash' 20 | -------------------------------------------------------------------------------- /test/unit/_cli/unpickle.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_cli/unpickle.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import importlib 11 | import io 12 | import pickle 13 | import sys 14 | from typing import Any, Set 15 | 16 | 17 | class Unpickler(pickle.Unpickler): 18 | """This Unpickler will raise an exception if loading the pickled object 19 | imports any b2sdk module.""" 20 | 21 | _modules_to_load: Set[str] 22 | 23 | def load(self): 24 | self._modules_to_load = set() 25 | 26 | b2_modules = [module for module in sys.modules if 'b2sdk' in module] 27 | for key in b2_modules: 28 | del sys.modules[key] 29 | 30 | result = super().load() 31 | 32 | for module in self._modules_to_load: 33 | importlib.import_module(module) 34 | importlib.reload(sys.modules[module]) 35 | 36 | if any('b2sdk' in module for module in sys.modules): 37 | raise RuntimeError('Loading the pickled object imported b2sdk module') 38 | return result 39 | 40 | def find_class(self, module: str, name: str) -> Any: 41 | self._modules_to_load.add(module) 42 | return super().find_class(module, name) 43 | 44 | 45 | def unpickle(data: bytes) -> Any: 46 | """Unpickling function that raises RuntimeError if unpickled 47 | object depends on b2sdk.""" 48 | return Unpickler(io.BytesIO(data)).load() 49 | -------------------------------------------------------------------------------- /test/unit/_utils/test_uri.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/_utils/test_uri.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from pathlib import Path 11 | 12 | import pytest 13 | 14 | from b2._internal._utils.uri import B2URI, B2FileIdURI, parse_uri 15 | 16 | 17 | class TestB2URI: 18 | def test__str__(self): 19 | uri = B2URI(bucket_name='testbucket', path='path/to/file') 20 | assert str(uri) == 'b2://testbucket/path/to/file' 21 | 22 | @pytest.mark.parametrize( 23 | 'path, expected', 24 | [ 25 | ('', True), 26 | ('path/', True), 27 | ('path/subpath', None), 28 | ], 29 | ) 30 | def test_is_dir(self, path, expected): 31 | assert B2URI('bucket', path).is_dir() is expected 32 | 33 | def test__bucket_uris_are_normalized(self): 34 | alternatives = [ 35 | B2URI('bucket'), 36 | B2URI('bucket', ''), 37 | ] 38 | assert len(set(alternatives)) == 1 39 | assert {str(uri) for uri in alternatives} == {'b2://bucket/'} # normalized 40 | 41 | @pytest.mark.parametrize( 42 | 'path, expected_uri_str', 43 | [ 44 | ('', 'b2://bucket/'), 45 | ('path/', 'b2://bucket/path/'), 46 | ('path/subpath', 'b2://bucket/path/subpath'), 47 | ], 48 | ) 49 | def test__normalization(self, path, expected_uri_str): 50 | assert str(B2URI('bucket', path)) == expected_uri_str 51 | assert str(B2URI('bucket', path)) == str(B2URI('bucket', path)) # normalized 52 | 53 | 54 | def test_b2fileuri_str(): 55 | uri = B2FileIdURI(file_id='file123') 56 | assert str(uri) == 'b2id://file123' 57 | 58 | 59 | @pytest.mark.parametrize( 60 | 'uri,expected', 61 | [ 62 | ('some/local/path', Path('some/local/path')), 63 | ('./some/local/path', Path('some/local/path')), 64 | ('b2://bucket', B2URI(bucket_name='bucket')), 65 | ('b2://bucket/', B2URI(bucket_name='bucket')), 66 | ('b2://bucket/path/to/dir/', B2URI(bucket_name='bucket', path='path/to/dir/')), 67 | ('b2id://file123', B2FileIdURI(file_id='file123')), 68 | ('b2://bucket/wild[card]', B2URI(bucket_name='bucket', path='wild[card]')), 69 | ('b2://bucket/wild?card', B2URI(bucket_name='bucket', path='wild?card')), 70 | ('b2://bucket/special#char', B2URI(bucket_name='bucket', path='special#char')), 71 | ], 72 | ) 73 | def test_parse_uri(uri, expected): 74 | assert parse_uri(uri) == expected 75 | 76 | 77 | def test_parse_uri__allow_all_buckets(): 78 | assert parse_uri('b2://', allow_all_buckets=True) == B2URI('') 79 | with pytest.raises(ValueError) as exc_info: 80 | parse_uri('b2:///', allow_all_buckets=True) 81 | assert ( 82 | "Invalid B2 URI: all buckets URI doesn't allow non-empty path, but '/' was provided" 83 | == str(exc_info.value) 84 | ) 85 | 86 | 87 | @pytest.mark.parametrize( 88 | 'uri, expected_exception_message', 89 | [ 90 | ('', 'URI cannot be empty'), 91 | # Test cases for invalid B2 URIs (missing netloc part) 92 | ('b2://', "Invalid B2 URI: 'b2://'"), 93 | ('b2id://', "Invalid B2 URI: 'b2id://'"), 94 | # Test cases for B2 URIs with credentials 95 | ( 96 | 'b2://user@password:bucket/path', 97 | 'Invalid B2 URI: credentials passed using `user@password:` syntax is not supported in URI', 98 | ), 99 | ( 100 | 'b2id://user@password:file123', 101 | 'Invalid B2 URI: credentials passed using `user@password:` syntax is not supported in URI', 102 | ), 103 | # Test cases for unsupported URI schemes 104 | ('unknown://bucket/path', "Unsupported URI scheme: 'unknown'"), 105 | ], 106 | ) 107 | def test_parse_uri_exceptions(uri, expected_exception_message): 108 | with pytest.raises(ValueError) as exc_info: 109 | parse_uri(uri) 110 | assert expected_exception_message in str(exc_info.value) 111 | -------------------------------------------------------------------------------- /test/unit/conftest.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/conftest.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import importlib 11 | import os 12 | from unittest import mock 13 | 14 | import pytest 15 | from b2sdk.v2 import REALM_URLS 16 | 17 | from b2._internal.console_tool import _TqdmCloser 18 | from b2._internal.version_listing import CLI_VERSIONS, UNSTABLE_CLI_VERSION, get_int_version 19 | 20 | from ..helpers import b2_uri_args_v3, b2_uri_args_v4 21 | from .helpers import RunOrDieExecutor 22 | from .test_console_tool import BaseConsoleToolTest 23 | 24 | 25 | @pytest.hookimpl 26 | def pytest_addoption(parser): 27 | parser.addoption( 28 | '--cli', 29 | default=UNSTABLE_CLI_VERSION, 30 | choices=CLI_VERSIONS, 31 | help='version of the CLI', 32 | ) 33 | 34 | 35 | @pytest.hookimpl 36 | def pytest_report_header(config): 37 | int_version = get_int_version(config.getoption('--cli')) 38 | return f'b2 apiver: {int_version}' 39 | 40 | 41 | @pytest.fixture(scope='session') 42 | def cli_version(request) -> str: 43 | return request.config.getoption('--cli') 44 | 45 | 46 | @pytest.fixture 47 | def homedir(tmp_path_factory): 48 | yield tmp_path_factory.mktemp('test_homedir') 49 | 50 | 51 | @pytest.fixture 52 | def env(homedir, monkeypatch): 53 | """Get ENV for running b2 command from shell level.""" 54 | monkeypatch.setenv('HOME', str(homedir)) 55 | monkeypatch.delenv('XDG_CONFIG_HOME', raising=False) 56 | monkeypatch.setenv('SHELL', '/bin/bash') # fix for running under github actions 57 | if 'TERM' not in os.environ: 58 | monkeypatch.setenv('TERM', 'xterm') 59 | yield os.environ 60 | 61 | 62 | @pytest.fixture(scope='session') 63 | def console_tool_class(cli_version): 64 | # Ensures import of the correct library to handle all the tests. 65 | module = importlib.import_module(f'b2._internal.{cli_version}.registry') 66 | return module.ConsoleTool 67 | 68 | 69 | @pytest.fixture(scope='class') 70 | def unit_test_console_tool_class(request, console_tool_class): 71 | # Ensures that the unittest class uses the correct console tool version. 72 | request.cls.console_tool_class = console_tool_class 73 | 74 | 75 | @pytest.fixture(autouse=True, scope='session') 76 | def mock_realm_urls(): 77 | with mock.patch.dict(REALM_URLS, {'production': 'http://production.example.com'}): 78 | yield 79 | 80 | 81 | @pytest.fixture 82 | def bg_executor(): 83 | """Executor for running background tasks in tests""" 84 | with RunOrDieExecutor() as executor: 85 | yield executor 86 | 87 | 88 | @pytest.fixture(autouse=True) 89 | def disable_tqdm_closer_cleanup(): 90 | with mock.patch.object(_TqdmCloser, '__exit__'): 91 | yield 92 | 93 | 94 | class ConsoleToolTester(BaseConsoleToolTest): 95 | def authorize(self): 96 | self._authorize_account() 97 | 98 | def run(self, *args, **kwargs): 99 | return self._run_command(*args, **kwargs) 100 | 101 | 102 | @pytest.fixture(scope='session', autouse=True) 103 | def mock_signal(): 104 | with mock.patch('signal.signal'): 105 | yield 106 | 107 | 108 | @pytest.fixture 109 | def b2_cli(console_tool_class): 110 | cli_tester = ConsoleToolTester() 111 | # Because of the magic the pytest does on importing and collecting fixtures, 112 | # ConsoleToolTester is not injected with the `unit_test_console_tool_class` 113 | # despite having it as a parent. 114 | # Thus, we inject it manually here. 115 | cli_tester.console_tool_class = console_tool_class 116 | cli_tester.setUp() 117 | yield cli_tester 118 | cli_tester.tearDown() 119 | 120 | 121 | @pytest.fixture 122 | def authorized_b2_cli(b2_cli): 123 | b2_cli.authorize() 124 | yield b2_cli 125 | 126 | 127 | @pytest.fixture 128 | def bucket_info(b2_cli, authorized_b2_cli): 129 | bucket_name = 'my-bucket' 130 | bucket_id = 'bucket_0' 131 | b2_cli.run(['bucket', 'create', bucket_name, 'allPublic'], expected_stdout=f'{bucket_id}\n') 132 | return { 133 | 'bucketName': bucket_name, 134 | 'bucketId': bucket_id, 135 | } 136 | 137 | 138 | @pytest.fixture 139 | def bucket(bucket_info): 140 | return bucket_info['bucketName'] 141 | 142 | 143 | @pytest.fixture 144 | def api_bucket(bucket_info, b2_cli): 145 | return b2_cli.b2_api.get_bucket_by_name(bucket_info['bucketName']) 146 | 147 | 148 | @pytest.fixture 149 | def local_file(tmp_path): 150 | """Set up a test file and return its path.""" 151 | filename = 'file1.txt' 152 | content = 'hello world' 153 | local_file = tmp_path / filename 154 | local_file.write_text(content) 155 | 156 | mod_time = 1500111222 157 | os.utime(local_file, (mod_time, mod_time)) 158 | 159 | return local_file 160 | 161 | 162 | @pytest.fixture 163 | def uploaded_file_with_control_chars(b2_cli, bucket_info, local_file): 164 | filename = '\u009bC\u009bC\u009bIfile.txt' 165 | b2_cli.run(['file', 'upload', bucket_info['bucketName'], str(local_file), filename]) 166 | return { 167 | 'bucket': bucket_info['bucketName'], 168 | 'bucketId': bucket_info['bucketId'], 169 | 'fileName': filename, 170 | 'escapedFileName': '\\\\x9bC\\\\x9bC\\\\x9bIfile.txt', 171 | 'fileId': '1111', 172 | 'content': local_file.read_text(), 173 | } 174 | 175 | 176 | @pytest.fixture 177 | def uploaded_file(b2_cli, bucket_info, local_file): 178 | filename = 'file1.txt' 179 | b2_cli.run(['file', 'upload', '--quiet', bucket_info['bucketName'], str(local_file), filename]) 180 | return { 181 | 'bucket': bucket_info['bucketName'], 182 | 'bucketId': bucket_info['bucketId'], 183 | 'fileName': filename, 184 | 'fileId': '9999', 185 | 'content': local_file.read_text(), 186 | } 187 | 188 | 189 | @pytest.fixture(scope='class') 190 | def b2_uri_args(apiver_int, request): 191 | if apiver_int >= 4: 192 | fn = b2_uri_args_v4 193 | else: 194 | fn = b2_uri_args_v3 195 | 196 | request.cls.b2_uri_args = staticmethod(fn) 197 | -------------------------------------------------------------------------------- /test/unit/console_tool/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/__init__.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | """Tests for the console_tool commands.""" 11 | -------------------------------------------------------------------------------- /test/unit/console_tool/conftest.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/conftest.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import os 11 | import sys 12 | 13 | import pytest 14 | 15 | import b2._internal.console_tool 16 | 17 | 18 | @pytest.fixture 19 | def cwd_path(tmp_path): 20 | """Set up a test directory and return its path.""" 21 | prev_cwd = os.getcwd() 22 | os.chdir(tmp_path) 23 | yield tmp_path 24 | os.chdir(prev_cwd) 25 | 26 | 27 | @pytest.fixture 28 | def b2_cli_log_fix(caplog): 29 | caplog.set_level(0) # prevent pytest from blocking logs 30 | b2._internal.console_tool.logger.setLevel(0) # reset logger level to default 31 | 32 | 33 | @pytest.fixture 34 | def mock_stdin(monkeypatch): 35 | out_, in_ = os.pipe() 36 | monkeypatch.setattr(sys, 'stdin', os.fdopen(out_)) 37 | in_f = open(in_, 'w') 38 | yield in_f 39 | in_f.close() 40 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_authorize_account.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_authorize_account.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from unittest import mock 11 | 12 | import pytest 13 | from b2sdk.v2 import ALL_CAPABILITIES 14 | 15 | from b2._internal._cli.const import ( 16 | B2_APPLICATION_KEY_ENV_VAR, 17 | B2_APPLICATION_KEY_ID_ENV_VAR, 18 | B2_ENVIRONMENT_ENV_VAR, 19 | ) 20 | 21 | 22 | @pytest.fixture 23 | def b2_cli_is_authorized_afterwards(b2_cli): 24 | assert b2_cli.account_info.get_account_auth_token() is None 25 | yield b2_cli 26 | assert b2_cli.account_info.get_account_auth_token() is not None 27 | 28 | 29 | def test_authorize_with_bad_key(b2_cli): 30 | expected_stdout = '' 31 | expected_stderr = """ 32 | ERROR: unable to authorize account: Invalid authorization token. Server said: secret key is wrong (unauthorized) 33 | """ 34 | 35 | b2_cli._run_command( 36 | ['account', 'authorize', b2_cli.account_id, 'bad-app-key'], 37 | expected_stdout, 38 | expected_stderr, 39 | 1, 40 | ) 41 | assert b2_cli.account_info.get_account_auth_token() is None 42 | 43 | 44 | @pytest.mark.parametrize( 45 | 'command', 46 | [ 47 | ['authorize-account'], 48 | ['authorize_account'], 49 | ['account', 'authorize'], 50 | ], 51 | ) 52 | def test_authorize_with_good_key(b2_cli, b2_cli_is_authorized_afterwards, command): 53 | assert b2_cli.account_info.get_account_auth_token() is None 54 | 55 | expected_stderr = ( 56 | '' 57 | if len(command) == 2 58 | else 'WARNING: `authorize-account` command is deprecated. Use `account authorize` instead.\n' 59 | ) 60 | 61 | b2_cli._run_command([*command, b2_cli.account_id, b2_cli.master_key], None, expected_stderr, 0) 62 | 63 | assert b2_cli.account_info.get_account_auth_token() is not None 64 | 65 | 66 | def test_authorize_using_env_variables(b2_cli): 67 | assert b2_cli.account_info.get_account_auth_token() is None 68 | 69 | with mock.patch.dict( 70 | 'os.environ', 71 | { 72 | B2_APPLICATION_KEY_ID_ENV_VAR: b2_cli.account_id, 73 | B2_APPLICATION_KEY_ENV_VAR: b2_cli.master_key, 74 | }, 75 | ): 76 | b2_cli._run_command(['account', 'authorize'], None, '', 0) 77 | 78 | # test deprecated command 79 | with mock.patch.dict( 80 | 'os.environ', 81 | { 82 | B2_APPLICATION_KEY_ID_ENV_VAR: b2_cli.account_id, 83 | B2_APPLICATION_KEY_ENV_VAR: b2_cli.master_key, 84 | }, 85 | ): 86 | b2_cli._run_command( 87 | ['authorize-account'], 88 | None, 89 | 'WARNING: `authorize-account` command is deprecated. Use `account authorize` instead.\n', 90 | 0, 91 | ) 92 | 93 | assert b2_cli.account_info.get_account_auth_token() is not None 94 | 95 | 96 | @pytest.mark.parametrize( 97 | 'flags,realm_url', 98 | [ 99 | ([], 'http://production.example.com'), 100 | (['--debug-logs'], 'http://production.example.com'), 101 | (['--environment', 'http://custom.example.com'], 'http://custom.example.com'), 102 | (['--environment', 'production'], 'http://production.example.com'), 103 | (['--dev'], 'http://api.backblazeb2.xyz:8180'), 104 | (['--staging'], 'https://api.backblaze.net'), 105 | ], 106 | ) 107 | def test_authorize_towards_realm( 108 | b2_cli, b2_cli_is_authorized_afterwards, flags, realm_url, cwd_path, b2_cli_log_fix 109 | ): 110 | expected_stderr = f'Using {realm_url}\n' if any(f != '--debug-logs' for f in flags) else '' 111 | 112 | b2_cli._run_command( 113 | ['account', 'authorize', *flags, b2_cli.account_id, b2_cli.master_key], 114 | None, 115 | expected_stderr, 116 | 0, 117 | ) 118 | log_path = cwd_path / 'b2_cli.log' 119 | if '--debug-logs' in flags: 120 | assert f'Using {realm_url}\n' in log_path.read_text() 121 | else: 122 | assert not log_path.exists() 123 | 124 | 125 | def test_authorize_towards_custom_realm_using_env(b2_cli, b2_cli_is_authorized_afterwards): 126 | expected_stderr = """ 127 | Using http://custom2.example.com 128 | """ 129 | 130 | with mock.patch.dict( 131 | 'os.environ', 132 | { 133 | B2_ENVIRONMENT_ENV_VAR: 'http://custom2.example.com', 134 | }, 135 | ): 136 | b2_cli._run_command( 137 | ['account', 'authorize', b2_cli.account_id, b2_cli.master_key], 138 | None, 139 | expected_stderr, 140 | 0, 141 | ) 142 | 143 | 144 | def test_authorize_account_prints_account_info(b2_cli): 145 | expected_json = { 146 | 'accountAuthToken': 'auth_token_0', 147 | 'accountFilePath': None, 148 | 'accountId': 'account-0', 149 | 'allowed': { 150 | 'bucketId': None, 151 | 'bucketName': None, 152 | 'capabilities': sorted(ALL_CAPABILITIES), 153 | 'namePrefix': None, 154 | }, 155 | 'apiUrl': 'http://api.example.com', 156 | 'applicationKey': 'masterKey-0', 157 | 'applicationKeyId': 'account-0', 158 | 'downloadUrl': 'http://download.example.com', 159 | 'isMasterKey': True, 160 | 's3endpoint': 'http://s3.api.example.com', 161 | } 162 | 163 | b2_cli._run_command( 164 | ['account', 'authorize', b2_cli.account_id, b2_cli.master_key], 165 | expected_stderr='', 166 | expected_status=0, 167 | expected_json_in_stdout=expected_json, 168 | ) 169 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_download_file.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_download_file.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import os 11 | import pathlib 12 | 13 | import pytest 14 | 15 | from test.helpers import skip_on_windows 16 | 17 | EXPECTED_STDOUT_DOWNLOAD = """ 18 | File name: file1.txt 19 | File id: 9999 20 | Output file path: {output_path} 21 | File size: 11 22 | Content type: b2/x-auto 23 | Content sha1: 2aae6c35c94fcfb415dbe95f408b9ce91ee846ed 24 | Encryption: none 25 | Retention: none 26 | Legal hold: 27 | INFO src_last_modified_millis: 1500111222000 28 | Checksum matches 29 | Download finished 30 | """ 31 | 32 | 33 | @pytest.mark.parametrize( 34 | 'flag,expected_stdout', 35 | [ 36 | ('--no-progress', EXPECTED_STDOUT_DOWNLOAD), 37 | ('-q', ''), 38 | ('--quiet', ''), 39 | ], 40 | ) 41 | def test_download_file_by_uri__flag_support(b2_cli, uploaded_file, tmp_path, flag, expected_stdout): 42 | output_path = tmp_path / 'output.txt' 43 | 44 | b2_cli.run( 45 | ['file', 'download', flag, 'b2id://9999', str(output_path)], 46 | expected_stdout=expected_stdout.format(output_path=pathlib.Path(output_path).resolve()), 47 | ) 48 | assert output_path.read_text() == uploaded_file['content'] 49 | 50 | b2_cli.run( 51 | ['download-file', flag, 'b2id://9999', str(output_path)], 52 | expected_stderr='WARNING: `download-file` command is deprecated. Use `file download` instead.\n', 53 | expected_stdout=expected_stdout.format(output_path=pathlib.Path(output_path).resolve()), 54 | ) 55 | assert output_path.read_text() == uploaded_file['content'] 56 | 57 | 58 | @pytest.mark.parametrize( 59 | 'b2_uri', 60 | [ 61 | 'b2://my-bucket/file1.txt', 62 | 'b2id://9999', 63 | ], 64 | ) 65 | def test_download_file_by_uri__b2_uri_support(b2_cli, uploaded_file, tmp_path, b2_uri): 66 | output_path = tmp_path / 'output.txt' 67 | 68 | b2_cli.run( 69 | ['file', 'download', b2_uri, str(output_path)], 70 | expected_stdout=EXPECTED_STDOUT_DOWNLOAD.format( 71 | output_path=pathlib.Path(output_path).resolve() 72 | ), 73 | ) 74 | assert output_path.read_text() == uploaded_file['content'] 75 | 76 | 77 | @pytest.mark.parametrize( 78 | 'flag,expected_stdout', 79 | [ 80 | ('--no-progress', EXPECTED_STDOUT_DOWNLOAD), 81 | ('-q', ''), 82 | ('--quiet', ''), 83 | ], 84 | ) 85 | def test_download_file_by_name(b2_cli, local_file, uploaded_file, tmp_path, flag, expected_stdout): 86 | output_path = tmp_path / 'output.txt' 87 | 88 | b2_cli.run( 89 | [ 90 | 'download-file-by-name', 91 | uploaded_file['bucket'], 92 | uploaded_file['fileName'], 93 | str(output_path), 94 | ], 95 | expected_stdout=EXPECTED_STDOUT_DOWNLOAD.format( 96 | output_path=pathlib.Path(output_path).resolve() 97 | ), 98 | expected_stderr='WARNING: `download-file-by-name` command is deprecated. Use `file download` instead.\n', 99 | ) 100 | assert output_path.read_text() == uploaded_file['content'] 101 | 102 | 103 | @pytest.mark.parametrize( 104 | 'flag,expected_stdout', 105 | [ 106 | ('--no-progress', EXPECTED_STDOUT_DOWNLOAD), 107 | ('-q', ''), 108 | ('--quiet', ''), 109 | ], 110 | ) 111 | def test_download_file_by_id(b2_cli, uploaded_file, tmp_path, flag, expected_stdout): 112 | output_path = tmp_path / 'output.txt' 113 | 114 | b2_cli.run( 115 | ['download-file-by-id', flag, '9999', str(output_path)], 116 | expected_stdout=expected_stdout.format(output_path=pathlib.Path(output_path).resolve()), 117 | expected_stderr='WARNING: `download-file-by-id` command is deprecated. Use `file download` instead.\n', 118 | ) 119 | assert output_path.read_text() == uploaded_file['content'] 120 | 121 | 122 | @skip_on_windows(reason='os.mkfifo is not supported on Windows') 123 | def test_download_file_by_name__named_pipe( 124 | b2_cli, local_file, uploaded_file, tmp_path, bg_executor 125 | ): 126 | output_path = tmp_path / 'output.txt' 127 | os.mkfifo(output_path) 128 | 129 | output_string = None 130 | 131 | def reader(): 132 | nonlocal output_string 133 | output_string = output_path.read_text() 134 | 135 | reader_future = bg_executor.submit(reader) 136 | 137 | b2_cli.run( 138 | [ 139 | 'download-file-by-name', 140 | '--no-progress', 141 | uploaded_file['bucket'], 142 | uploaded_file['fileName'], 143 | str(output_path), 144 | ], 145 | expected_stdout=EXPECTED_STDOUT_DOWNLOAD.format( 146 | output_path=pathlib.Path(output_path).resolve() 147 | ), 148 | expected_stderr='WARNING: `download-file-by-name` command is deprecated. Use `file download` instead.\n', 149 | ) 150 | reader_future.result(timeout=1) 151 | assert output_string == uploaded_file['content'] 152 | 153 | 154 | @pytest.fixture 155 | def uploaded_stdout_txt(b2_cli, bucket, local_file, tmp_path): 156 | local_file.write_text('non-mocked /dev/stdout test ignore me') 157 | b2_cli.run(['file', 'upload', bucket, str(local_file), 'stdout.txt']) 158 | return { 159 | 'bucket': bucket, 160 | 'fileName': 'stdout.txt', 161 | 'content': local_file.read_text(), 162 | } 163 | 164 | 165 | def test_download_file_by_name__to_stdout_by_alias( 166 | b2_cli, bucket, uploaded_stdout_txt, tmp_path, capfd 167 | ): 168 | """Test download-file-by-name stdout alias support""" 169 | b2_cli.run( 170 | ['download-file-by-name', '--no-progress', bucket, uploaded_stdout_txt['fileName'], '-'], 171 | expected_stderr='WARNING: `download-file-by-name` command is deprecated. Use `file download` instead.\n', 172 | ) 173 | assert capfd.readouterr().out == uploaded_stdout_txt['content'] 174 | assert not pathlib.Path('-').exists() 175 | 176 | 177 | def test_cat__b2_uri(b2_cli, bucket, uploaded_stdout_txt, tmp_path, capfd): 178 | b2_cli.run( 179 | ['file', 'cat', '--no-progress', f"b2://{bucket}/{uploaded_stdout_txt['fileName']}"], 180 | ) 181 | assert capfd.readouterr().out == uploaded_stdout_txt['content'] 182 | 183 | 184 | def test_cat__b2_uri__invalid(b2_cli, capfd): 185 | b2_cli.run( 186 | ['file', 'cat', 'nothing/meaningful'], 187 | expected_stderr=None, 188 | expected_status=2, 189 | ) 190 | assert "argument B2_URI: Unsupported URI scheme: ''" in capfd.readouterr().err 191 | 192 | 193 | def test_cat__b2_uri__not_a_file(b2_cli, bucket, capfd): 194 | b2_cli.run( 195 | ['file', 'cat', 'b2://bucket/dir/subdir/'], 196 | expected_stderr=None, 197 | expected_status=2, 198 | ) 199 | assert ( 200 | 'argument B2_URI: B2 URI pointing to a file-like object is required' 201 | in capfd.readouterr().err 202 | ) 203 | 204 | 205 | def test_cat__b2id_uri(b2_cli, bucket, uploaded_stdout_txt, tmp_path, capfd): 206 | b2_cli.run( 207 | ['file', 'cat', '--no-progress', 'b2id://9999'], 208 | ) 209 | assert capfd.readouterr().out == uploaded_stdout_txt['content'] 210 | 211 | b2_cli.run( 212 | ['cat', '--no-progress', 'b2id://9999'], 213 | expected_stderr='WARNING: `cat` command is deprecated. Use `file cat` instead.\n', 214 | ) 215 | assert capfd.readouterr().out == uploaded_stdout_txt['content'] 216 | 217 | 218 | def test__download_file__threads(b2_cli, local_file, uploaded_file, tmp_path): 219 | num_threads = 13 220 | output_path = tmp_path / 'output.txt' 221 | 222 | b2_cli.run( 223 | [ 224 | 'file', 225 | 'download', 226 | '--no-progress', 227 | '--threads', 228 | str(num_threads), 229 | 'b2://my-bucket/file1.txt', 230 | str(output_path), 231 | ] 232 | ) 233 | 234 | assert output_path.read_text() == uploaded_file['content'] 235 | assert b2_cli.console_tool.api.services.download_manager.get_thread_pool_size() == num_threads 236 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_file_hide.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_file_hide.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import pytest 13 | 14 | 15 | @pytest.mark.apiver(to_ver=3) 16 | def test_legacy_hide_file(b2_cli, api_bucket, uploaded_file): 17 | b2_cli.run( 18 | ['hide-file', uploaded_file['bucket'], uploaded_file['fileName']], 19 | expected_stderr='WARNING: `hide-file` command is deprecated. Use `file hide` instead.\n', 20 | ) 21 | assert not list(api_bucket.ls()) 22 | 23 | 24 | @pytest.mark.apiver(to_ver=4) 25 | def test_file_hide__by_bucket_and_file_name(b2_cli, api_bucket, uploaded_file): 26 | b2_cli.run( 27 | ['file', 'hide', uploaded_file['bucket'], uploaded_file['fileName']], 28 | expected_stderr=( 29 | 'WARNING: "bucketName fileName" arguments syntax is deprecated, use "b2://bucketName/fileName" instead\n' 30 | ), 31 | ) 32 | assert not list(api_bucket.ls()) 33 | 34 | 35 | @pytest.mark.apiver 36 | def test_file_hide__by_b2_uri(b2_cli, api_bucket, uploaded_file): 37 | b2_cli.run(['file', 'hide', f"b2://{uploaded_file['bucket']}/{uploaded_file['fileName']}"]) 38 | assert not list(api_bucket.ls()) 39 | 40 | 41 | @pytest.mark.apiver 42 | def test_file_hide__cannot_hide_by_b2id(b2_cli, api_bucket, uploaded_file): 43 | b2_cli.run(['file', 'hide', f"b2id://{uploaded_file['fileId']}"], expected_status=2) 44 | assert list(api_bucket.ls()) 45 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_file_info.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_file_info.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import pytest 11 | 12 | 13 | @pytest.fixture 14 | def uploaded_download_version(b2_cli, bucket_info, uploaded_file): 15 | return { 16 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 17 | 'contentType': 'b2/x-auto', 18 | 'fileId': uploaded_file['fileId'], 19 | 'fileInfo': {'src_last_modified_millis': '1500111222000'}, 20 | 'fileName': 'file1.txt', 21 | 'serverSideEncryption': {'mode': 'none'}, 22 | 'size': 11, 23 | 'uploadTimestamp': 5000, 24 | } 25 | 26 | 27 | @pytest.fixture 28 | def uploaded_file_version(b2_cli, bucket_info, uploaded_file, uploaded_download_version): 29 | return { 30 | **uploaded_download_version, 31 | 'accountId': b2_cli.account_id, 32 | 'action': 'upload', 33 | 'bucketId': uploaded_file['bucketId'], 34 | } 35 | 36 | 37 | def test_get_file_info(b2_cli, uploaded_file_version): 38 | b2_cli.run( 39 | ['get-file-info', uploaded_file_version['fileId']], 40 | expected_json_in_stdout=uploaded_file_version, 41 | expected_stderr='WARNING: `get-file-info` command is deprecated. Use `file info` instead.\n', 42 | ) 43 | 44 | 45 | def test_file_info__b2_uri(b2_cli, bucket, uploaded_download_version): 46 | b2_cli.run( 47 | [ 48 | 'file', 49 | 'info', 50 | f'b2://{bucket}/{uploaded_download_version["fileName"]}', 51 | ], 52 | expected_json_in_stdout=uploaded_download_version, 53 | ) 54 | 55 | 56 | def test_file_info__b2id_uri(b2_cli, uploaded_file_version): 57 | b2_cli.run( 58 | ['file', 'info', f'b2id://{uploaded_file_version["fileId"]}'], 59 | expected_json_in_stdout=uploaded_file_version, 60 | ) 61 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_file_server_side_copy.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_file_server_side_copy.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import pytest 13 | 14 | 15 | @pytest.mark.apiver 16 | def test_copy_file_by_id(b2_cli, api_bucket, uploaded_file): 17 | expected_json = { 18 | 'accountId': b2_cli.account_id, 19 | 'action': 'copy', 20 | 'bucketId': api_bucket.id_, 21 | 'size': 11, 22 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 23 | 'contentType': 'b2/x-auto', 24 | 'fileId': '9998', 25 | 'fileInfo': {'src_last_modified_millis': '1500111222000'}, 26 | 'fileName': 'file1_copy.txt', 27 | 'serverSideEncryption': {'mode': 'none'}, 28 | 'uploadTimestamp': 5001, 29 | } 30 | b2_cli.run( 31 | ['file', 'copy-by-id', '9999', 'my-bucket', 'file1_copy.txt'], 32 | expected_json_in_stdout=expected_json, 33 | expected_stderr='WARNING: `copy-by-id` command is deprecated. Use `file server-side-copy` instead.\n', 34 | ) 35 | 36 | 37 | @pytest.mark.apiver 38 | def test_file_server_side_copy__with_range(b2_cli, api_bucket, uploaded_file): 39 | expected_json = { 40 | 'accountId': b2_cli.account_id, 41 | 'action': 'copy', 42 | 'bucketId': api_bucket.id_, 43 | 'size': 5, 44 | 'contentSha1': '4f664540ff30b8d34e037298a84e4736be39d731', 45 | 'contentType': 'b2/x-auto', 46 | 'fileId': '9998', 47 | 'fileInfo': {'src_last_modified_millis': '1500111222000'}, 48 | 'fileName': 'file1_copy.txt', 49 | 'serverSideEncryption': {'mode': 'none'}, 50 | 'uploadTimestamp': 5001, 51 | } 52 | b2_cli.run( 53 | [ 54 | 'file', 55 | 'server-side-copy', 56 | '--range', 57 | '3,7', 58 | f'b2id://{uploaded_file["fileId"]}', 59 | 'b2://my-bucket/file1_copy.txt', 60 | ], 61 | expected_json_in_stdout=expected_json, 62 | ) 63 | 64 | 65 | @pytest.mark.apiver 66 | def test_file_server_side_copy__invalid_metadata_copy_with_file_info( 67 | b2_cli, api_bucket, uploaded_file 68 | ): 69 | b2_cli.run( 70 | [ 71 | 'file', 72 | 'server-side-copy', 73 | '--info', 74 | 'a=b', 75 | 'b2id://9999', 76 | 'b2://my-bucket/file1_copy.txt', 77 | ], 78 | '', 79 | expected_stderr='ERROR: File info can be set only when content type is set\n', 80 | expected_status=1, 81 | ) 82 | 83 | 84 | @pytest.mark.apiver 85 | def test_file_server_side_copy__invalid_metadata_replace_file_info( 86 | b2_cli, api_bucket, uploaded_file 87 | ): 88 | b2_cli.run( 89 | [ 90 | 'file', 91 | 'server-side-copy', 92 | '--content-type', 93 | 'text/plain', 94 | 'b2id://9999', 95 | 'b2://my-bucket/file1_copy.txt', 96 | ], 97 | '', 98 | expected_stderr='ERROR: File info can be not set only when content type is not set\n', 99 | expected_status=1, 100 | ) 101 | 102 | # replace with content type and file info 103 | expected_json = { 104 | 'accountId': b2_cli.account_id, 105 | 'action': 'copy', 106 | 'bucketId': api_bucket.id_, 107 | 'size': 11, 108 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 109 | 'contentType': 'text/plain', 110 | 'fileId': '9998', 111 | 'fileInfo': {'a': 'b'}, 112 | 'fileName': 'file1_copy.txt', 113 | 'serverSideEncryption': {'mode': 'none'}, 114 | 'uploadTimestamp': 5001, 115 | } 116 | b2_cli.run( 117 | [ 118 | 'file', 119 | 'server-side-copy', 120 | '--content-type', 121 | 'text/plain', 122 | '--info', 123 | 'a=b', 124 | 'b2id://9999', 125 | 'b2://my-bucket/file1_copy.txt', 126 | ], 127 | expected_json_in_stdout=expected_json, 128 | ) 129 | 130 | 131 | @pytest.mark.apiver 132 | def test_file_server_side_copy__unsatisfied_range(b2_cli, api_bucket, uploaded_file): 133 | expected_stderr = 'ERROR: The range in the request is outside the size of the file\n' 134 | b2_cli.run( 135 | [ 136 | 'file', 137 | 'server-side-copy', 138 | '--range', 139 | '12,20', 140 | 'b2id://9999', 141 | 'b2://my-bucket/file1_copy.txt', 142 | ], 143 | '', 144 | expected_stderr, 145 | 1, 146 | ) 147 | 148 | # Copy in different bucket 149 | b2_cli.run(['bucket', 'create', 'my-bucket1', 'allPublic'], 'bucket_1\n', '', 0) 150 | expected_json = { 151 | 'accountId': b2_cli.account_id, 152 | 'action': 'copy', 153 | 'bucketId': 'bucket_1', 154 | 'size': 11, 155 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 156 | 'contentType': 'b2/x-auto', 157 | 'fileId': '9997', 158 | 'fileInfo': {'src_last_modified_millis': '1500111222000'}, 159 | 'fileName': 'file1_copy.txt', 160 | 'serverSideEncryption': {'mode': 'none'}, 161 | 'uploadTimestamp': 5001, 162 | } 163 | b2_cli.run( 164 | ['file', 'server-side-copy', 'b2id://9999', 'b2://my-bucket1/file1_copy.txt'], 165 | expected_json_in_stdout=expected_json, 166 | ) 167 | 168 | 169 | @pytest.mark.apiver 170 | def test_copy_file_by_id__deprecated(b2_cli, api_bucket, uploaded_file): 171 | expected_json = { 172 | 'accountId': b2_cli.account_id, 173 | 'action': 'copy', 174 | 'bucketId': api_bucket.id_, 175 | 'size': 11, 176 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 177 | 'contentType': 'b2/x-auto', 178 | 'fileId': '9998', 179 | 'fileInfo': {'src_last_modified_millis': '1500111222000'}, 180 | 'fileName': 'file1_copy_2.txt', 181 | 'serverSideEncryption': {'mode': 'none'}, 182 | 'uploadTimestamp': 5001, 183 | } 184 | b2_cli.run( 185 | ['copy-file-by-id', '9999', api_bucket.name, 'file1_copy_2.txt'], 186 | expected_stderr='WARNING: `copy-file-by-id` command is deprecated. Use `file server-side-copy` instead.\n', 187 | expected_json_in_stdout=expected_json, 188 | ) 189 | 190 | 191 | @pytest.mark.apiver 192 | def test_file_server_side_copy__by_b2_uri(b2_cli, api_bucket, uploaded_file): 193 | b2_cli.run( 194 | [ 195 | 'file', 196 | 'server-side-copy', 197 | f"b2://{uploaded_file['bucket']}/{uploaded_file['fileName']}", 198 | f"b2://{uploaded_file['bucket']}/copy.bin", 199 | ], 200 | ) 201 | assert [fv.file_name for fv, _ in api_bucket.ls()] == ['copy.bin', uploaded_file['fileName']] 202 | 203 | 204 | @pytest.mark.apiver 205 | def test_file_hide__by_b2id_uri(b2_cli, api_bucket, uploaded_file): 206 | b2_cli.run( 207 | [ 208 | 'file', 209 | 'server-side-copy', 210 | f"b2id://{uploaded_file['fileId']}", 211 | f"b2://{uploaded_file['bucket']}/copy.bin", 212 | ], 213 | ) 214 | assert [fv.file_name for fv, _ in api_bucket.ls()] == ['copy.bin', uploaded_file['fileName']] 215 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_get_url.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_get_url.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import pytest 11 | 12 | 13 | @pytest.fixture 14 | def uploaded_file_url(bucket_info, uploaded_file): 15 | return ( 16 | f"http://download.example.com/file/{bucket_info['bucketName']}/{uploaded_file['fileName']}" 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def uploaded_file_url_by_id(uploaded_file): 22 | return f"http://download.example.com/b2api/v3/b2_download_file_by_id?fileId={uploaded_file['fileId']}" 23 | 24 | 25 | def test_get_url(b2_cli, uploaded_file, uploaded_file_url_by_id): 26 | b2_cli.run( 27 | ['get-url', f"b2id://{uploaded_file['fileId']}"], 28 | expected_stdout=f'{uploaded_file_url_by_id}\n', 29 | expected_stderr='WARNING: `get-url` command is deprecated. Use `file url` instead.\n', 30 | ) 31 | 32 | 33 | def test_make_url(b2_cli, uploaded_file, uploaded_file_url_by_id): 34 | b2_cli.run( 35 | ['make-url', uploaded_file['fileId']], 36 | expected_stdout=f'{uploaded_file_url_by_id}\n', 37 | expected_stderr='WARNING: `make-url` command is deprecated. Use `file url` instead.\n', 38 | ) 39 | 40 | 41 | def test_make_friendly_url(b2_cli, bucket, uploaded_file, uploaded_file_url): 42 | b2_cli.run( 43 | ['make-friendly-url', bucket, uploaded_file['fileName']], 44 | expected_stdout=f'{uploaded_file_url}\n', 45 | expected_stderr='WARNING: `make-friendly-url` command is deprecated. Use `file url` instead.\n', 46 | ) 47 | 48 | 49 | def test_get_url__b2_uri(b2_cli, bucket, uploaded_file, uploaded_file_url): 50 | b2_cli.run( 51 | [ 52 | 'file', 53 | 'url', 54 | f'b2://{bucket}/{uploaded_file["fileName"]}', 55 | ], 56 | expected_stdout=f'{uploaded_file_url}\n', 57 | ) 58 | 59 | 60 | def test_get_url__b2id_uri(b2_cli, uploaded_file, uploaded_file_url_by_id): 61 | b2_cli.run( 62 | ['file', 'url', f'b2id://{uploaded_file["fileId"]}'], 63 | expected_stdout=f'{uploaded_file_url_by_id}\n', 64 | ) 65 | 66 | 67 | def test_get_url__b2id_uri__with_auth__error(b2_cli, uploaded_file): 68 | b2_cli.run( 69 | ['file', 'url', '--with-auth', f'b2id://{uploaded_file["fileId"]}'], 70 | expected_stderr='ERROR: --with-auth param cannot be used with `b2id://` urls. Please, use `b2://bucket/filename` url format instead\n', 71 | expected_status=1, 72 | ) 73 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_help.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_help.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import pytest 11 | 12 | 13 | @pytest.mark.parametrize( 14 | 'flag, included, excluded', 15 | [ 16 | # --help shouldn't show deprecated commands 17 | ( 18 | '--help', 19 | [' b2 file ', '-h', '--help-all'], 20 | [' b2 download-file-by-name ', '(DEPRECATED)'], 21 | ), 22 | # --help-all should show deprecated commands, but marked as deprecated 23 | ( 24 | '--help-all', 25 | ['(DEPRECATED) b2 download-file-by-name ', '-h', '--help-all'], 26 | [], 27 | ), 28 | ], 29 | ) 30 | def test_help(b2_cli, flag, included, excluded, capsys): 31 | b2_cli.run([flag], expected_stdout=None) 32 | 33 | out = capsys.readouterr().out 34 | 35 | found = set() 36 | for i in included: 37 | if i in out: 38 | found.add(i) 39 | for e in excluded: 40 | if e in out: 41 | found.add(e) 42 | assert found.issuperset(included), f'expected {included!r} in {out!r}' 43 | assert found.isdisjoint(excluded), f'expected {excluded!r} not in {out!r}' 44 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_install_autocomplete.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_install_autocomplete.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import contextlib 12 | import shutil 13 | 14 | import pexpect 15 | import pytest 16 | 17 | from test.helpers import skip_on_windows 18 | 19 | 20 | @contextlib.contextmanager 21 | def pexpect_shell(shell_bin, env): 22 | p = pexpect.spawn(f'{shell_bin} -i', env=env, maxread=1000) 23 | p.setwinsize(100, 100) # required to see all suggestions in tests 24 | yield p 25 | p.close() 26 | 27 | 28 | @pytest.mark.parametrize('shell', ['bash', 'zsh', 'fish']) 29 | @skip_on_windows 30 | def test_install_autocomplete(b2_cli, env, shell, monkeypatch): 31 | shell_bin = shutil.which(shell) 32 | if shell_bin is None: 33 | pytest.skip(f'{shell} is not installed') 34 | 35 | monkeypatch.setenv('SHELL', shell_bin) 36 | b2_cli.run( 37 | ['install-autocomplete'], 38 | expected_part_of_stdout=f'Autocomplete successfully installed for {shell}', 39 | ) 40 | 41 | with pexpect_shell(shell_bin, env=env) as pshell: 42 | pshell.send('b2 \t\t') 43 | pshell.expect_exact(['authorize-account', 'download-file', 'get-bucket'], timeout=30) 44 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_ls.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_ls.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import pytest 13 | 14 | 15 | def test_ls__without_bucket_name(b2_cli, bucket_info): 16 | expected_output = 'bucket_0 allPublic my-bucket\n' 17 | 18 | b2_cli.run(['ls'], expected_stdout=expected_output) 19 | b2_cli.run(['ls', 'b2://'], expected_stdout=expected_output) 20 | 21 | 22 | def test_ls__without_bucket_name__json(b2_cli, bucket_info): 23 | expected_output = [ 24 | { 25 | 'accountId': 'account-0', 26 | 'bucketId': 'bucket_0', 27 | 'bucketInfo': {}, 28 | 'bucketName': 'my-bucket', 29 | 'bucketType': 'allPublic', 30 | 'corsRules': [], 31 | 'defaultRetention': {'mode': None}, 32 | 'defaultServerSideEncryption': {'mode': 'none'}, 33 | 'isFileLockEnabled': False, 34 | 'lifecycleRules': [], 35 | 'options': [], 36 | 'replication': { 37 | 'asReplicationDestination': None, 38 | 'asReplicationSource': None, 39 | }, 40 | 'revision': 1, 41 | } 42 | ] 43 | 44 | b2_cli.run(['ls', '--json'], expected_json_in_stdout=expected_output) 45 | b2_cli.run(['ls', '--json', 'b2://'], expected_json_in_stdout=expected_output) 46 | 47 | 48 | @pytest.mark.parametrize('flag', ['--long', '--recursive', '--replication']) 49 | def test_ls__without_bucket_name__option_not_supported(b2_cli, bucket_info, flag): 50 | b2_cli.run( 51 | ['ls', flag], 52 | expected_stderr=f'ERROR: Cannot use {flag} option without specifying a bucket name\n', 53 | expected_status=1, 54 | ) 55 | 56 | 57 | @pytest.mark.apiver(to_ver=3) 58 | def test_ls__pre_v4__should_not_return_exact_match_filename(b2_cli, uploaded_file): 59 | """`b2v3 ls bucketName folderName` should not return files named `folderName` even if such exist""" 60 | b2_cli.run(['ls', uploaded_file['bucket']], expected_stdout='file1.txt\n') # sanity check 61 | b2_cli.run( 62 | ['ls', uploaded_file['bucket'], uploaded_file['fileName']], 63 | expected_stdout='', 64 | ) 65 | 66 | 67 | @pytest.mark.apiver(from_ver=4) 68 | def test_ls__b2_uri__pointing_to_bucket(b2_cli, uploaded_file): 69 | b2_cli.run( 70 | ['ls', f"b2://{uploaded_file['bucket']}/"], 71 | expected_stdout='file1.txt\n', 72 | ) 73 | 74 | 75 | @pytest.mark.apiver(from_ver=4) 76 | def test_ls__b2_uri__pointing_to_a_file(b2_cli, uploaded_file): 77 | b2_cli.run( 78 | ['ls', f"b2://{uploaded_file['bucket']}/{uploaded_file['fileName']}"], 79 | expected_stdout='file1.txt\n', 80 | ) 81 | 82 | b2_cli.run( 83 | ['ls', f"b2://{uploaded_file['bucket']}/nonExistingFile"], 84 | expected_stdout='', 85 | ) 86 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_notification_rules.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_notification_rules.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import json 11 | 12 | import pytest 13 | 14 | 15 | @pytest.fixture() 16 | def bucket_notification_rule(b2_cli, bucket): 17 | rule = { 18 | 'eventTypes': ['b2:ObjectCreated:*'], 19 | 'isEnabled': True, 20 | 'isSuspended': False, 21 | 'name': 'test-rule', 22 | 'objectNamePrefix': '', 23 | 'suspensionReason': '', 24 | 'targetConfiguration': { 25 | 'targetType': 'webhook', 26 | 'url': 'https://example.com/webhook', 27 | }, 28 | } 29 | _, stdout, _ = b2_cli.run( 30 | [ 31 | 'bucket', 32 | 'notification-rule', 33 | 'create', 34 | '--json', 35 | f'b2://{bucket}', 36 | 'test-rule', 37 | '--webhook-url', 38 | 'https://example.com/webhook', 39 | '--event-type', 40 | 'b2:ObjectCreated:*', 41 | ], 42 | ) 43 | actual_rule = json.loads(stdout) 44 | assert actual_rule == rule 45 | return actual_rule 46 | 47 | 48 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 49 | def test_notification_rules__list_all(b2_cli, bucket, bucket_notification_rule, command): 50 | _, stdout, _ = b2_cli.run( 51 | [ 52 | *command, 53 | 'list', 54 | f'b2://{bucket}', 55 | ] 56 | ) 57 | assert ( 58 | stdout 59 | == f"""\ 60 | Notification rules for b2://{bucket}/ : 61 | - name: test-rule 62 | eventTypes: 63 | - b2:ObjectCreated:* 64 | isEnabled: true 65 | isSuspended: false 66 | objectNamePrefix: '' 67 | suspensionReason: '' 68 | targetConfiguration: 69 | targetType: webhook 70 | url: https://example.com/webhook 71 | """ 72 | ) 73 | 74 | 75 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 76 | def test_notification_rules__list_all_json(b2_cli, bucket, bucket_notification_rule, command): 77 | _, stdout, _ = b2_cli.run( 78 | [ 79 | *command, 80 | 'list', 81 | '--json', 82 | f'b2://{bucket}', 83 | ] 84 | ) 85 | assert json.loads(stdout) == [bucket_notification_rule] 86 | 87 | 88 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 89 | def test_notification_rules__update(b2_cli, bucket, bucket_notification_rule, command): 90 | bucket_notification_rule['isEnabled'] = False 91 | _, stdout, _ = b2_cli.run( 92 | [ 93 | *command, 94 | 'update', 95 | '--json', 96 | f'b2://{bucket}', 97 | bucket_notification_rule['name'], 98 | '--disable', 99 | '--custom-header', 100 | 'X-Custom-Header=value=1', 101 | ], 102 | ) 103 | bucket_notification_rule['targetConfiguration']['customHeaders'] = { 104 | 'X-Custom-Header': 'value=1' 105 | } 106 | assert json.loads(stdout) == bucket_notification_rule 107 | 108 | 109 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 110 | def test_notification_rules__update__no_such_rule( 111 | b2_cli, bucket, bucket_notification_rule, command 112 | ): 113 | b2_cli.run( 114 | [ 115 | *command, 116 | 'update', 117 | f'b2://{bucket}', 118 | f'{bucket_notification_rule["name"]}-unexisting', 119 | '--disable', 120 | ], 121 | expected_stderr=( 122 | "ERROR: rule with name 'test-rule-unexisting' does not exist on bucket " 123 | "'my-bucket', available rules: ['test-rule']\n" 124 | ), 125 | expected_status=1, 126 | ) 127 | 128 | 129 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 130 | def test_notification_rules__update__custom_header_malformed( 131 | b2_cli, bucket, bucket_notification_rule, command 132 | ): 133 | bucket_notification_rule['isEnabled'] = False 134 | _, stdout, _ = b2_cli.run( 135 | [ 136 | *command, 137 | 'update', 138 | '--json', 139 | f'b2://{bucket}', 140 | bucket_notification_rule['name'], 141 | '--disable', 142 | '--custom-header', 143 | 'X-Custom-Header: value', 144 | ], 145 | ) 146 | bucket_notification_rule['targetConfiguration']['customHeaders'] = { 147 | 'X-Custom-Header: value': '' 148 | } 149 | assert json.loads(stdout) == bucket_notification_rule 150 | 151 | 152 | def test_notification_rules__delete(b2_cli, bucket, bucket_notification_rule): 153 | _, stdout, _ = b2_cli.run( 154 | [ 155 | 'bucket', 156 | 'notification-rule', 157 | 'delete', 158 | f'b2://{bucket}', 159 | bucket_notification_rule['name'], 160 | ], 161 | ) 162 | assert stdout == "Rule 'test-rule' has been deleted from b2://my-bucket/\n" 163 | 164 | 165 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 166 | def test_notification_rules__delete_no_such_rule(b2_cli, bucket, bucket_notification_rule, command): 167 | b2_cli.run( 168 | [ 169 | *command, 170 | 'delete', 171 | f'b2://{bucket}', 172 | f'{bucket_notification_rule["name"]}-unexisting', 173 | ], 174 | expected_stderr=( 175 | "ERROR: no such rule to delete: 'test-rule-unexisting', available rules: ['test-rule'];" 176 | ' No rules have been deleted.\n' 177 | ), 178 | expected_status=1, 179 | ) 180 | 181 | 182 | @pytest.mark.parametrize( 183 | 'args,expected_stdout', 184 | [ 185 | (['-q'], ''), 186 | ([], 'No notification rules for b2://my-bucket/\n'), 187 | (['--json'], '[]\n'), 188 | ], 189 | ) 190 | def test_notification_rules__no_rules(b2_cli, bucket, args, expected_stdout): 191 | b2_cli.run( 192 | ['bucket', 'notification-rule', 'list', f'b2://{bucket}', *args], 193 | expected_stdout=expected_stdout, 194 | ) 195 | 196 | 197 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 198 | def test_notification_rules__disable_enable(b2_cli, bucket, bucket_notification_rule, command): 199 | _, stdout, _ = b2_cli.run( 200 | [ 201 | *command, 202 | 'disable', 203 | '--json', 204 | f'b2://{bucket}', 205 | bucket_notification_rule['name'], 206 | ], 207 | ) 208 | assert json.loads(stdout) == {**bucket_notification_rule, 'isEnabled': False} 209 | 210 | _, stdout, _ = b2_cli.run( 211 | [ 212 | *command, 213 | 'enable', 214 | '--json', 215 | f'b2://{bucket}', 216 | bucket_notification_rule['name'], 217 | ], 218 | ) 219 | assert json.loads(stdout) == {**bucket_notification_rule, 'isEnabled': True} 220 | 221 | 222 | @pytest.mark.parametrize( 223 | 'subcommand', 224 | ['disable', 'enable'], 225 | ) 226 | def test_notification_rules__disable_enable__no_such_rule( 227 | b2_cli, 228 | bucket, 229 | bucket_notification_rule, 230 | subcommand, 231 | ): 232 | b2_cli.run( 233 | [ 234 | 'bucket', 235 | 'notification-rule', 236 | subcommand, 237 | f'b2://{bucket}', 238 | f'{bucket_notification_rule["name"]}-unexisting', 239 | ], 240 | expected_stderr=( 241 | "ERROR: rule with name 'test-rule-unexisting' does not exist on bucket " 242 | "'my-bucket', available rules: ['test-rule']\n" 243 | ), 244 | expected_status=1, 245 | ) 246 | 247 | 248 | @pytest.mark.parametrize('command', [['bucket', 'notification-rule'], ['notification-rules']]) 249 | def test_notification_rules__sign_secret(b2_cli, bucket, bucket_notification_rule, command): 250 | b2_cli.run( 251 | [ 252 | *command, 253 | 'update', 254 | '--json', 255 | f'b2://{bucket}', 256 | bucket_notification_rule['name'], 257 | '--sign-secret', 258 | 'new-secret', 259 | ], 260 | expected_status=2, 261 | ) 262 | 263 | _, stdout, _ = b2_cli.run( 264 | [ 265 | *command, 266 | 'update', 267 | '--json', 268 | f'b2://{bucket}', 269 | bucket_notification_rule['name'], 270 | '--sign-secret', 271 | '7' * 32, 272 | ], 273 | ) 274 | bucket_notification_rule['targetConfiguration']['hmacSha256SigningSecret'] = '7' * 32 275 | assert json.loads(stdout) == bucket_notification_rule 276 | 277 | assert json.loads( 278 | b2_cli.run( 279 | [*command, 'list', '--json', f'b2://{bucket}'], 280 | )[1] 281 | ) == [bucket_notification_rule] 282 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_rm.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_rm.py 4 | # 5 | # Copyright 2024 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | from __future__ import annotations 11 | 12 | import pytest 13 | 14 | 15 | @pytest.mark.apiver(to_ver=3) 16 | def test_rm__pre_v4__should_not_rm_exact_match_filename(b2_cli, api_bucket, uploaded_file): 17 | """`b2v3 rm bucketName folderName` should not remove file named `folderName` even if such exist""" 18 | b2_cli.run(['rm', uploaded_file['bucket'], uploaded_file['fileName']]) 19 | assert list(api_bucket.ls()) # nothing was removed 20 | 21 | 22 | @pytest.mark.apiver(from_ver=4) 23 | def test_rm__b2_uri__pointing_to_a_file(b2_cli, api_bucket, uploaded_file): 24 | b2_cli.run(['rm', f"b2://{uploaded_file['bucket']}/noSuchFile"]) 25 | assert list(api_bucket.ls()) # sanity check: bucket is not empty 26 | b2_cli.run(['rm', f"b2://{uploaded_file['bucket']}/{uploaded_file['fileName']}"]) 27 | assert not list(api_bucket.ls()) 28 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_upload_file.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_upload_file.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import os 11 | 12 | import pytest 13 | 14 | from test.helpers import skip_on_windows 15 | 16 | 17 | def test_upload_file__file_info_src_last_modified_millis_and_headers(b2_cli, bucket, tmpdir): 18 | """Test `file upload` supports manually specifying file info src_last_modified_millis""" 19 | filename = 'file1.txt' 20 | content = 'hello world' 21 | local_file1 = tmpdir.join('file1.txt') 22 | local_file1.write(content) 23 | 24 | expected_json = { 25 | 'action': 'upload', 26 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 27 | 'fileInfo': { 28 | 'b2-cache-control': 'max-age=3600', 29 | 'b2-expires': 'Thu, 01 Dec 2050 16:00:00 GMT', 30 | 'b2-content-language': 'en', 31 | 'b2-content-disposition': 'attachment', 32 | 'b2-content-encoding': 'gzip', 33 | 'src_last_modified_millis': '1', 34 | }, 35 | 'fileName': filename, 36 | 'size': len(content), 37 | } 38 | b2_cli.run( 39 | [ 40 | 'file', 41 | 'upload', 42 | '--no-progress', 43 | '--info=src_last_modified_millis=1', 44 | 'my-bucket', 45 | '--cache-control', 46 | 'max-age=3600', 47 | '--expires', 48 | 'Thu, 01 Dec 2050 16:00:00 GMT', 49 | '--content-language', 50 | 'en', 51 | '--content-disposition', 52 | 'attachment', 53 | '--content-encoding', 54 | 'gzip', 55 | str(local_file1), 56 | 'file1.txt', 57 | ], 58 | expected_json_in_stdout=expected_json, 59 | remove_version=True, 60 | ) 61 | 62 | 63 | @skip_on_windows 64 | def test_upload_file__named_pipe(b2_cli, bucket, tmpdir, bg_executor): 65 | """Test `file upload` supports named pipes""" 66 | filename = 'named_pipe.txt' 67 | content = 'hello world' 68 | local_file1 = tmpdir.join('file1.txt') 69 | os.mkfifo(str(local_file1)) 70 | writer = bg_executor.submit( 71 | local_file1.write, content 72 | ) # writer will block until content is read 73 | 74 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 75 | expected_json = { 76 | 'action': 'upload', 77 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 78 | 'contentType': 'b2/x-auto', 79 | 'fileName': filename, 80 | 'size': len(content), 81 | } 82 | b2_cli.run( 83 | ['file', 'upload', '--no-progress', 'my-bucket', str(local_file1), filename], 84 | expected_json_in_stdout=expected_json, 85 | remove_version=True, 86 | expected_part_of_stdout=expected_stdout, 87 | ) 88 | writer.result(timeout=1) 89 | 90 | 91 | @pytest.mark.apiver(to_ver=3) 92 | def test_upload_file__hyphen_file_instead_of_stdin(b2_cli, bucket, tmpdir, monkeypatch): 93 | """Test `file upload` will upload file named `-` instead of stdin by default""" 94 | filename = 'stdin.txt' 95 | content = "I'm very rare creature, a file named '-'" 96 | monkeypatch.chdir(str(tmpdir)) 97 | source_file = tmpdir.join('-') 98 | source_file.write(content) 99 | 100 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 101 | expected_json = { 102 | 'action': 'upload', 103 | 'contentSha1': 'ab467567b98216a255f77aef08aa2c418073d974', 104 | 'fileName': filename, 105 | 'size': len(content), 106 | } 107 | b2_cli.run( 108 | ['upload-file', '--no-progress', 'my-bucket', '-', filename], 109 | expected_json_in_stdout=expected_json, 110 | remove_version=True, 111 | expected_part_of_stdout=expected_stdout, 112 | expected_stderr='WARNING: `upload-file` command is deprecated. Use `file upload` instead.\n' 113 | "WARNING: Filename `-` won't be supported in the future and will always be treated as stdin alias.\n", 114 | ) 115 | 116 | 117 | @pytest.mark.apiver(from_ver=4) 118 | def test_upload_file__ignore_hyphen_file(b2_cli, bucket, tmpdir, monkeypatch, mock_stdin): 119 | """Test `file upload` will upload stdin even when file named `-` is explicitly specified""" 120 | content = "I'm very rare creature, a file named '-'" 121 | monkeypatch.chdir(str(tmpdir)) 122 | source_file = tmpdir.join('-') 123 | source_file.write(content) 124 | 125 | content = 'stdin input' 126 | filename = 'stdin.txt' 127 | 128 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 129 | expected_json = { 130 | 'action': 'upload', 131 | 'contentSha1': '2ce72aa159d1f190fddf295cc883f20c4787a751', 132 | 'fileName': filename, 133 | 'size': len(content), 134 | } 135 | mock_stdin.write(content) 136 | mock_stdin.close() 137 | 138 | b2_cli.run( 139 | ['file', 'upload', '--no-progress', 'my-bucket', '-', filename], 140 | expected_json_in_stdout=expected_json, 141 | remove_version=True, 142 | expected_part_of_stdout=expected_stdout, 143 | ) 144 | 145 | 146 | def test_upload_file__stdin(b2_cli, bucket, tmpdir, mock_stdin): 147 | """Test `file upload` stdin alias support""" 148 | content = 'stdin input' 149 | filename = 'stdin.txt' 150 | 151 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 152 | expected_json = { 153 | 'action': 'upload', 154 | 'contentSha1': '2ce72aa159d1f190fddf295cc883f20c4787a751', 155 | 'fileName': filename, 156 | 'size': len(content), 157 | } 158 | mock_stdin.write(content) 159 | mock_stdin.close() 160 | 161 | b2_cli.run( 162 | ['file', 'upload', '--no-progress', 'my-bucket', '-', filename], 163 | expected_json_in_stdout=expected_json, 164 | remove_version=True, 165 | expected_part_of_stdout=expected_stdout, 166 | ) 167 | 168 | 169 | def test_upload_file_deprecated__stdin(b2_cli, bucket, tmpdir, mock_stdin): 170 | """Test `upload-file` stdin alias support""" 171 | content = 'stdin input deprecated' 172 | filename = 'stdin-deprecated.txt' 173 | 174 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 175 | expected_json = { 176 | 'action': 'upload', 177 | 'contentSha1': 'fcaa935e050efe0b5d7b26e65162b32b5e40aa81', 178 | 'fileName': filename, 179 | 'size': len(content), 180 | } 181 | mock_stdin.write(content) 182 | mock_stdin.close() 183 | 184 | b2_cli.run( 185 | ['upload-file', '--no-progress', 'my-bucket', '-', filename], 186 | expected_stderr='WARNING: `upload-file` command is deprecated. Use `file upload` instead.\n', 187 | expected_json_in_stdout=expected_json, 188 | remove_version=True, 189 | expected_part_of_stdout=expected_stdout, 190 | ) 191 | 192 | 193 | def test_upload_file__threads_setting(b2_cli, bucket, tmp_path): 194 | """Test `file upload` supports setting number of threads""" 195 | num_threads = 66 196 | filename = 'file1.txt' 197 | content = 'hello world' 198 | local_file1 = tmp_path / 'file1.txt' 199 | local_file1.write_text(content) 200 | 201 | expected_json = { 202 | 'action': 'upload', 203 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 204 | 'fileInfo': {'src_last_modified_millis': f'{local_file1.stat().st_mtime_ns // 1000000}'}, 205 | 'fileName': filename, 206 | 'size': len(content), 207 | } 208 | 209 | b2_cli.run( 210 | [ 211 | 'file', 212 | 'upload', 213 | '--no-progress', 214 | 'my-bucket', 215 | '--threads', 216 | str(num_threads), 217 | str(local_file1), 218 | 'file1.txt', 219 | ], 220 | expected_json_in_stdout=expected_json, 221 | remove_version=True, 222 | ) 223 | 224 | assert b2_cli.console_tool.api.services.upload_manager.get_thread_pool_size() == num_threads 225 | -------------------------------------------------------------------------------- /test/unit/console_tool/test_upload_unbound_stream.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/console_tool/test_upload_unbound_stream.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import os 11 | 12 | from b2sdk.v2 import DEFAULT_MIN_PART_SIZE 13 | 14 | from test.helpers import skip_on_windows 15 | 16 | UUS_DEPRECATION_WARNING = ( 17 | 'WARNING: `upload-unbound-stream` command is deprecated. Use `file upload` instead.\n' 18 | ) 19 | 20 | 21 | @skip_on_windows 22 | def test_upload_unbound_stream__named_pipe(b2_cli, bucket, tmpdir, bg_executor): 23 | """Test upload_unbound_stream supports named pipes""" 24 | filename = 'named_pipe.txt' 25 | content = 'hello world' 26 | fifo_file = tmpdir.join('fifo_file.txt') 27 | os.mkfifo(str(fifo_file)) 28 | writer = bg_executor.submit(fifo_file.write, content) # writer will block until content is read 29 | 30 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 31 | expected_json = { 32 | 'action': 'upload', 33 | 'contentSha1': '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 34 | 'fileName': filename, 35 | 'size': len(content), 36 | } 37 | b2_cli.run( 38 | ['upload-unbound-stream', '--no-progress', 'my-bucket', str(fifo_file), filename], 39 | expected_json_in_stdout=expected_json, 40 | remove_version=True, 41 | expected_part_of_stdout=expected_stdout, 42 | expected_stderr=UUS_DEPRECATION_WARNING, 43 | ) 44 | writer.result(timeout=1) 45 | 46 | 47 | def test_upload_unbound_stream__stdin(b2_cli, bucket, tmpdir, mock_stdin): 48 | """Test upload_unbound_stream stdin alias support""" 49 | content = 'stdin input' 50 | filename = 'stdin.txt' 51 | 52 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 53 | expected_json = { 54 | 'action': 'upload', 55 | 'contentSha1': '2ce72aa159d1f190fddf295cc883f20c4787a751', 56 | 'fileName': filename, 57 | 'size': len(content), 58 | } 59 | mock_stdin.write(content) 60 | mock_stdin.close() 61 | 62 | b2_cli.run( 63 | ['upload-unbound-stream', '--no-progress', 'my-bucket', '-', filename], 64 | expected_json_in_stdout=expected_json, 65 | remove_version=True, 66 | expected_part_of_stdout=expected_stdout, 67 | expected_stderr=UUS_DEPRECATION_WARNING, 68 | ) 69 | 70 | 71 | @skip_on_windows 72 | def test_upload_unbound_stream__with_part_size_options( 73 | b2_cli, bucket, tmpdir, mock_stdin, bg_executor 74 | ): 75 | """Test upload_unbound_stream with part size options""" 76 | part_size = DEFAULT_MIN_PART_SIZE 77 | expected_size = part_size + 500 # has to be bigger to force multipart upload 78 | 79 | filename = 'named_pipe.txt' 80 | fifo_file = tmpdir.join('fifo_file.txt') 81 | os.mkfifo(str(fifo_file)) 82 | writer = bg_executor.submit( 83 | lambda: fifo_file.write('x' * expected_size) 84 | ) # writer will block until content is read 85 | 86 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 87 | expected_json = { 88 | 'action': 'upload', 89 | 'fileName': filename, 90 | 'size': expected_size, 91 | } 92 | 93 | b2_cli.run( 94 | [ 95 | 'upload-unbound-stream', 96 | '--min-part-size', 97 | str(DEFAULT_MIN_PART_SIZE), 98 | '--part-size', 99 | str(part_size), 100 | '--no-progress', 101 | 'my-bucket', 102 | str(fifo_file), 103 | filename, 104 | ], 105 | expected_json_in_stdout=expected_json, 106 | remove_version=True, 107 | expected_part_of_stdout=expected_stdout, 108 | expected_stderr=UUS_DEPRECATION_WARNING, 109 | ) 110 | writer.result(timeout=1) 111 | 112 | 113 | def test_upload_unbound_stream__regular_file(b2_cli, bucket, tmpdir): 114 | """Test upload_unbound_stream regular file support""" 115 | content = 'stdin input' 116 | filename = 'file.txt' 117 | filepath = tmpdir.join(filename) 118 | filepath.write(content) 119 | 120 | expected_stdout = f'URL by file name: http://download.example.com/file/my-bucket/{filename}' 121 | expected_json = { 122 | 'action': 'upload', 123 | 'contentSha1': '2ce72aa159d1f190fddf295cc883f20c4787a751', 124 | 'fileName': filename, 125 | 'size': len(content), 126 | } 127 | 128 | b2_cli.run( 129 | ['upload-unbound-stream', '--no-progress', 'my-bucket', str(filepath), filename], 130 | expected_json_in_stdout=expected_json, 131 | remove_version=True, 132 | expected_part_of_stdout=expected_stdout, 133 | expected_stderr=f'{UUS_DEPRECATION_WARNING}' 134 | 'WARNING: You are using a stream upload command to upload a regular file. While it will work, it is inefficient. Use of `file upload` command is recommended.\n', 135 | ) 136 | -------------------------------------------------------------------------------- /test/unit/helpers.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/helpers.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import concurrent.futures 11 | import sys 12 | 13 | 14 | class RunOrDieExecutor(concurrent.futures.ThreadPoolExecutor): 15 | """ 16 | Deadly ThreadPoolExecutor, which ensures all task are quickly closed before exiting. 17 | 18 | Only really usable in tests. 19 | """ 20 | 21 | def __exit__(self, exc_type, exc_val, exc_tb): 22 | self.shutdown(wait=False, cancel_futures=True) 23 | return super().__exit__(exc_type, exc_val, exc_tb) 24 | 25 | if sys.version_info < (3, 9): # shutdown(cancel_futures=True) is Python 3.9+ 26 | 27 | def __init__(self, *args, **kwargs): 28 | super().__init__(*args, **kwargs) 29 | self._futures = [] 30 | 31 | def shutdown(self, wait=True, cancel_futures=False): 32 | if cancel_futures: 33 | for future in self._futures: 34 | future.cancel() 35 | super().shutdown(wait=wait) 36 | 37 | def submit(self, *args, **kwargs): 38 | future = super().submit(*args, **kwargs) 39 | self._futures.append(future) 40 | return future 41 | -------------------------------------------------------------------------------- /test/unit/test_apiver.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/test_apiver.py 4 | # 5 | # Copyright 2023 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | import unittest 11 | 12 | import pytest 13 | 14 | 15 | @pytest.fixture 16 | def inject_apiver_int(request, apiver_int): 17 | request.cls.apiver_int = apiver_int 18 | 19 | 20 | @pytest.mark.usefixtures('inject_apiver_int') 21 | class UnitTestClass(unittest.TestCase): 22 | apiver_int: int 23 | 24 | @pytest.mark.apiver(to_ver=3) 25 | def test_passes_below_and_on_v3(self): 26 | assert self.apiver_int <= 3 27 | 28 | @pytest.mark.apiver(from_ver=4) 29 | def test_passes_above_and_on_v4(self): 30 | assert self.apiver_int >= 4 31 | 32 | @pytest.mark.apiver(3) 33 | def test_passes_only_on_v3(self): 34 | assert self.apiver_int == 3 35 | 36 | @pytest.mark.apiver(4) 37 | def test_passes_only_on_v4(self): 38 | assert self.apiver_int == 4 39 | 40 | @pytest.mark.apiver(3, 4) 41 | def test_passes_on_both_v3_and_v4(self): 42 | assert self.apiver_int in {3, 4} 43 | 44 | 45 | @pytest.mark.apiver(to_ver=3) 46 | def test_passes_below_and_on_v3(apiver_int): 47 | assert apiver_int <= 3 48 | 49 | 50 | @pytest.mark.apiver(from_ver=4) 51 | def test_passes_above_and_on_v4(apiver_int): 52 | assert apiver_int >= 4 53 | 54 | 55 | @pytest.mark.apiver(3) 56 | def test_passes_only_on_v3(apiver_int): 57 | assert apiver_int == 3 58 | 59 | 60 | @pytest.mark.apiver(4) 61 | def test_passes_only_on_v4(apiver_int): 62 | assert apiver_int == 4 63 | 64 | 65 | @pytest.mark.apiver(3, 4) 66 | def test_passes_on_both_v3_and_v4(apiver_int): 67 | assert apiver_int in {3, 4} 68 | -------------------------------------------------------------------------------- /test/unit/test_arg_parser.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/test_arg_parser.py 4 | # 5 | # Copyright 2020 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import argparse 12 | import sys 13 | 14 | from b2._internal._cli.arg_parser_types import ( 15 | parse_comma_separated_list, 16 | parse_millis_from_float_timestamp, 17 | parse_range, 18 | ) 19 | from b2._internal.arg_parser import B2ArgumentParser 20 | from b2._internal.console_tool import B2 21 | 22 | from .test_base import TestBase 23 | 24 | 25 | class TestCustomArgTypes(TestBase): 26 | def test_parse_comma_separated_list(self): 27 | self.assertEqual([''], parse_comma_separated_list('')) 28 | self.assertEqual(['1', '2', '3'], parse_comma_separated_list('1,2,3')) 29 | 30 | def test_parse_millis_from_float_timestamp(self): 31 | self.assertEqual(1367900664000, parse_millis_from_float_timestamp('1367900664')) 32 | self.assertEqual(1367900664152, parse_millis_from_float_timestamp('1367900664.152')) 33 | with self.assertRaises(ValueError): 34 | parse_millis_from_float_timestamp('!$@$%@!@$') 35 | 36 | def test_parse_range(self): 37 | self.assertEqual((1, 2), parse_range('1,2')) 38 | with self.assertRaises(argparse.ArgumentTypeError): 39 | parse_range('1') 40 | with self.assertRaises(argparse.ArgumentTypeError): 41 | parse_range('1,2,3') 42 | with self.assertRaises(ValueError): 43 | parse_range('!@#,%^&') 44 | 45 | 46 | class TestNonUTF8TerminalSupport(TestBase): 47 | class ASCIIEncodedStream: 48 | def __init__(self, original_stream): 49 | self.original_stream = original_stream 50 | self.encoding = 'ascii' 51 | 52 | def write(self, data): 53 | if isinstance(data, str): 54 | data = data.encode(self.encoding, 'strict') 55 | self.original_stream.buffer.write(data) 56 | 57 | def flush(self): 58 | self.original_stream.flush() 59 | 60 | def check_help_string(self, command_class, command_name): 61 | help_string = command_class.__doc__ 62 | 63 | # create a parser with a help message that is based on the command_class.__doc__ string 64 | parser = B2ArgumentParser(description=help_string) 65 | 66 | try: 67 | old_stdout = sys.stdout 68 | old_stderr = sys.stderr 69 | sys.stdout = TestNonUTF8TerminalSupport.ASCIIEncodedStream(sys.stdout) 70 | sys.stderr = TestNonUTF8TerminalSupport.ASCIIEncodedStream(sys.stderr) 71 | 72 | parser.print_help() 73 | 74 | except UnicodeEncodeError as e: 75 | self.fail( 76 | f'Failed to encode help message for command "{command_name}" on a non-UTF-8 terminal: {e}' 77 | ) 78 | 79 | finally: 80 | # Restore original stdout and stderr 81 | sys.stdout = old_stdout 82 | sys.stderr = old_stderr 83 | 84 | def test_help_in_non_utf8_terminal(self): 85 | command_classes = dict(B2.subcommands_registry.items()) 86 | command_classes['b2'] = B2 87 | 88 | for command_name, command_class in command_classes.items(): 89 | with self.subTest(command_class=command_class, command_name=command_name): 90 | self.check_help_string(command_class, command_name) 91 | -------------------------------------------------------------------------------- /test/unit/test_base.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/test_base.py 4 | # 5 | # Copyright 2019 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | import re 12 | import unittest 13 | from contextlib import contextmanager 14 | from typing import Type 15 | 16 | import pytest 17 | 18 | 19 | @pytest.mark.usefixtures('unit_test_console_tool_class', 'b2_uri_args') 20 | class TestBase(unittest.TestCase): 21 | console_tool_class: Type 22 | 23 | @contextmanager 24 | def assertRaises(self, exc, msg=None): 25 | try: 26 | yield 27 | except exc as e: 28 | if msg is not None: 29 | if msg != str(e): 30 | assert False, f"expected message '{msg}', but got '{str(e)}'" 31 | else: 32 | assert False, f'should have thrown {exc}' 33 | 34 | @contextmanager 35 | def assertRaisesRegexp(self, expected_exception, expected_regexp): 36 | try: 37 | yield 38 | except expected_exception as e: 39 | if not re.search(expected_regexp, str(e)): 40 | assert False, f"expected message '{expected_regexp}', but got '{str(e)}'" 41 | else: 42 | assert False, f'should have thrown {expected_exception}' 43 | -------------------------------------------------------------------------------- /test/unit/test_copy.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/test_copy.py 4 | # 5 | # Copyright 2021 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from unittest import mock 12 | 13 | from b2sdk.v2 import ( 14 | SSE_B2_AES, 15 | UNKNOWN_KEY_ID, 16 | EncryptionAlgorithm, 17 | EncryptionKey, 18 | EncryptionMode, 19 | EncryptionSetting, 20 | ) 21 | 22 | from b2._internal._utils.uri import B2FileIdURI 23 | from b2._internal.console_tool import FileCopyById 24 | 25 | from .test_base import TestBase 26 | 27 | 28 | class TestCopy(TestBase): 29 | def test_determine_source_metadata(self): 30 | mock_api = mock.MagicMock() 31 | mock_console_tool = mock.MagicMock() 32 | mock_console_tool.api = mock_api 33 | copy_file_command = FileCopyById(mock_console_tool) 34 | 35 | result = copy_file_command._determine_source_metadata( 36 | B2FileIdURI('id'), 37 | destination_encryption=None, 38 | source_encryption=None, 39 | target_file_info=None, 40 | target_content_type=None, 41 | fetch_if_necessary=True, 42 | ) 43 | assert result == (None, None) 44 | assert len(mock_api.method_calls) == 0 45 | 46 | result = copy_file_command._determine_source_metadata( 47 | B2FileIdURI('id'), 48 | destination_encryption=SSE_B2_AES, 49 | source_encryption=SSE_B2_AES, 50 | target_file_info={}, 51 | target_content_type='', 52 | fetch_if_necessary=True, 53 | ) 54 | assert result == (None, None) 55 | assert len(mock_api.method_calls) == 0 56 | 57 | result = copy_file_command._determine_source_metadata( 58 | B2FileIdURI('id'), 59 | destination_encryption=SSE_B2_AES, 60 | source_encryption=SSE_B2_AES, 61 | target_file_info={}, 62 | target_content_type='', 63 | fetch_if_necessary=True, 64 | ) 65 | assert result == (None, None) 66 | assert len(mock_api.method_calls) == 0 67 | 68 | source_sse_c = EncryptionSetting( 69 | EncryptionMode.SSE_C, 70 | EncryptionAlgorithm.AES256, 71 | EncryptionKey(b'some_key', UNKNOWN_KEY_ID), 72 | ) 73 | destination_sse_c = EncryptionSetting( 74 | EncryptionMode.SSE_C, 75 | EncryptionAlgorithm.AES256, 76 | EncryptionKey(b'some_other_key', 'key_id'), 77 | ) 78 | 79 | result = copy_file_command._determine_source_metadata( 80 | B2FileIdURI('id'), 81 | destination_encryption=destination_sse_c, 82 | source_encryption=source_sse_c, 83 | target_file_info={}, 84 | target_content_type='', 85 | fetch_if_necessary=True, 86 | ) 87 | assert result == (None, None) 88 | assert len(mock_api.method_calls) == 0 89 | 90 | with self.assertRaises( 91 | ValueError, 92 | 'Attempting to copy file with metadata while either source or ' 93 | 'destination uses SSE-C. Use --fetch-metadata to fetch source ' 94 | 'file metadata before copying.', 95 | ): 96 | copy_file_command._determine_source_metadata( 97 | B2FileIdURI('id'), 98 | destination_encryption=destination_sse_c, 99 | source_encryption=source_sse_c, 100 | target_file_info=None, 101 | target_content_type=None, 102 | fetch_if_necessary=False, 103 | ) 104 | assert len(mock_api.method_calls) == 0 105 | 106 | result = copy_file_command._determine_source_metadata( 107 | B2FileIdURI('id'), 108 | destination_encryption=destination_sse_c, 109 | source_encryption=source_sse_c, 110 | target_file_info=None, 111 | target_content_type=None, 112 | fetch_if_necessary=True, 113 | ) 114 | assert result != (None, None) 115 | assert len(mock_api.method_calls) 116 | -------------------------------------------------------------------------------- /test/unit/test_represent_file_metadata.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # 3 | # File: test/unit/test_represent_file_metadata.py 4 | # 5 | # Copyright 2021 Backblaze Inc. All Rights Reserved. 6 | # 7 | # License https://www.backblaze.com/using_b2_code.html 8 | # 9 | ###################################################################### 10 | 11 | from io import StringIO 12 | 13 | import pytest 14 | from b2sdk.v2 import ( 15 | SSE_B2_AES, 16 | B2Api, 17 | B2HttpApiConfig, 18 | EncryptionAlgorithm, 19 | EncryptionKey, 20 | EncryptionMode, 21 | EncryptionSetting, 22 | FileRetentionSetting, 23 | LegalHold, 24 | RawSimulator, 25 | RetentionMode, 26 | StubAccountInfo, 27 | ) 28 | 29 | from b2._internal.console_tool import ConsoleTool, DownloadCommand 30 | 31 | from .test_base import TestBase 32 | 33 | 34 | class TestReprentFileMetadata(TestBase): 35 | def setUp(self): 36 | self.master_b2_api = B2Api( 37 | StubAccountInfo(), None, api_config=B2HttpApiConfig(_raw_api_class=RawSimulator) 38 | ) 39 | self.raw_api = self.master_b2_api.session.raw_api 40 | (self.master_account_id, self.master_key) = self.raw_api.create_account() 41 | self.master_b2_api.authorize_account('production', self.master_account_id, self.master_key) 42 | self.lock_enabled_bucket = self.master_b2_api.create_bucket( 43 | 'lock-enabled-bucket', 'allPrivate', is_file_lock_enabled=True 44 | ) 45 | self.lock_disabled_bucket = self.master_b2_api.create_bucket( 46 | 'lock-disabled-bucket', 'allPrivate', is_file_lock_enabled=False 47 | ) 48 | new_key = self.master_b2_api.create_key( 49 | [ 50 | 'listKeys', 51 | 'listBuckets', 52 | 'listFiles', 53 | 'readFiles', 54 | ], 55 | 'restricted', 56 | ) 57 | self.restricted_key_id, self.restricted_key = new_key.id_, new_key.application_key 58 | 59 | self.restricted_b2_api = B2Api(StubAccountInfo(), None) 60 | self.restricted_b2_api.session.raw_api = self.raw_api 61 | self.restricted_b2_api.authorize_account( 62 | 'production', self.restricted_key_id, self.restricted_key 63 | ) 64 | 65 | def _get_b2api(**kwargs) -> B2Api: 66 | kwargs.pop('profile', None) 67 | return self.master_b2_api 68 | 69 | self.mp = pytest.MonkeyPatch() 70 | self.mp.setattr('b2._internal.console_tool._get_b2api_for_profile', _get_b2api) 71 | self.mp.setattr('b2._internal.console_tool._get_inmemory_b2api', _get_b2api) 72 | 73 | self.stdout = StringIO() 74 | self.stderr = StringIO() 75 | self.console_tool = ConsoleTool(self.stdout, self.stderr) 76 | 77 | def tearDown(self): 78 | self.mp.undo() 79 | super().tearDown() 80 | 81 | def assertRetentionRepr(self, file_id: str, api: B2Api, expected_repr: str): 82 | file_version = api.get_file_info(file_id) 83 | assert DownloadCommand._represent_retention(file_version.file_retention) == expected_repr 84 | 85 | def assertLegalHoldRepr(self, file_id: str, api: B2Api, expected_repr: str): 86 | file_version = api.get_file_info(file_id) 87 | assert DownloadCommand._represent_legal_hold(file_version.legal_hold) == expected_repr 88 | 89 | def assertEncryptionRepr(self, file_id: str, expected_repr: str): 90 | file_version = self.master_b2_api.get_file_info(file_id) 91 | assert ( 92 | DownloadCommand._represent_encryption(file_version.server_side_encryption) 93 | == expected_repr 94 | ) 95 | 96 | def test_file_retention(self): 97 | file = self.lock_disabled_bucket.upload_bytes(b'insignificant', 'file') 98 | self.assertRetentionRepr(file.id_, self.master_b2_api, 'none') 99 | self.assertRetentionRepr(file.id_, self.restricted_b2_api, '') 100 | 101 | file = self.lock_enabled_bucket.upload_bytes(b'insignificant', 'file') 102 | self.assertRetentionRepr(file.id_, self.master_b2_api, 'none') 103 | self.assertRetentionRepr(file.id_, self.restricted_b2_api, '') 104 | 105 | self.master_b2_api.update_file_retention( 106 | file.id_, file.file_name, FileRetentionSetting(RetentionMode.GOVERNANCE, 1500) 107 | ) 108 | self.assertRetentionRepr( 109 | file.id_, 110 | self.master_b2_api, 111 | 'mode=governance, retainUntil=1970-01-01 00:00:01.500000+00:00', 112 | ) 113 | self.assertRetentionRepr(file.id_, self.restricted_b2_api, '') 114 | 115 | self.master_b2_api.update_file_retention( 116 | file.id_, file.file_name, FileRetentionSetting(RetentionMode.COMPLIANCE, 2000) 117 | ) 118 | 119 | self.assertRetentionRepr( 120 | file.id_, self.master_b2_api, 'mode=compliance, retainUntil=1970-01-01 00:00:02+00:00' 121 | ) 122 | self.assertRetentionRepr(file.id_, self.restricted_b2_api, '') 123 | 124 | def test_legal_hold(self): 125 | file = self.lock_disabled_bucket.upload_bytes(b'insignificant', 'file') 126 | self.assertLegalHoldRepr(file.id_, self.master_b2_api, '') 127 | self.assertLegalHoldRepr(file.id_, self.restricted_b2_api, '') 128 | 129 | file = self.lock_enabled_bucket.upload_bytes(b'insignificant', 'file') 130 | self.assertLegalHoldRepr(file.id_, self.master_b2_api, '') 131 | self.assertLegalHoldRepr(file.id_, self.restricted_b2_api, '') 132 | 133 | self.master_b2_api.update_file_legal_hold(file.id_, file.file_name, LegalHold.ON) 134 | self.assertLegalHoldRepr(file.id_, self.master_b2_api, 'on') 135 | self.assertLegalHoldRepr(file.id_, self.restricted_b2_api, '') 136 | 137 | self.master_b2_api.update_file_legal_hold(file.id_, file.file_name, LegalHold.OFF) 138 | self.assertLegalHoldRepr(file.id_, self.master_b2_api, 'off') 139 | self.assertLegalHoldRepr(file.id_, self.restricted_b2_api, '') 140 | 141 | def test_encryption(self): 142 | file = self.lock_enabled_bucket.upload_bytes(b'insignificant', 'file') 143 | self.assertEncryptionRepr(file.id_, 'none') 144 | 145 | file = self.lock_enabled_bucket.upload_bytes( 146 | b'insignificant', 'file', encryption=SSE_B2_AES 147 | ) 148 | self.assertEncryptionRepr(file.id_, 'mode=SSE-B2, algorithm=AES256') 149 | 150 | file = self.lock_enabled_bucket.upload_bytes( 151 | b'insignificant', 152 | 'file', 153 | encryption=EncryptionSetting( 154 | EncryptionMode.SSE_C, 155 | algorithm=EncryptionAlgorithm.AES256, 156 | key=EncryptionKey(b'', key_id=None), 157 | ), 158 | ) 159 | self.assertEncryptionRepr(file.id_, 'mode=SSE-C, algorithm=AES256') 160 | 161 | file = self.lock_enabled_bucket.upload_bytes( 162 | b'insignificant', 163 | 'file', 164 | encryption=EncryptionSetting( 165 | EncryptionMode.SSE_C, 166 | algorithm=EncryptionAlgorithm.AES256, 167 | key=EncryptionKey(b'', key_id='some_id'), 168 | ), 169 | ) 170 | self.assertEncryptionRepr(file.id_, 'mode=SSE-C, algorithm=AES256, key_id=some_id') 171 | --------------------------------------------------------------------------------