diff --git a/b2sdk/_internal/__init__.py b/b2sdk/_internal/__init__.py index 3a8f1d109..375cdf5c1 100644 --- a/b2sdk/_internal/__init__.py +++ b/b2sdk/_internal/__init__.py @@ -10,5 +10,5 @@ """ b2sdk._internal package contains internal modules, and should not be used directly. -Please use chosen apiver package instead, e.g. b2sdk.v2 +Please use chosen apiver package instead, e.g. b2sdk.v3 """ diff --git a/b2sdk/_internal/account_info/abstract.py b/b2sdk/_internal/account_info/abstract.py index 8095f375e..7b1f3a7ad 100644 --- a/b2sdk/_internal/account_info/abstract.py +++ b/b2sdk/_internal/account_info/abstract.py @@ -31,8 +31,7 @@ class AbstractAccountInfo(metaclass=B2TraceMetaAbstract): # The 'allowed' structure to use for old account info that was saved without 'allowed'. DEFAULT_ALLOWED = dict( - bucketId=None, - bucketName=None, + buckets=None, capabilities=ALL_CAPABILITIES, namePrefix=None, ) @@ -318,7 +317,7 @@ def set_auth_data( """ if allowed is None: allowed = self.DEFAULT_ALLOWED - assert self.allowed_is_valid(allowed) + assert self.allowed_is_valid(allowed), allowed self._set_auth_data( account_id, @@ -337,8 +336,7 @@ def set_auth_data( @classmethod def allowed_is_valid(cls, allowed): """ - Make sure that all of the required fields are present, and that - bucketId is set if bucketName is. + Make sure that all of the required fields are present If the bucketId is for a bucket that no longer exists, or the capabilities do not allow for listBuckets, then we will not have a bucketName. @@ -346,13 +344,7 @@ def allowed_is_valid(cls, allowed): :param dict allowed: the structure to use for old account info that was saved without 'allowed' :rtype: bool """ - return ( - ('bucketId' in allowed) - and ('bucketName' in allowed) - and ((allowed['bucketId'] is not None) or (allowed['bucketName'] is None)) - and ('capabilities' in allowed) - and ('namePrefix' in allowed) - ) + return ('buckets' in allowed) and ('capabilities' in allowed) and ('namePrefix' in allowed) @abstractmethod def _set_auth_data( diff --git a/b2sdk/_internal/account_info/sqlite_account_info.py b/b2sdk/_internal/account_info/sqlite_account_info.py index 234d9ddaf..cb63486f8 100644 --- a/b2sdk/_internal/account_info/sqlite_account_info.py +++ b/b2sdk/_internal/account_info/sqlite_account_info.py @@ -280,7 +280,7 @@ def _create_tables(self, conn, last_upgrade_to_run): """ ) # By default, we run all the upgrades - last_upgrade_to_run = 4 if last_upgrade_to_run is None else last_upgrade_to_run + last_upgrade_to_run = 5 if last_upgrade_to_run is None else last_upgrade_to_run # Add the 'allowed' column if it hasn't been yet. if 1 <= last_upgrade_to_run: self._ensure_update(1, ['ALTER TABLE account ADD COLUMN allowed TEXT;']) @@ -384,6 +384,40 @@ def _create_tables(self, conn, last_upgrade_to_run): ], ) + if 5 <= last_upgrade_to_run: + self._migrate_allowed_to_multi_bucket() + + def _migrate_allowed_to_multi_bucket(self): + """ + Migrate existing allowed json dict to a new multi-bucket keys format + """ + if self._get_update_count(5) > 0: + return + + try: + allowed_json = self._get_account_info_or_raise('allowed') + except MissingAccountData: + allowed_json = None + + if allowed_json is None: + self._perform_update(5, []) + return + + allowed = json.loads(allowed_json) + + bucket_id = allowed.pop('bucketId') + bucket_name = allowed.pop('bucketName') + + if bucket_id is not None: + allowed['buckets'] = [{'id': bucket_id, 'name': bucket_name}] + else: + allowed['buckets'] = None + + allowed_text = json.dumps(allowed) + stmt = f"UPDATE account SET allowed = ('{allowed_text}');" + + self._perform_update(5, [stmt]) + def _ensure_update(self, update_number, update_commands: list[str]): """ Run the update with the given number if it hasn't been done yet. @@ -391,19 +425,26 @@ def _ensure_update(self, update_number, update_commands: list[str]): Does the update and stores the number as a single transaction, so they will always be in sync. """ + update_count = self._get_update_count(update_number) + if update_count > 0: + return + + self._perform_update(update_number, update_commands) + + def _get_update_count(self, update_number: int): with self._get_connection() as conn: - conn.execute('BEGIN') cursor = conn.execute( 'SELECT COUNT(*) AS count FROM update_done WHERE update_number = ?;', (update_number,), ) - update_count = cursor.fetchone()[0] - if update_count == 0: - for command in update_commands: - conn.execute(command) - conn.execute( - 'INSERT INTO update_done (update_number) VALUES (?);', (update_number,) - ) + return cursor.fetchone()[0] + + def _perform_update(self, update_number, update_commands: list[str]): + with self._get_connection() as conn: + conn.execute('BEGIN') + for command in update_commands: + conn.execute(command) + conn.execute('INSERT INTO update_done (update_number) VALUES (?);', (update_number,)) def clear(self): """ @@ -551,8 +592,7 @@ def get_allowed(self): .. code-block:: python { - "bucketId": null, - "bucketName": null, + "buckets": null, "capabilities": [ "listKeys", "writeKeys" @@ -567,8 +607,8 @@ def get_allowed(self): allowed_json = self._get_account_info_or_raise('allowed') if allowed_json is None: return self.DEFAULT_ALLOWED - else: - return json.loads(allowed_json) + + return json.loads(allowed_json) def get_s3_api_url(self): result = self._get_account_info_or_raise('s3_api_url') diff --git a/b2sdk/_internal/api.py b/b2sdk/_internal/api.py index 1551c8cc5..5ca76053e 100644 --- a/b2sdk/_internal/api.py +++ b/b2sdk/_internal/api.py @@ -37,7 +37,8 @@ ) from .large_file.services import LargeFileServices from .progress import AbstractProgressListener -from .raw_api import API_VERSION, LifecycleRule +from .raw_api import API_VERSION as RAW_API_VERSION +from .raw_api import LifecycleRule from .replication.setting import ReplicationConfiguration from .session import B2Session from .transfer import ( @@ -52,21 +53,6 @@ logger = logging.getLogger(__name__) -def url_for_api(info, api_name): - """ - Return URL for an API endpoint. - - :param info: account info - :param str api_nam: - :rtype: str - """ - if api_name in ['b2_download_file_by_id']: - base = info.get_download_url() - else: - base = info.get_api_url() - return f'{base}/b2api/{API_VERSION}/{api_name}' - - class Services: """Gathers objects that provide high level logic over raw api usage.""" @@ -141,7 +127,10 @@ class handles several things that simplify the task of uploading FILE_VERSION_FACTORY_CLASS = staticmethod(FileVersionFactory) DOWNLOAD_VERSION_FACTORY_CLASS = staticmethod(DownloadVersionFactory) SERVICES_CLASS = staticmethod(Services) + APPLICATION_KEY_CLASS = ApplicationKey + FULL_APPLICATION_KEY_CLASS = FullApplicationKey DEFAULT_LIST_KEY_COUNT = 1000 + API_VERSION = RAW_API_VERSION def __init__( self, @@ -484,6 +473,21 @@ def delete_file_version( response = self.session.delete_file_version(file_id, file_name, bypass_governance) return FileIdAndName.from_cancel_or_delete_response(response) + @classmethod + def _get_url_for_api(cls, info, api_name) -> str: + """ + Return URL for an API endpoint. + + :param info: account info + :param str api_nam: + :rtype: str + """ + if api_name in ['b2_download_file_by_id']: + base = info.get_download_url() + else: + base = info.get_api_url() + return f'{base}/b2api/{cls.API_VERSION}/{api_name}' + # download def get_download_url_for_fileid(self, file_id): """ @@ -491,7 +495,7 @@ def get_download_url_for_fileid(self, file_id): :param str file_id: a file ID """ - url = url_for_api(self.account_info, 'b2_download_file_by_id') + url = self._get_url_for_api(self.account_info, 'b2_download_file_by_id') return f'{url}?fileId={file_id}' def get_download_url_for_file_name(self, bucket_name, file_name): @@ -512,7 +516,7 @@ def create_key( capabilities: list[str], key_name: str, valid_duration_seconds: int | None = None, - bucket_id: str | None = None, + bucket_ids: list[str] | None = None, name_prefix: str | None = None, ) -> FullApplicationKey: """ @@ -531,14 +535,14 @@ def create_key( capabilities=capabilities, key_name=key_name, valid_duration_seconds=valid_duration_seconds, - bucket_id=bucket_id, + bucket_ids=bucket_ids, name_prefix=name_prefix, ) assert set(response['capabilities']) == set(capabilities) assert response['keyName'] == key_name - return FullApplicationKey.from_create_response(response) + return self.FULL_APPLICATION_KEY_CLASS.from_create_response(response) def delete_key(self, application_key: BaseApplicationKey): """ @@ -557,7 +561,7 @@ def delete_key_by_id(self, application_key_id: str) -> ApplicationKey: """ response = self.session.delete_key(application_key_id=application_key_id) - return ApplicationKey.from_api_response(response) + return self.APPLICATION_KEY_CLASS.from_api_response(response) def list_keys( self, start_application_key_id: str | None = None @@ -576,7 +580,7 @@ def list_keys( start_application_key_id=start_application_key_id, ) for entry in response['keys']: - yield ApplicationKey.from_api_response(entry) + yield self.APPLICATION_KEY_CLASS.from_api_response(entry) next_application_key_id = response['nextApplicationKeyId'] if next_application_key_id is None: @@ -599,6 +603,8 @@ def get_key(self, key_id: str) -> ApplicationKey | None: if key.id_ == key_id: return key + return None + # other def get_file_info(self, file_id: str) -> FileVersion: """ @@ -630,7 +636,7 @@ def check_bucket_name_restrictions(self, bucket_name: str): :raises b2sdk.v2.exception.RestrictedBucket: if the account is not allowed to use this bucket """ - self._check_bucket_restrictions('bucketName', bucket_name) + self._check_bucket_restrictions('name', bucket_name) def check_bucket_id_restrictions(self, bucket_id: str): """ @@ -641,15 +647,21 @@ def check_bucket_id_restrictions(self, bucket_id: str): :raises b2sdk.v2.exception.RestrictedBucket: if the account is not allowed to use this bucket """ - self._check_bucket_restrictions('bucketId', bucket_id) + self._check_bucket_restrictions('id', bucket_id) def _check_bucket_restrictions(self, key, value): - allowed = self.account_info.get_allowed() - allowed_bucket_identifier = allowed[key] + buckets = self.account_info.get_allowed()['buckets'] - if allowed_bucket_identifier is not None: - if allowed_bucket_identifier != value: - raise RestrictedBucket(allowed_bucket_identifier) + if not buckets: + return + + for item in buckets: + if item[key] == value: + return + + msg = str([b['name'] for b in buckets]) + + raise RestrictedBucket(msg) def _populate_bucket_cache_from_key(self): # If the key is restricted to the bucket, pre-populate the cache with it @@ -658,15 +670,16 @@ def _populate_bucket_cache_from_key(self): except MissingAccountData: return - allowed_bucket_id = allowed.get('bucketId') - if allowed_bucket_id is None: + allowed_buckets = allowed.get('buckets') + if not allowed_buckets: return - allowed_bucket_name = allowed.get('bucketName') - # If we have bucketId set we still need to check bucketName. If the bucketName is None, # it means that the bucketId belongs to a bucket that was already removed. - if allowed_bucket_name is None: - raise RestrictedBucketMissing() - self.cache.save_bucket(self.BUCKET_CLASS(self, allowed_bucket_id, name=allowed_bucket_name)) + for item in allowed_buckets: + if item['name'] is None: + raise RestrictedBucketMissing + + for item in allowed_buckets: + self.cache.save_bucket(self.BUCKET_CLASS(self, item['id'], name=item['name'])) diff --git a/b2sdk/_internal/application_key.py b/b2sdk/_internal/application_key.py index c005ee0cd..a2dd1f4af 100644 --- a/b2sdk/_internal/application_key.py +++ b/b2sdk/_internal/application_key.py @@ -20,7 +20,7 @@ def __init__( capabilities: list[str], account_id: str, expiration_timestamp_millis: int | None = None, - bucket_id: str | None = None, + bucket_ids: list[str] | None = None, name_prefix: str | None = None, options: list[str] | None = None, ): @@ -39,7 +39,7 @@ def __init__( self.capabilities = capabilities self.account_id = account_id self.expiration_timestamp_millis = expiration_timestamp_millis - self.bucket_id = bucket_id + self.bucket_ids = bucket_ids self.name_prefix = name_prefix self.options = options @@ -54,7 +54,7 @@ def parse_response_dict(cls, response: dict): optional_args = { 'expiration_timestamp_millis': response.get('expirationTimestamp'), - 'bucket_id': response.get('bucketId'), + 'bucket_ids': response.get('bucketIds'), 'name_prefix': response.get('namePrefix'), 'options': response.get('options'), } @@ -77,7 +77,7 @@ def as_dict(self): } optional_keys = { 'expirationTimestamp': self.expiration_timestamp_millis, - 'bucketId': self.bucket_id, + 'bucketIds': self.bucket_ids, 'namePrefix': self.name_prefix, 'options': self.options, } @@ -107,7 +107,7 @@ def __init__( capabilities: list[str], account_id: str, expiration_timestamp_millis: int | None = None, - bucket_id: str | None = None, + bucket_ids: list[str] | None = None, name_prefix: str | None = None, options: list[str] | None = None, ): @@ -129,7 +129,7 @@ def __init__( capabilities=capabilities, account_id=account_id, expiration_timestamp_millis=expiration_timestamp_millis, - bucket_id=bucket_id, + bucket_ids=bucket_ids, name_prefix=name_prefix, options=options, ) diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index 16764de1d..f27066187 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -369,12 +369,12 @@ def __init__(self, message, code): class RestrictedBucket(B2Error): - def __init__(self, bucket_name): + def __init__(self, bucket_names): super().__init__() - self.bucket_name = bucket_name + self.bucket_names = bucket_names def __str__(self): - return 'Application key is restricted to bucket: %s' % self.bucket_name + return 'Application key is restricted to buckets: %s' % self.bucket_names class RestrictedBucketMissing(RestrictedBucket): diff --git a/b2sdk/_internal/raw_api.py b/b2sdk/_internal/raw_api.py index 4b8207403..0fe2d84dd 100644 --- a/b2sdk/_internal/raw_api.py +++ b/b2sdk/_internal/raw_api.py @@ -79,7 +79,7 @@ ] # API version number to use when calling the service -API_VERSION = 'v3' +API_VERSION = 'v4' logger = getLogger(__name__) @@ -253,7 +253,7 @@ def create_key( capabilities, key_name, valid_duration_seconds, - bucket_id, + bucket_ids, name_prefix, ): pass @@ -538,6 +538,8 @@ class B2RawHTTPApi(AbstractRawApi): which is relatively quick. """ + API_VERSION = API_VERSION + def __init__(self, b2_http): self.b2_http = b2_http @@ -619,7 +621,7 @@ def create_key( capabilities, key_name, valid_duration_seconds, - bucket_id, + bucket_ids, name_prefix, ): return self._post_json( @@ -630,7 +632,7 @@ def create_key( capabilities=capabilities, keyName=key_name, validDurationInSeconds=valid_duration_seconds, - bucketId=bucket_id, + bucketIds=bucket_ids, namePrefix=name_prefix, ) diff --git a/b2sdk/_internal/raw_simulator.py b/b2sdk/_internal/raw_simulator.py index cb4550216..aa757fcd3 100644 --- a/b2sdk/_internal/raw_simulator.py +++ b/b2sdk/_internal/raw_simulator.py @@ -17,7 +17,7 @@ import re import threading import time -from contextlib import contextmanager, suppress +from contextlib import contextmanager from typing import Iterable from requests.structures import CaseInsensitiveDict @@ -99,8 +99,7 @@ def __init__( key, capabilities, expiration_timestamp_or_none, - bucket_id_or_none, - bucket_name_or_none, + buckets_or_none, name_prefix_or_none, ): self.name = name @@ -109,14 +108,19 @@ def __init__( self.key = key self.capabilities = capabilities self.expiration_timestamp_or_none = expiration_timestamp_or_none - self.bucket_id_or_none = bucket_id_or_none - self.bucket_name_or_none = bucket_name_or_none + self.buckets_or_none = buckets_or_none self.name_prefix_or_none = name_prefix_or_none + def _get_bucket_ids(self): + if self.buckets_or_none is None: + return None + + return [item['id'] for item in self.buckets_or_none] + def as_key(self): return dict( accountId=self.account_id, - bucketId=self.bucket_id_or_none, + bucketIds=self._get_bucket_ids(), applicationKeyId=self.application_key_id, capabilities=self.capabilities, expirationTimestamp=self.expiration_timestamp_or_none @@ -133,6 +137,7 @@ def as_created_key(self): """ result = self.as_key() result['applicationKey'] = self.key + return result def get_allowed(self): @@ -140,8 +145,7 @@ def get_allowed(self): Return the 'allowed' structure to include in the response from b2_authorize_account. """ return dict( - bucketId=self.bucket_id_or_none, - bucketName=self.bucket_name_or_none, + buckets=self.buckets_or_none, capabilities=self.capabilities, namePrefix=self.name_prefix_or_none, ) @@ -1371,8 +1375,7 @@ def create_account(self): key=master_key, capabilities=ALL_CAPABILITIES, expiration_timestamp_or_none=None, - bucket_id_or_none=None, - bucket_name_or_none=None, + buckets_or_none=None, name_prefix_or_none=None, ) @@ -1398,12 +1401,9 @@ def authorize_account(self, realm_url, application_key_id, application_key): self.current_token = auth_token self.auth_token_counter += 1 self.auth_token_to_key[auth_token] = key_sim + allowed = key_sim.get_allowed() - bucketId = allowed.get('bucketId') - if (bucketId is not None) and (bucketId in self.bucket_id_to_bucket): - allowed['bucketName'] = self.bucket_id_to_bucket[bucketId].bucket_name - else: - allowed['bucketName'] = None + return dict( accountId=key_sim.account_id, authorizationToken=auth_token, @@ -1416,8 +1416,6 @@ def authorize_account(self, realm_url, application_key_id, application_key): absoluteMinimumPartSize=self.MIN_PART_SIZE, allowed=allowed, s3ApiUrl=self.S3_API_URL, - bucketId=allowed['bucketId'], - bucketName=allowed['bucketName'], capabilities=allowed['capabilities'], namePrefix=allowed['namePrefix'], ), @@ -1476,7 +1474,7 @@ def create_key( capabilities, key_name, valid_duration_seconds, - bucket_id, + bucket_ids, name_prefix, ): if not re.match(r'^[A-Za-z0-9-]{1,100}$', key_name): @@ -1497,12 +1495,18 @@ def create_key( self.app_key_counter += 1 application_key_id = 'appKeyId%d' % (index,) app_key = 'appKey%d' % (index,) - bucket_name_or_none = None - if bucket_id is not None: + + buckets = None + + if bucket_ids is not None: # It is possible for bucketId to be filled and bucketName to be empty. # It can happen when the bucket was deleted. - with suppress(NonExistentBucket): - bucket_name_or_none = self._get_bucket_by_id(bucket_id).bucket_name + buckets = [] + for _id in bucket_ids: + try: + buckets.append({'id': _id, 'name': self._get_bucket_by_id(_id).bucket_name}) + except NonExistentBucket: + buckets.append({'id': _id, 'name': None}) key_sim = KeySimulator( account_id=account_id, @@ -1511,8 +1515,7 @@ def create_key( key=app_key, capabilities=capabilities, expiration_timestamp_or_none=expiration_timestamp_or_none, - bucket_id_or_none=bucket_id, - bucket_name_or_none=bucket_name_or_none, + buckets_or_none=buckets, name_prefix_or_none=name_prefix, ) self.key_id_to_key[application_key_id] = key_sim @@ -2100,8 +2103,17 @@ def _assert_account_auth( raise InvalidAuthToken('auth token expired', 'auth_token_expired') if capability not in key_sim.capabilities: raise Unauthorized('', 'unauthorized') - if key_sim.bucket_id_or_none is not None and key_sim.bucket_id_or_none != bucket_id: - raise Unauthorized('', 'unauthorized') + + if key_sim.buckets_or_none: + found = False + for item in key_sim.buckets_or_none: + if item['id'] == bucket_id: + found = True + break + + if not found: + raise Unauthorized('', 'unauthorized') + if key_sim.name_prefix_or_none is not None: if file_name is not None and not file_name.startswith(key_sim.name_prefix_or_none): raise Unauthorized('', 'unauthorized') diff --git a/b2sdk/_internal/replication/setup.py b/b2sdk/_internal/replication/setup.py index b41502750..3da018d05 100644 --- a/b2sdk/_internal/replication/setup.py +++ b/b2sdk/_internal/replication/setup.py @@ -314,7 +314,7 @@ def _create_key( return api.create_key( capabilities=capabilities, key_name=name, - bucket_id=bucket.id_, + bucket_ids=[bucket.id_], name_prefix=prefix, ) diff --git a/b2sdk/_internal/session.py b/b2sdk/_internal/session.py index 820744980..589c0cffb 100644 --- a/b2sdk/_internal/session.py +++ b/b2sdk/_internal/session.py @@ -118,14 +118,7 @@ def authorize_account(self, realm, application_key_id, application_key): account_id = response['accountId'] storage_api_info = response['apiInfo']['storageApi'] - # `allowed` object has been deprecated in the v3 of the API, but we still - # construct it artificially to avoid changes in all the reliant parts. - allowed = { - 'bucketId': storage_api_info['bucketId'], - 'bucketName': storage_api_info['bucketName'], - 'capabilities': storage_api_info['capabilities'], - 'namePrefix': storage_api_info['namePrefix'], - } + allowed = self._construct_allowed_dict(storage_api_info) # Clear the cache if new account has been used if not self.account_info.is_same_account(account_id, realm): @@ -146,6 +139,12 @@ def authorize_account(self, realm, application_key_id, application_key): application_key_id=application_key_id, ) + def _construct_allowed_dict(self, storage_api_info): + # `allowed` object has been deprecated in the v3 of the API, but we still + # construct it artificially to avoid changes in all the reliant parts. + + return storage_api_info['allowed'] + def cancel_large_file(self, file_id): return self._wrap_default_token(self.raw_api.cancel_large_file, file_id) @@ -175,7 +174,7 @@ def create_bucket( ) def create_key( - self, account_id, capabilities, key_name, valid_duration_seconds, bucket_id, name_prefix + self, account_id, capabilities, key_name, valid_duration_seconds, bucket_ids, name_prefix ): return self._wrap_default_token( self.raw_api.create_key, @@ -183,7 +182,7 @@ def create_key( capabilities, key_name, valid_duration_seconds, - bucket_id, + bucket_ids, name_prefix, ) @@ -506,15 +505,17 @@ def _add_app_key_info_to_unauthorized(self, unauthorized): # What's allowed? allowed = self.account_info.get_allowed() capabilities = allowed['capabilities'] - bucket_name = allowed['bucketName'] name_prefix = allowed['namePrefix'] # Make a list of messages about the application key restrictions key_messages = [] if set(capabilities) != set(ALL_CAPABILITIES): key_messages.append("with capabilities '" + ','.join(capabilities) + "'") - if bucket_name is not None: - key_messages.append("restricted to bucket '" + bucket_name + "'") + + allowed_buckets_msg = self._get_allowed_buckets_message(allowed) + if allowed_buckets_msg: + key_messages.append(allowed_buckets_msg) + if name_prefix is not None: key_messages.append("restricted to files that start with '" + name_prefix + "'") if not key_messages: @@ -526,6 +527,15 @@ def _add_app_key_info_to_unauthorized(self, unauthorized): return Unauthorized(new_message, unauthorized.code) + def _get_allowed_buckets_message(self, allowed) -> str | None: + buckets = allowed['buckets'] + if not buckets: + return None + + bucket_names = [b['name'] for b in buckets] + + return f'restricted to buckets {bucket_names}' + def _get_upload_data(self, bucket_id): """ Take ownership of an upload URL / auth token for the bucket and diff --git a/b2sdk/v2/__init__.py b/b2sdk/v2/__init__.py index 0537cda12..5480e08e7 100644 --- a/b2sdk/v2/__init__.py +++ b/b2sdk/v2/__init__.py @@ -9,10 +9,10 @@ ###################################################################### from __future__ import annotations -from b2sdk._v3 import * # noqa -from b2sdk._v3 import parse_folder as parse_sync_folder -from b2sdk._v3 import AbstractPath as AbstractSyncPath -from b2sdk._v3 import LocalPath as LocalSyncPath +from b2sdk.v3 import * # noqa +from b2sdk.v3 import parse_folder as parse_sync_folder +from b2sdk.v3 import AbstractPath as AbstractSyncPath +from b2sdk.v3 import LocalPath as LocalSyncPath from b2sdk._internal.utils.escape import ( unprintable_to_hex, escape_control_chars, @@ -20,6 +20,10 @@ ) from .account_info import AbstractAccountInfo +from .account_info import InMemoryAccountInfo +from .account_info import SqliteAccountInfo +from .account_info import StubAccountInfo +from .account_info import UrlPoolAccountInfo from .api import B2Api from .b2http import B2Http from .bucket import Bucket, BucketFactory @@ -27,6 +31,16 @@ from .sync import B2SyncPath from .transfer import DownloadManager, UploadManager +# replication + +from .replication.setup import ReplicationSetupHelper + +# data classes + +from .application_key import ApplicationKey +from .application_key import BaseApplicationKey +from .application_key import FullApplicationKey + # utils from .version_utils import rename_argument, rename_function @@ -36,6 +50,7 @@ from .raw_simulator import BucketSimulator from .raw_simulator import RawSimulator +from .raw_simulator import KeySimulator # raw_api diff --git a/b2sdk/v2/account_info.py b/b2sdk/v2/account_info.py index b3992fca1..e7b58d397 100644 --- a/b2sdk/v2/account_info.py +++ b/b2sdk/v2/account_info.py @@ -8,9 +8,87 @@ # ###################################################################### from __future__ import annotations -from b2sdk import _v3 +import json -class AbstractAccountInfo(_v3.AbstractAccountInfo): +from b2sdk import v3 +from .exception import MissingAccountData + + +class _OldAllowedMixin: + DEFAULT_ALLOWED = dict( + bucketId=None, + bucketName=None, + capabilities=v3.ALL_CAPABILITIES, + namePrefix=None, + ) + + @classmethod + def allowed_is_valid(cls, allowed): + return ( + ('bucketId' in allowed) + and ('bucketName' in allowed) + and ((allowed['bucketId'] is not None) or (allowed['bucketName'] is None)) + and ('capabilities' in allowed) + and ('namePrefix' in allowed) + ) + + +class AbstractAccountInfo(_OldAllowedMixin, v3.AbstractAccountInfo): def list_bucket_names_ids(self): return [] # Removed @abstractmethod decorator + + +class UrlPoolAccountInfo(_OldAllowedMixin, v3.UrlPoolAccountInfo): + pass + + +class InMemoryAccountInfo(_OldAllowedMixin, v3.InMemoryAccountInfo): + pass + + +class SqliteAccountInfo(_OldAllowedMixin, v3.SqliteAccountInfo): + def get_allowed(self): + """ + Return 'allowed' dictionary info. + Example: + + .. code-block:: python + + { + "bucketId": null, + "bucketName": null, + "capabilities": [ + "listKeys", + "writeKeys" + ], + "namePrefix": null + } + + The 'allowed' column was not in the original schema, so it may be NULL. + + :rtype: dict + """ + allowed_json = self._get_account_info_or_raise('allowed') + if allowed_json is None: + return self.DEFAULT_ALLOWED + + allowed = json.loads(allowed_json) + + # convert a multi-bucket key to a single bucket + + if 'buckets' in allowed: + buckets = allowed.pop('buckets') + if buckets and len(buckets) > 1: + raise MissingAccountData( + 'Multi-bucket keys cannot be used with the current sdk version' + ) + + allowed['bucketId'] = buckets[0]['id'] if buckets else None + allowed['bucketName'] = buckets[0]['name'] if buckets else None + + return allowed + + +class StubAccountInfo(_OldAllowedMixin, v3.StubAccountInfo): + pass diff --git a/b2sdk/v2/api.py b/b2sdk/v2/api.py index aef3052a9..afd3ecc0d 100644 --- a/b2sdk/v2/api.py +++ b/b2sdk/v2/api.py @@ -8,15 +8,23 @@ # ###################################################################### from __future__ import annotations +from typing import Generator -from b2sdk import _v3 as v3 -from b2sdk._v3.exception import BucketIdNotFound as v3BucketIdNotFound +from b2sdk import v3 +from b2sdk.v3.exception import BucketIdNotFound as v3BucketIdNotFound from .bucket import Bucket, BucketFactory -from .exception import BucketIdNotFound +from .exception import ( + BucketIdNotFound, + RestrictedBucket, + RestrictedBucketMissing, + MissingAccountData, +) +from .raw_api import API_VERSION as RAW_API_VERSION from .session import B2Session from .transfer import DownloadManager, UploadManager from .file_version import FileVersionFactory from .large_file import LargeFileServices +from .application_key import FullApplicationKey, ApplicationKey, BaseApplicationKey class Services(v3.Services): @@ -35,6 +43,9 @@ class B2Api(v3.B2Api): BUCKET_FACTORY_CLASS = staticmethod(BucketFactory) SERVICES_CLASS = staticmethod(Services) FILE_VERSION_FACTORY_CLASS = staticmethod(FileVersionFactory) + APPLICATION_KEY_CLASS = ApplicationKey # type: ignore + FULL_APPLICATION_KEY_CLASS = FullApplicationKey # type: ignore + API_VERSION = RAW_API_VERSION # Legacy init in case something depends on max_workers defaults = 10 def __init__(self, *args, **kwargs): @@ -56,3 +67,75 @@ def authorize_account(self, realm, application_key_id, application_key): application_key=application_key, realm=realm, ) + + def create_key( # type: ignore + self, + capabilities: list[str], + key_name: str, + valid_duration_seconds: int | None = None, + bucket_id: str | None = None, + name_prefix: str | None = None, + ) -> FullApplicationKey: + account_id = self.account_info.get_account_id() + + response = self.session.create_key( + account_id, + capabilities=capabilities, + key_name=key_name, + valid_duration_seconds=valid_duration_seconds, + bucket_id=bucket_id, + name_prefix=name_prefix, + ) + + assert set(response['capabilities']) == set(capabilities) + assert response['keyName'] == key_name + + return self.FULL_APPLICATION_KEY_CLASS.from_create_response(response) + + def delete_key(self, application_key: BaseApplicationKey): # type: ignore + return super().delete_key(application_key) # type: ignore + + def delete_key_by_id(self, application_key_id: str) -> ApplicationKey: # type: ignore + return super().delete_key_by_id(application_key_id) # type: ignore + + def list_keys( # type: ignore + self, start_application_key_id: str | None = None + ) -> Generator[ApplicationKey, None, None]: + return super().list_keys(start_application_key_id) # type: ignore + + def get_key(self, key_id: str) -> ApplicationKey | None: # type: ignore + return super().get_key(key_id) # type: ignore + + def check_bucket_name_restrictions(self, bucket_name: str): + self._check_bucket_restrictions('bucketName', bucket_name) + + def check_bucket_id_restrictions(self, bucket_id: str): + self._check_bucket_restrictions('bucketId', bucket_id) + + def _check_bucket_restrictions(self, key, value): + allowed = self.account_info.get_allowed() + allowed_bucket_identifier = allowed[key] + + if allowed_bucket_identifier is not None: + if allowed_bucket_identifier != value: + raise RestrictedBucket(allowed_bucket_identifier) + + def _populate_bucket_cache_from_key(self): + # If the key is restricted to the bucket, pre-populate the cache with it + try: + allowed = self.account_info.get_allowed() + except MissingAccountData: + return + + allowed_bucket_id = allowed.get('bucketId') + if allowed_bucket_id is None: + return + + allowed_bucket_name = allowed.get('bucketName') + + # If we have bucketId set we still need to check bucketName. If the bucketName is None, + # it means that the bucketId belongs to a bucket that was already removed. + if allowed_bucket_name is None: + raise RestrictedBucketMissing() + + self.cache.save_bucket(self.BUCKET_CLASS(self, allowed_bucket_id, name=allowed_bucket_name)) diff --git a/b2sdk/v2/application_key.py b/b2sdk/v2/application_key.py new file mode 100644 index 000000000..bc79dd883 --- /dev/null +++ b/b2sdk/v2/application_key.py @@ -0,0 +1,153 @@ +###################################################################### +# +# File: b2sdk/v2/application_key.py +# +# Copyright 2025 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations + + +class BaseApplicationKey: + """Common methods for ApplicationKey and FullApplicationKey.""" + + def __init__( + self, + key_name: str, + application_key_id: str, + capabilities: list[str], + account_id: str, + expiration_timestamp_millis: int | None = None, + bucket_id: str | None = None, + name_prefix: str | None = None, + options: list[str] | None = None, + ): + """ + :param key_name: name of the key, assigned by user + :param application_key_id: key id, used to authenticate + :param capabilities: list of capabilities assigned to this key + :param account_id: account's id + :param expiration_timestamp_millis: expiration time of the key + :param bucket_id: if restricted to a bucket, this is the bucket's id + :param name_prefix: if restricted to some files, this is their prefix + :param options: reserved for future use + """ + self.key_name = key_name + self.id_ = application_key_id + self.capabilities = capabilities + self.account_id = account_id + self.expiration_timestamp_millis = expiration_timestamp_millis + self.bucket_id = bucket_id + self.name_prefix = name_prefix + self.options = options + + @classmethod + def parse_response_dict(cls, response: dict): + mandatory_args = { + 'key_name': response['keyName'], + 'application_key_id': response['applicationKeyId'], + 'capabilities': response['capabilities'], + 'account_id': response['accountId'], + } + + optional_args = { + 'expiration_timestamp_millis': response.get('expirationTimestamp'), + 'bucket_id': response.get('bucketId'), + 'name_prefix': response.get('namePrefix'), + 'options': response.get('options'), + } + return { + **mandatory_args, + **{key: value for key, value in optional_args.items() if value is not None}, + } + + def has_capabilities(self, capabilities) -> bool: + """checks whether the key has ALL of the given capabilities""" + return len(set(capabilities) - set(self.capabilities)) == 0 + + def as_dict(self): + """Represent the key as a dict, like the one returned by B2 cloud""" + mandatory_keys = { + 'keyName': self.key_name, + 'applicationKeyId': self.id_, + 'capabilities': self.capabilities, + 'accountId': self.account_id, + } + optional_keys = { + 'expirationTimestamp': self.expiration_timestamp_millis, + 'bucketId': self.bucket_id, + 'namePrefix': self.name_prefix, + 'options': self.options, + } + return { + **mandatory_keys, + **{key: value for key, value in optional_keys.items() if value is not None}, + } + + +class ApplicationKey(BaseApplicationKey): + """Dataclass for storing info about an application key returned by delete-key or list-keys.""" + + @classmethod + def from_api_response(cls, response: dict) -> ApplicationKey: + """Create an ApplicationKey object from a delete-key or list-key response (a parsed json object).""" + return cls(**cls.parse_response_dict(response)) + + +class FullApplicationKey(BaseApplicationKey): + """Dataclass for storing info about an application key, including the actual key, as returned by create-key.""" + + def __init__( + self, + key_name: str, + application_key_id: str, + application_key: str, + capabilities: list[str], + account_id: str, + expiration_timestamp_millis: int | None = None, + bucket_id: str | None = None, + name_prefix: str | None = None, + options: list[str] | None = None, + ): + """ + :param key_name: name of the key, assigned by user + :param application_key_id: key id, used to authenticate + :param application_key: the actual secret key + :param capabilities: list of capabilities assigned to this key + :param account_id: account's id + :param expiration_timestamp_millis: expiration time of the key + :param bucket_id: if restricted to a bucket, this is the bucket's id + :param name_prefix: if restricted to some files, this is their prefix + :param options: reserved for future use + """ + self.application_key = application_key + super().__init__( + key_name=key_name, + application_key_id=application_key_id, + capabilities=capabilities, + account_id=account_id, + expiration_timestamp_millis=expiration_timestamp_millis, + bucket_id=bucket_id, + name_prefix=name_prefix, + options=options, + ) + + @classmethod + def from_create_response(cls, response: dict) -> FullApplicationKey: + """Create a FullApplicationKey object from a create-key response (a parsed json object).""" + return cls(**cls.parse_response_dict(response)) + + @classmethod + def parse_response_dict(cls, response: dict): + result = super().parse_response_dict(response) + result['application_key'] = response['applicationKey'] + return result + + def as_dict(self): + """Represent the key as a dict, like the one returned by B2 cloud""" + return { + **super().as_dict(), + 'applicationKey': self.application_key, + } diff --git a/b2sdk/v2/b2http.py b/b2sdk/v2/b2http.py index e6a26e6af..1ed07332b 100644 --- a/b2sdk/v2/b2http.py +++ b/b2sdk/v2/b2http.py @@ -9,8 +9,8 @@ ###################################################################### from __future__ import annotations -from b2sdk import _v3 as v3 -from b2sdk._v3.exception import BucketIdNotFound as v3BucketIdNotFound +from b2sdk import v3 +from b2sdk.v3.exception import BucketIdNotFound as v3BucketIdNotFound from .exception import BucketIdNotFound diff --git a/b2sdk/v2/bucket.py b/b2sdk/v2/bucket.py index 66a35d76f..931b201ab 100644 --- a/b2sdk/v2/bucket.py +++ b/b2sdk/v2/bucket.py @@ -11,8 +11,8 @@ import typing -from b2sdk import _v3 as v3 -from b2sdk._v3.exception import BucketIdNotFound as v3BucketIdNotFound +from b2sdk import v3 +from b2sdk.v3.exception import BucketIdNotFound as v3BucketIdNotFound from b2sdk.v2._compat import _file_infos_rename from b2sdk._internal.http_constants import LIST_FILE_NAMES_MAX_LIMIT from .exception import BucketIdNotFound diff --git a/b2sdk/v2/exception.py b/b2sdk/v2/exception.py index fad9437ea..5153011b1 100644 --- a/b2sdk/v2/exception.py +++ b/b2sdk/v2/exception.py @@ -9,7 +9,7 @@ ###################################################################### from __future__ import annotations -from b2sdk._v3.exception import * # noqa +from b2sdk.v3.exception import * # noqa v3BucketIdNotFound = BucketIdNotFound UnSyncableFilename = UnsupportedFilename @@ -24,3 +24,12 @@ def __init__(self, bucket_id): def __str__(self): return BadRequest.__str__(self) + + +class RestrictedBucket(B2Error): + def __init__(self, bucket_name): + super().__init__() + self.bucket_name = bucket_name + + def __str__(self): + return 'Application key is restricted to bucket: %s' % self.bucket_name diff --git a/b2sdk/v2/file_version.py b/b2sdk/v2/file_version.py index db800d216..46008c177 100644 --- a/b2sdk/v2/file_version.py +++ b/b2sdk/v2/file_version.py @@ -15,7 +15,7 @@ from b2sdk.v2 import NO_RETENTION_FILE_SETTING, FileRetentionSetting, LegalHold from b2sdk.v2 import ReplicationStatus -from b2sdk import _v3 as v3 +from b2sdk import v3 if TYPE_CHECKING: from .api import B2Api diff --git a/b2sdk/v2/large_file.py b/b2sdk/v2/large_file.py index ee24c631c..06559e78d 100644 --- a/b2sdk/v2/large_file.py +++ b/b2sdk/v2/large_file.py @@ -9,7 +9,7 @@ ###################################################################### from __future__ import annotations -from b2sdk import _v3 as v3 +from b2sdk import v3 class UnfinishedLargeFile(v3.UnfinishedLargeFile): diff --git a/b2sdk/v2/raw_api.py b/b2sdk/v2/raw_api.py index 5003f133c..7ce4912a7 100644 --- a/b2sdk/v2/raw_api.py +++ b/b2sdk/v2/raw_api.py @@ -8,10 +8,13 @@ # ###################################################################### from __future__ import annotations +from abc import abstractmethod -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk.v2._compat import _file_infos_rename +API_VERSION = 'v3' + class _OldRawAPI: """RawAPI compatibility layer""" @@ -92,10 +95,48 @@ def upload_file( **kwargs, ) + def get_download_url_by_id(self, download_url, file_id): + return f'{download_url}/b2api/{API_VERSION}/b2_download_file_by_id?fileId={file_id}' + class AbstractRawApi(_OldRawAPI, v3.AbstractRawApi): - pass + @abstractmethod + def create_key( + self, + api_url, + account_auth_token, + account_id, + capabilities, + key_name, + valid_duration_seconds, + bucket_id, + name_prefix, + ): + pass class B2RawHTTPApi(_OldRawAPI, v3.B2RawHTTPApi): - pass + API_VERSION = API_VERSION + + def create_key( + self, + api_url, + account_auth_token, + account_id, + capabilities, + key_name, + valid_duration_seconds, + bucket_id, + name_prefix, + ): + return self._post_json( + api_url, + 'b2_create_key', + account_auth_token, + accountId=account_id, + capabilities=capabilities, + keyName=key_name, + validDurationInSeconds=valid_duration_seconds, + bucketId=bucket_id, + namePrefix=name_prefix, + ) diff --git a/b2sdk/v2/raw_simulator.py b/b2sdk/v2/raw_simulator.py index 95f18ecd6..6c2ddcd75 100644 --- a/b2sdk/v2/raw_simulator.py +++ b/b2sdk/v2/raw_simulator.py @@ -8,10 +8,67 @@ # ###################################################################### from __future__ import annotations +import re +import time +from contextlib import suppress -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk.v2._compat import _file_infos_rename +from b2sdk.v2.exception import BadJson, NonExistentBucket, InvalidAuthToken, Unauthorized + + +class KeySimulator(v3.KeySimulator): + """ + Hold information about one application key, which can be either + a master application key, or one created with create_key(). + """ + + def __init__( + self, + account_id, + name, + application_key_id, + key, + capabilities, + expiration_timestamp_or_none, + bucket_id_or_none, + bucket_name_or_none, + name_prefix_or_none, + ): + self.name = name + self.account_id = account_id + self.application_key_id = application_key_id + self.key = key + self.capabilities = capabilities + self.expiration_timestamp_or_none = expiration_timestamp_or_none + self.bucket_id_or_none = bucket_id_or_none + self.bucket_name_or_none = bucket_name_or_none + self.name_prefix_or_none = name_prefix_or_none + + def as_key(self): + return dict( + accountId=self.account_id, + bucketId=self.bucket_id_or_none, + applicationKeyId=self.application_key_id, + capabilities=self.capabilities, + expirationTimestamp=self.expiration_timestamp_or_none + and self.expiration_timestamp_or_none * 1000, + keyName=self.name, + namePrefix=self.name_prefix_or_none, + ) + + def get_allowed(self): + """ + Return the 'allowed' structure to include in the response from b2_authorize_account. + """ + return dict( + bucketId=self.bucket_id_or_none, + bucketName=self.bucket_name_or_none, + capabilities=self.capabilities, + namePrefix=self.name_prefix_or_none, + ) + class BucketSimulator(v3.BucketSimulator): @_file_infos_rename @@ -126,3 +183,134 @@ def upload_file( *args, **kwargs, ) + + def create_account(self): + """ + Simulate creating an account. + + Return (accountId, masterApplicationKey) for a newly created account. + """ + # Pick the IDs for the account and the key + account_id = 'account-%d' % (self.account_counter,) + master_key = 'masterKey-%d' % (self.account_counter,) + self.account_counter += 1 + + # Create the key + self.key_id_to_key[account_id] = KeySimulator( + account_id=account_id, + name='master', + application_key_id=account_id, + key=master_key, + capabilities=v3.ALL_CAPABILITIES, + expiration_timestamp_or_none=None, + bucket_id_or_none=None, + bucket_name_or_none=None, + name_prefix_or_none=None, + ) + + # Return the info + return (account_id, master_key) + + def create_key( + self, + api_url, + account_auth_token, + account_id, + capabilities, + key_name, + valid_duration_seconds, + bucket_id, + name_prefix, + ): + if not re.match(r'^[A-Za-z0-9-]{1,100}$', key_name): + raise BadJson('illegal key name: ' + key_name) + if valid_duration_seconds is not None: + if valid_duration_seconds < 1 or valid_duration_seconds > self.MAX_DURATION_IN_SECONDS: + raise BadJson( + 'valid duration must be greater than 0, and less than 1000 days in seconds' + ) + self._assert_account_auth(api_url, account_auth_token, account_id, 'writeKeys') + + if valid_duration_seconds is None: + expiration_timestamp_or_none = None + else: + expiration_timestamp_or_none = int(time.time() + valid_duration_seconds) + + index = self.app_key_counter + self.app_key_counter += 1 + application_key_id = 'appKeyId%d' % (index,) + app_key = 'appKey%d' % (index,) + bucket_name_or_none = None + if bucket_id is not None: + # It is possible for bucketId to be filled and bucketName to be empty. + # It can happen when the bucket was deleted. + with suppress(NonExistentBucket): + bucket_name_or_none = self._get_bucket_by_id(bucket_id).bucket_name + + key_sim = KeySimulator( + account_id=account_id, + name=key_name, + application_key_id=application_key_id, + key=app_key, + capabilities=capabilities, + expiration_timestamp_or_none=expiration_timestamp_or_none, + bucket_id_or_none=bucket_id, + bucket_name_or_none=bucket_name_or_none, + name_prefix_or_none=name_prefix, + ) + self.key_id_to_key[application_key_id] = key_sim + self.all_application_keys.append(key_sim) + return key_sim.as_created_key() + + def _assert_account_auth( + self, api_url, account_auth_token, account_id, capability, bucket_id=None, file_name=None + ): + key_sim = self.auth_token_to_key.get(account_auth_token) + assert key_sim is not None + assert api_url == self.API_URL + assert account_id == key_sim.account_id + if account_auth_token in self.expired_auth_tokens: + raise InvalidAuthToken('auth token expired', 'auth_token_expired') + if capability not in key_sim.capabilities: + raise Unauthorized('', 'unauthorized') + if key_sim.bucket_id_or_none is not None and key_sim.bucket_id_or_none != bucket_id: + raise Unauthorized('', 'unauthorized') + if key_sim.name_prefix_or_none is not None: + if file_name is not None and not file_name.startswith(key_sim.name_prefix_or_none): + raise Unauthorized('', 'unauthorized') + + def authorize_account(self, realm_url, application_key_id, application_key): + key_sim = self.key_id_to_key.get(application_key_id) + if key_sim is None: + raise InvalidAuthToken('application key ID not valid', 'unauthorized') + if application_key != key_sim.key: + raise InvalidAuthToken('secret key is wrong', 'unauthorized') + auth_token = 'auth_token_%d' % (self.auth_token_counter,) + self.current_token = auth_token + self.auth_token_counter += 1 + self.auth_token_to_key[auth_token] = key_sim + allowed = key_sim.get_allowed() + bucketId = allowed.get('bucketId') + if (bucketId is not None) and (bucketId in self.bucket_id_to_bucket): + allowed['bucketName'] = self.bucket_id_to_bucket[bucketId].bucket_name + else: + allowed['bucketName'] = None + return dict( + accountId=key_sim.account_id, + authorizationToken=auth_token, + apiInfo=dict( + groupsApi=dict(), + storageApi=dict( + apiUrl=self.API_URL, + downloadUrl=self.DOWNLOAD_URL, + recommendedPartSize=self.MIN_PART_SIZE, + absoluteMinimumPartSize=self.MIN_PART_SIZE, + allowed=allowed, + s3ApiUrl=self.S3_API_URL, + bucketId=allowed['bucketId'], + bucketName=allowed['bucketName'], + capabilities=allowed['capabilities'], + namePrefix=allowed['namePrefix'], + ), + ), + ) diff --git a/b2sdk/v2/replication/__init__.py b/b2sdk/v2/replication/__init__.py new file mode 100644 index 000000000..c9d905358 --- /dev/null +++ b/b2sdk/v2/replication/__init__.py @@ -0,0 +1,10 @@ +###################################################################### +# +# File: b2sdk/v2/replication/__init__.py +# +# Copyright 2025 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations diff --git a/b2sdk/v2/replication/setup.py b/b2sdk/v2/replication/setup.py new file mode 100644 index 000000000..1a74c9d6f --- /dev/null +++ b/b2sdk/v2/replication/setup.py @@ -0,0 +1,106 @@ +###################################################################### +# +# File: b2sdk/v2/replication/setup.py +# +# Copyright 2025 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations + +from b2sdk._internal.replication.setup import * # noqa + +from b2sdk import v3 as v3 + +from ..bucket import Bucket + +from ..application_key import ApplicationKey # type: ignore + + +class ReplicationSetupHelper(v3.ReplicationSetupHelper): # type: ignore + def setup_source( + self, + source_bucket: Bucket, + source_key: ApplicationKey, + destination_bucket: Bucket, + prefix: str | None = None, + name: str | None = None, + priority: int = None, + include_existing_files: bool = False, + ) -> Bucket: + return super().setup_source( + source_bucket=source_bucket, + source_key=source_key, + destination_bucket=destination_bucket, + prefix=prefix, + name=name, + priority=priority, + include_existing_files=include_existing_files, + ) + + @classmethod + def _get_source_key( + cls, + source_bucket: Bucket, + prefix: str, + current_replication_configuration: ReplicationConfiguration, + ) -> ApplicationKey: + return super()._get_source_key( + source_bucket=source_bucket, + prefix=prefix, + current_replication_configuration=current_replication_configuration, + ) + + @classmethod + def _should_make_new_source_key( + cls, + current_replication_configuration: ReplicationConfiguration, + current_source_key: ApplicationKey | None, + ) -> bool: + return super()._should_make_new_source_key( + current_replication_configuration=current_replication_configuration, + current_source_key=current_source_key, + ) + + @classmethod + def _create_source_key( + cls, + name: str, + bucket: Bucket, + prefix: str | None = None, + ) -> ApplicationKey: + return super()._create_source_key( + name=name, + bucket=bucket, + prefix=prefix, + ) + + @classmethod + def _create_destination_key( + cls, + name: str, + bucket: Bucket, + prefix: str | None = None, + ) -> ApplicationKey: + return super()._create_destination_key( + name=name, + bucket=bucket, + prefix=prefix, + ) + + @classmethod + def _create_key( + cls, + name: str, + bucket: Bucket, + prefix: str | None = None, + capabilities=tuple(), + ) -> ApplicationKey: + api: B2Api = bucket.api + return api.create_key( + capabilities=capabilities, + key_name=name, + bucket_id=bucket.id_, + name_prefix=prefix, + ) diff --git a/b2sdk/v2/session.py b/b2sdk/v2/session.py index d62baab18..a6ce6d6e9 100644 --- a/b2sdk/v2/session.py +++ b/b2sdk/v2/session.py @@ -9,7 +9,7 @@ ###################################################################### from __future__ import annotations -from b2sdk import _v3 as v3 +from b2sdk import v3 from .b2http import B2Http from ._compat import _file_infos_rename @@ -33,6 +33,19 @@ def __init__( cache = _cache.DummyCache() super().__init__(account_info, cache, api_config) + def create_key( + self, account_id, capabilities, key_name, valid_duration_seconds, bucket_id, name_prefix + ): + return self._wrap_default_token( + self.raw_api.create_key, + account_id, + capabilities, + key_name, + valid_duration_seconds, + bucket_id, + name_prefix, + ) + @_file_infos_rename def upload_file( self, @@ -68,3 +81,18 @@ def upload_file( *args, **kwargs, ) + + def _construct_allowed_dict(self, storage_api_info): + return { + 'bucketId': storage_api_info['bucketId'], + 'bucketName': storage_api_info['bucketName'], + 'capabilities': storage_api_info['capabilities'], + 'namePrefix': storage_api_info['namePrefix'], + } + + def _get_allowed_buckets_message(self, allowed) -> str | None: + bucket_name = allowed['bucketName'] + if bucket_name is None: + return None + + return "restricted to bucket '" + bucket_name + "'" diff --git a/b2sdk/v2/sync.py b/b2sdk/v2/sync.py index ea81cc534..86341d8ae 100644 --- a/b2sdk/v2/sync.py +++ b/b2sdk/v2/sync.py @@ -9,6 +9,6 @@ ###################################################################### from __future__ import annotations -from b2sdk._v3 import B2Path +from b2sdk.v3 import B2Path B2SyncPath = B2Path diff --git a/b2sdk/v2/transfer.py b/b2sdk/v2/transfer.py index 1ad2d5bfa..7e60e0680 100644 --- a/b2sdk/v2/transfer.py +++ b/b2sdk/v2/transfer.py @@ -9,7 +9,7 @@ ###################################################################### from __future__ import annotations -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk._internal.utils.thread_pool import LazyThreadPool # noqa: F401 diff --git a/b2sdk/v2/version_utils.py b/b2sdk/v2/version_utils.py index 527997db7..c3ce1c886 100644 --- a/b2sdk/v2/version_utils.py +++ b/b2sdk/v2/version_utils.py @@ -9,7 +9,7 @@ ###################################################################### from __future__ import annotations -from b2sdk import _v3 as v3 +from b2sdk import v3 class _OldAbstractDeprecatorMixin: diff --git a/b2sdk/_v3/__init__.py b/b2sdk/v3/__init__.py similarity index 99% rename from b2sdk/_v3/__init__.py rename to b2sdk/v3/__init__.py index b0633d08f..e56691c57 100644 --- a/b2sdk/_v3/__init__.py +++ b/b2sdk/v3/__init__.py @@ -1,6 +1,6 @@ ###################################################################### # -# File: b2sdk/_v3/__init__.py +# File: b2sdk/v3/__init__.py # # Copyright 2021 Backblaze Inc. All Rights Reserved. # diff --git a/b2sdk/_v3/exception.py b/b2sdk/v3/exception.py similarity index 99% rename from b2sdk/_v3/exception.py rename to b2sdk/v3/exception.py index 379efd982..061fc385e 100644 --- a/b2sdk/_v3/exception.py +++ b/b2sdk/v3/exception.py @@ -1,6 +1,6 @@ ###################################################################### # -# File: b2sdk/_v3/exception.py +# File: b2sdk/v3/exception.py # # Copyright 2021 Backblaze Inc. All Rights Reserved. # diff --git a/changelog.d/+apiver_v3.changed.md b/changelog.d/+apiver_v3.changed.md new file mode 100644 index 000000000..29a2a7493 --- /dev/null +++ b/changelog.d/+apiver_v3.changed.md @@ -0,0 +1 @@ +Release apiver v3 interface. `from b2sdk.v3 import ...` is now the recommended import, but previous versions are still supported. \ No newline at end of file diff --git a/changelog.d/+application_keys_multi_bucket.changed.md b/changelog.d/+application_keys_multi_bucket.changed.md new file mode 100644 index 000000000..d0719eabd --- /dev/null +++ b/changelog.d/+application_keys_multi_bucket.changed.md @@ -0,0 +1 @@ +Update application key classes to support multiple bucket ids. \ No newline at end of file diff --git a/changelog.d/+authorize_account.changed.md b/changelog.d/+authorize_account.changed.md new file mode 100644 index 000000000..a9802a281 --- /dev/null +++ b/changelog.d/+authorize_account.changed.md @@ -0,0 +1 @@ +Adapt authorize_account flow to multi-bucket keys. \ No newline at end of file diff --git a/changelog.d/+b2_api_v4.changed.md b/changelog.d/+b2_api_v4.changed.md new file mode 100644 index 000000000..6a98265d0 --- /dev/null +++ b/changelog.d/+b2_api_v4.changed.md @@ -0,0 +1 @@ +Migrate to b2 native api v4. diff --git a/changelog.d/+create_key_multi_bucket.changed.md b/changelog.d/+create_key_multi_bucket.changed.md new file mode 100644 index 000000000..6d9818aa9 --- /dev/null +++ b/changelog.d/+create_key_multi_bucket.changed.md @@ -0,0 +1 @@ +Update create_key flow to multi-bucket keys. \ No newline at end of file diff --git a/changelog.d/+integration_tests_apiver_v3.infrastructure.md b/changelog.d/+integration_tests_apiver_v3.infrastructure.md new file mode 100644 index 000000000..54ff82138 --- /dev/null +++ b/changelog.d/+integration_tests_apiver_v3.infrastructure.md @@ -0,0 +1 @@ +Migrate integration tests to apiver v3. \ No newline at end of file diff --git a/changelog.d/+url_for_api.changed.md b/changelog.d/+url_for_api.changed.md new file mode 100644 index 000000000..5e4f16c87 --- /dev/null +++ b/changelog.d/+url_for_api.changed.md @@ -0,0 +1 @@ +Move url_for_api func to an internal classmethod in B2Api class. diff --git a/doc/source/advanced.rst b/doc/source/advanced.rst index b43976589..5b78c8f9c 100644 --- a/doc/source/advanced.rst +++ b/doc/source/advanced.rst @@ -21,17 +21,17 @@ Available methods +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ | Method / supported options | Source | Range |br| overlap | Streaming |br| interface | :ref:`Continuation ` | +============================================+========+=====================+==========================+====================================+ -| :meth:`b2sdk.v2.Bucket.upload` | local | no | no | automatic | +| :meth:`b2sdk.v3.Bucket.upload` | local | no | no | automatic | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ -| :meth:`b2sdk.v2.Bucket.copy` | remote | no | no | automatic | +| :meth:`b2sdk.v3.Bucket.copy` | remote | no | no | automatic | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ -| :meth:`b2sdk.v2.Bucket.concatenate` | any | no | no | automatic | +| :meth:`b2sdk.v3.Bucket.concatenate` | any | no | no | automatic | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ -| :meth:`b2sdk.v2.Bucket.concatenate_stream` | any | no | yes | manual | +| :meth:`b2sdk.v3.Bucket.concatenate_stream` | any | no | yes | manual | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ -| :meth:`b2sdk.v2.Bucket.create_file` | any | yes | no | automatic | +| :meth:`b2sdk.v3.Bucket.create_file` | any | yes | no | automatic | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ -| :meth:`b2sdk.v2.Bucket.create_file_stream` | any | yes | yes | manual | +| :meth:`b2sdk.v3.Bucket.create_file_stream` | any | yes | yes | manual | +--------------------------------------------+--------+---------------------+--------------------------+------------------------------------+ Range overlap @@ -57,9 +57,9 @@ Please see :ref:`here ` Concatenate files ***************** -:meth:`b2sdk.v2.Bucket.concatenate` accepts an iterable of upload sources (either local or remote). It can be used to glue remote files together, back-to-back, into a new file. +:meth:`b2sdk.v3.Bucket.concatenate` accepts an iterable of upload sources (either local or remote). It can be used to glue remote files together, back-to-back, into a new file. -:meth:`b2sdk.v2.Bucket.concatenate_stream` does not create and validate a plan before starting the transfer, so it can be used to process a large input iterator, at a cost of limited automated continuation. +:meth:`b2sdk.v3.Bucket.concatenate_stream` does not create and validate a plan before starting the transfer, so it can be used to process a large input iterator, at a cost of limited automated continuation. Concatenate files of known size @@ -79,15 +79,15 @@ Concatenate files of known size If one of remote source has length smaller than :term:`absoluteMinimumPartSize` then it cannot be copied into large file part. Such remote source would be downloaded and concatenated locally with local source or with other downloaded remote source. -Please note that this method only allows checksum verification for local upload sources. Checksum verification for remote sources is available only when local copy is available. In such case :meth:`b2sdk.v2.Bucket.create_file` can be used with overalapping ranges in input. +Please note that this method only allows checksum verification for local upload sources. Checksum verification for remote sources is available only when local copy is available. In such case :meth:`b2sdk.v3.Bucket.create_file` can be used with overalapping ranges in input. -For more information about ``concatenate`` please see :meth:`b2sdk.v2.Bucket.concatenate` and :class:`b2sdk.v2.CopySource`. +For more information about ``concatenate`` please see :meth:`b2sdk.v3.Bucket.concatenate` and :class:`b2sdk.v3.CopySource`. Concatenate files of known size (streamed version) ================================================== -:meth:`b2sdk.v2.Bucket.concatenate` accepts an iterable of upload sources (either local or remote). The operation would not be planned ahead so it supports very large output objects, but continuation is only possible for local only sources and provided unfinished large file id. See more about continuation in :meth:`b2sdk.v2.Bucket.create_file` paragraph about continuation. +:meth:`b2sdk.v3.Bucket.concatenate` accepts an iterable of upload sources (either local or remote). The operation would not be planned ahead so it supports very large output objects, but continuation is only possible for local only sources and provided unfinished large file id. See more about continuation in :meth:`b2sdk.v3.Bucket.create_file` paragraph about continuation. .. code-block:: python @@ -118,7 +118,7 @@ Using methods described below an object can be created from both local and remot Update a file efficiently ==================================== -:meth:`b2sdk.v2.Bucket.create_file` accepts an iterable which *can contain overlapping destination ranges*. +:meth:`b2sdk.v3.Bucket.create_file` accepts an iterable which *can contain overlapping destination ranges*. .. note:: Following examples *create* new file - data in bucket is immutable, but **b2sdk** can create a new file version with the same name and updated content @@ -150,9 +150,9 @@ The assumption here is that the file has been appended to since it was last uplo >>> bucket.create_file(input_sources, remote_name, file_info) -`LocalUploadSource` has the size determined automatically in this case. This is more efficient than :meth:`b2sdk.v2.Bucket.concatenate`, as it can use the overlapping ranges when a remote part is smaller than :term:`absoluteMinimumPartSize` to prevent downloading a range (when concatenating, local source would have destination offset at the end of remote source) +`LocalUploadSource` has the size determined automatically in this case. This is more efficient than :meth:`b2sdk.v3.Bucket.concatenate`, as it can use the overlapping ranges when a remote part is smaller than :term:`absoluteMinimumPartSize` to prevent downloading a range (when concatenating, local source would have destination offset at the end of remote source) -For more information see :meth:`b2sdk.v2.Bucket.create_file`. +For more information see :meth:`b2sdk.v3.Bucket.create_file`. Change the middle of the remote file @@ -179,9 +179,9 @@ Change the middle of the remote file >>> bucket.create_file(input_sources, remote_name, file_info) -`LocalUploadSource` has the size determined automatically in this case. This is more efficient than :meth:`b2sdk.v2.Bucket.concatenate`, as it can use the overlapping ranges when a remote part is smaller than :term:`absoluteMinimumPartSize` to prevent downloading a range. +`LocalUploadSource` has the size determined automatically in this case. This is more efficient than :meth:`b2sdk.v3.Bucket.concatenate`, as it can use the overlapping ranges when a remote part is smaller than :term:`absoluteMinimumPartSize` to prevent downloading a range. -For more information see :meth:`b2sdk.v2.Bucket.create_file`. +For more information see :meth:`b2sdk.v3.Bucket.create_file`. Synthesize a file from local and remote parts @@ -193,7 +193,7 @@ This is useful for expert usage patterns such as: - mostly-server-side cutting and gluing uncompressed media files such as `wav` and `avi` with rewriting of file headers - various deduplicated backup scenarios -Please note that :meth:`b2sdk.v2.Bucket.create_file_stream` accepts **an ordered iterable** which *can contain overlapping ranges*, so the operation does not need to be planned ahead, but can be streamed, which supports very large output objects. +Please note that :meth:`b2sdk.v3.Bucket.create_file_stream` accepts **an ordered iterable** which *can contain overlapping ranges*, so the operation does not need to be planned ahead, but can be streamed, which supports very large output objects. Scenarios such as below are then possible: @@ -239,19 +239,19 @@ Scenarios such as below are then possible: -In such case, if the sizes allow for it (there would be no parts smaller than :term:`absoluteMinimumPartSize`), the only uploaded part will be `C-D`. Otherwise, more data will be uploaded, but the data transfer will be reduced in most cases. :meth:`b2sdk.v2.Bucket.create_file` does not guarantee that outbound transfer usage would be optimal, it uses a simple greedy algorithm with as small look-aheads as possible. +In such case, if the sizes allow for it (there would be no parts smaller than :term:`absoluteMinimumPartSize`), the only uploaded part will be `C-D`. Otherwise, more data will be uploaded, but the data transfer will be reduced in most cases. :meth:`b2sdk.v3.Bucket.create_file` does not guarantee that outbound transfer usage would be optimal, it uses a simple greedy algorithm with as small look-aheads as possible. -For more information see :meth:`b2sdk.v2.Bucket.create_file`. +For more information see :meth:`b2sdk.v3.Bucket.create_file`. Encryption ---------- -Even if files `A-C` and `D-G` are encrypted using `SSE-C` with different keys, they can still be used in a single :meth:`b2sdk.v2.Bucket.create_file` call, because :class:`b2sdk.v2.CopySource` accepts an optional :class:`b2sdk.v2.EncryptionSetting`. +Even if files `A-C` and `D-G` are encrypted using `SSE-C` with different keys, they can still be used in a single :meth:`b2sdk.v3.Bucket.create_file` call, because :class:`b2sdk.v3.CopySource` accepts an optional :class:`b2sdk.v3.EncryptionSetting`. Prioritize remote or local sources ---------------------------------- -:meth:`b2sdk.v2.Bucket.create_file` and :meth:`b2sdk.v2.Bucket.create_file_stream` support source/origin prioritization, so that planner would know which sources should be used for overlapping ranges. Supported values are: `local`, `remote` and `local_verification`. +:meth:`b2sdk.v3.Bucket.create_file` and :meth:`b2sdk.v3.Bucket.create_file_stream` support source/origin prioritization, so that planner would know which sources should be used for overlapping ranges. Supported values are: `local`, `remote` and `local_verification`. .. code-block:: @@ -324,7 +324,7 @@ If that is not available, ``large_file_id`` can be extracted via callback during Continuation of create/concatenate =================================== -:meth:`b2sdk.v2.Bucket.create_file` supports automatic continuation or manual continuation. :meth:`b2sdk.v2.Bucket.create_file_stream` supports only manual continuation for local-only inputs. The situation looks the same for :meth:`b2sdk.v2.Bucket.concatenate` and :meth:`b2sdk.v2.Bucket.concatenate_stream` (streamed version supports only manual continuation of local sources). Also :meth:`b2sdk.v2.Bucket.upload` and :meth:`b2sdk.v2.Bucket.copy` support both automatic and manual continuation. +:meth:`b2sdk.v3.Bucket.create_file` supports automatic continuation or manual continuation. :meth:`b2sdk.v3.Bucket.create_file_stream` supports only manual continuation for local-only inputs. The situation looks the same for :meth:`b2sdk.v3.Bucket.concatenate` and :meth:`b2sdk.v3.Bucket.concatenate_stream` (streamed version supports only manual continuation of local sources). Also :meth:`b2sdk.v3.Bucket.upload` and :meth:`b2sdk.v3.Bucket.copy` support both automatic and manual continuation. Manual continuation ------------------- @@ -353,7 +353,7 @@ Manual continuation (streamed version) >>> large_file_id = storage.query({'name': remote_name})[0]['large_file_id'] >>> bucket.create_file_stream(input_sources, remote_name, file_info, large_file_id=large_file_id) -Streams that contains remote sources cannot be continued with :meth:`b2sdk.v2.Bucket.create_file` - internally :meth:`b2sdk.v2.Bucket.create_file` stores plan information in file info for such inputs, and verifies it before any copy/upload and :meth:`b2sdk.v2.Bucket.create_file_stream` cannot store this information. Local source only inputs can be safely continued with :meth:`b2sdk.v2.Bucket.create_file` in auto continue mode or manual continue mode (because plan information is not stored in file info in such case). +Streams that contains remote sources cannot be continued with :meth:`b2sdk.v3.Bucket.create_file` - internally :meth:`b2sdk.v3.Bucket.create_file` stores plan information in file info for such inputs, and verifies it before any copy/upload and :meth:`b2sdk.v3.Bucket.create_file_stream` cannot store this information. Local source only inputs can be safely continued with :meth:`b2sdk.v3.Bucket.create_file` in auto continue mode or manual continue mode (because plan information is not stored in file info in such case). Auto continuation ----------------- @@ -362,9 +362,9 @@ Auto continuation >>> bucket.create_file(input_sources, remote_name, file_info) -For local source only input, :meth:`b2sdk.v2.Bucket.create_file` would try to find matching unfinished large file. It will verify uploaded parts checksums with local sources - the most completed, having all uploaded parts matched candidate would be automatically selected as file to continue. If there is no matching candidate (even if there are unfinished files for the same file name) new large file would be started. +For local source only input, :meth:`b2sdk.v3.Bucket.create_file` would try to find matching unfinished large file. It will verify uploaded parts checksums with local sources - the most completed, having all uploaded parts matched candidate would be automatically selected as file to continue. If there is no matching candidate (even if there are unfinished files for the same file name) new large file would be started. -In other cases plan information would be generated and :meth:`b2sdk.v2.Bucket.create_file` would try to find unfinished large file with matching plan info in its file info. If there is one or more such unfinished large files, :meth:`b2sdk.v2.Bucket.create_file` would verify checksums for all locally available parts and choose any matching candidate. If all candidates fails on uploaded parts checksums verification, process is interrupted and error raises. In such case corrupted unfinished large files should be cancelled manullay and :meth:`b2sdk.v2.Bucket.create_file` should be retried, or auto continuation should be turned off with `auto_continue=False` +In other cases plan information would be generated and :meth:`b2sdk.v3.Bucket.create_file` would try to find unfinished large file with matching plan info in its file info. If there is one or more such unfinished large files, :meth:`b2sdk.v3.Bucket.create_file` would verify checksums for all locally available parts and choose any matching candidate. If all candidates fails on uploaded parts checksums verification, process is interrupted and error raises. In such case corrupted unfinished large files should be cancelled manullay and :meth:`b2sdk.v3.Bucket.create_file` should be retried, or auto continuation should be turned off with `auto_continue=False` No continuation @@ -384,6 +384,6 @@ SHA-1 hashes for large files Depending on the number and size of sources and the size of the result file, the SDK may decide to use the large file API to create a file on the server. In such cases the file's SHA-1 won't be stored on the server in the ``X-Bz-Content-Sha1`` header, but it may optionally be stored with the file in the ``large_file_sha1`` entry in the ``file_info``, as per [B2 integration checklist](https://www.backblaze.com/b2/docs/integration_checklist.html). -In basic scenarios, large files uploaded to the server will have a ``large_file_sha1`` element added automatically to their ``file_info``. However, when concatenating multiple sources, it may be impossible for the SDK to figure out the SHA-1 automatically. In such cases, the SHA-1 can be provided using the ``large_file_sha1`` parameter to :meth:`b2sdk.v2.Bucket.create_file`, :meth:`b2sdk.v2.Bucket.concatenate` and their stream equivalents. If the parameter is skipped or ``None``, the result file may not have the ``large_file_sha1`` value set. +In basic scenarios, large files uploaded to the server will have a ``large_file_sha1`` element added automatically to their ``file_info``. However, when concatenating multiple sources, it may be impossible for the SDK to figure out the SHA-1 automatically. In such cases, the SHA-1 can be provided using the ``large_file_sha1`` parameter to :meth:`b2sdk.v3.Bucket.create_file`, :meth:`b2sdk.v3.Bucket.concatenate` and their stream equivalents. If the parameter is skipped or ``None``, the result file may not have the ``large_file_sha1`` value set. Note that the provided SHA-1 value is not verified. diff --git a/doc/source/api/account_info.rst b/doc/source/api/account_info.rst index 9a56a2d44..cad50a458 100644 --- a/doc/source/api/account_info.rst +++ b/doc/source/api/account_info.rst @@ -5,12 +5,12 @@ AccountInfo ######################## *AccountInfo* stores basic information about the account, such as *Application Key ID* and *Application Key*, -in order to let :py:class:`b2sdk.v2.B2Api` perform authenticated requests. +in order to let :py:class:`b2sdk.v3.B2Api` perform authenticated requests. There are two usable implementations provided by **b2sdk**: - * :py:class:`b2sdk.v2.InMemoryAccountInfo` - a basic implementation with no persistence - * :py:class:`b2sdk.v2.SqliteAccountInfo` - for console and GUI applications + * :py:class:`b2sdk.v3.InMemoryAccountInfo` - a basic implementation with no persistence + * :py:class:`b2sdk.v3.SqliteAccountInfo` - for console and GUI applications They both provide the full :ref:`AccountInfo interface `. @@ -27,7 +27,7 @@ InMemoryAccountInfo *AccountInfo* with no persistence. -.. autoclass:: b2sdk.v2.InMemoryAccountInfo() +.. autoclass:: b2sdk.v3.InMemoryAccountInfo() :no-members: Implements all methods of :ref:`AccountInfo interface `. @@ -46,7 +46,7 @@ InMemoryAccountInfo SqliteAccountInfo ================= -.. autoclass:: b2sdk.v2.SqliteAccountInfo() +.. autoclass:: b2sdk.v3.SqliteAccountInfo() :inherited-members: :no-members: :special-members: __init__ @@ -73,16 +73,16 @@ SqliteAccountInfo Implementing your own ********************* -When building a server-side application or a web service, you might want to implement your own *AccountInfo* class backed by a database. In such case, you should inherit from :py:class:`b2sdk.v2.UrlPoolAccountInfo`, which has groundwork for url pool functionality). If you cannot use it, inherit directly from :py:class:`b2sdk.v2.AbstractAccountInfo`. +When building a server-side application or a web service, you might want to implement your own *AccountInfo* class backed by a database. In such case, you should inherit from :py:class:`b2sdk.v3.UrlPoolAccountInfo`, which has groundwork for url pool functionality). If you cannot use it, inherit directly from :py:class:`b2sdk.v3.AbstractAccountInfo`. .. code-block:: python - >>> from b2sdk.v2 import UrlPoolAccountInfo + >>> from b2sdk.v3 import UrlPoolAccountInfo >>> class MyAccountInfo(UrlPoolAccountInfo): ... -:py:class:`b2sdk.v2.AbstractAccountInfo` describes the interface, while :py:class:`b2sdk.v2.UrlPoolAccountInfo` and :py:class:`b2sdk.v2.UploadUrlPool` implement a part of the interface for in-memory upload token management. +:py:class:`b2sdk.v3.AbstractAccountInfo` describes the interface, while :py:class:`b2sdk.v3.UrlPoolAccountInfo` and :py:class:`b2sdk.v3.UploadUrlPool` implement a part of the interface for in-memory upload token management. .. _account_info_interface: @@ -90,7 +90,7 @@ When building a server-side application or a web service, you might want to impl AccountInfo interface ===================== -.. autoclass:: b2sdk.v2.AbstractAccountInfo() +.. autoclass:: b2sdk.v3.AbstractAccountInfo() :inherited-members: :private-members: :exclude-members: _abc_cache, _abc_negative_cache, _abc_negative_cache_version, _abc_registry @@ -99,7 +99,7 @@ AccountInfo interface AccountInfo helper classes ========================== -.. autoclass:: b2sdk.v2.UrlPoolAccountInfo() +.. autoclass:: b2sdk.v3.UrlPoolAccountInfo() :inherited-members: :no-members: :members: BUCKET_UPLOAD_POOL_CLASS, LARGE_FILE_UPLOAD_POOL_CLASS diff --git a/doc/source/api/api.rst b/doc/source/api/api.rst index c6fc51b87..8abd5c0f8 100644 --- a/doc/source/api/api.rst +++ b/doc/source/api/api.rst @@ -1,11 +1,11 @@ B2 Api client ============= -.. autoclass:: b2sdk.v2.B2Api() +.. autoclass:: b2sdk.v3.B2Api() :inherited-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.B2HttpApiConfig() +.. autoclass:: b2sdk.v3.B2HttpApiConfig() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/application_key.rst b/doc/source/api/application_key.rst index 8ebb6aaab..dd8272857 100644 --- a/doc/source/api/application_key.rst +++ b/doc/source/api/application_key.rst @@ -1,11 +1,11 @@ B2 Application key ================== -.. autoclass:: b2sdk.v2.ApplicationKey() +.. autoclass:: b2sdk.v3.ApplicationKey() :inherited-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.FullApplicationKey() +.. autoclass:: b2sdk.v3.FullApplicationKey() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/bucket.rst b/doc/source/api/bucket.rst index 3900bf79a..65453887a 100644 --- a/doc/source/api/bucket.rst +++ b/doc/source/api/bucket.rst @@ -1,6 +1,6 @@ B2 Bucket ========= -.. autoclass:: b2sdk.v2.Bucket() +.. autoclass:: b2sdk.v3.Bucket() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/cache.rst b/doc/source/api/cache.rst index e000e9071..29c81f529 100644 --- a/doc/source/api/cache.rst +++ b/doc/source/api/cache.rst @@ -6,17 +6,17 @@ id, so that the user of the library does not need to maintain the mapping to call the api. -.. autoclass:: b2sdk.v2.AbstractCache +.. autoclass:: b2sdk.v3.AbstractCache :inherited-members: -.. autoclass:: b2sdk.v2.AuthInfoCache() +.. autoclass:: b2sdk.v3.AuthInfoCache() :inherited-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.DummyCache() +.. autoclass:: b2sdk.v3.DummyCache() :inherited-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.InMemoryCache() +.. autoclass:: b2sdk.v3.InMemoryCache() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/data_classes.rst b/doc/source/api/data_classes.rst index 1ecd7ee5c..65e62264d 100644 --- a/doc/source/api/data_classes.rst +++ b/doc/source/api/data_classes.rst @@ -1,23 +1,23 @@ Data classes ============ -.. autoclass:: b2sdk.v2.FileVersion +.. autoclass:: b2sdk.v3.FileVersion :inherited-members: :special-members: __dict__ -.. autoclass:: b2sdk.v2.DownloadVersion +.. autoclass:: b2sdk.v3.DownloadVersion :inherited-members: -.. autoclass:: b2sdk.v2.FileIdAndName +.. autoclass:: b2sdk.v3.FileIdAndName :inherited-members: :special-members: __dict__ -.. autoclass:: b2sdk.v2.UnfinishedLargeFile() +.. autoclass:: b2sdk.v3.UnfinishedLargeFile() :no-members: -.. autoclass:: b2sdk.v2.Part +.. autoclass:: b2sdk.v3.Part :no-members: -.. autoclass:: b2sdk.v2.Range +.. autoclass:: b2sdk.v3.Range :no-members: :special-members: __init__ diff --git a/doc/source/api/downloaded_file.rst b/doc/source/api/downloaded_file.rst index e83c317a4..446e99de1 100644 --- a/doc/source/api/downloaded_file.rst +++ b/doc/source/api/downloaded_file.rst @@ -1,6 +1,6 @@ Downloaded File =============== -.. autoclass:: b2sdk.v2.DownloadedFile +.. autoclass:: b2sdk.v3.DownloadedFile -.. autoclass:: b2sdk.v2.MtimeUpdatedFile +.. autoclass:: b2sdk.v3.MtimeUpdatedFile diff --git a/doc/source/api/encryption/setting.rst b/doc/source/api/encryption/setting.rst index 8548e67a6..43c42a073 100644 --- a/doc/source/api/encryption/setting.rst +++ b/doc/source/api/encryption/setting.rst @@ -3,23 +3,23 @@ Encryption Settings =================== -.. autoclass:: b2sdk.v2.EncryptionKey() +.. autoclass:: b2sdk.v3.EncryptionKey() :no-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.UNKNOWN_KEY_ID +.. autoclass:: b2sdk.v3.UNKNOWN_KEY_ID :no-members: -.. autoclass:: b2sdk.v2.EncryptionSetting() +.. autoclass:: b2sdk.v3.EncryptionSetting() :no-members: :special-members: __init__, as_dict -.. autoattribute:: b2sdk.v2.SSE_NONE +.. autoattribute:: b2sdk.v3.SSE_NONE Commonly used "no encryption" setting -.. autoattribute:: b2sdk.v2.SSE_B2_AES +.. autoattribute:: b2sdk.v3.SSE_B2_AES Commonly used SSE-B2 setting diff --git a/doc/source/api/enums.rst b/doc/source/api/enums.rst index a453f30bf..26cf81f4e 100644 --- a/doc/source/api/enums.rst +++ b/doc/source/api/enums.rst @@ -1,14 +1,14 @@ Enums ===== -.. autoclass:: b2sdk.v2.MetadataDirectiveMode +.. autoclass:: b2sdk.v3.MetadataDirectiveMode :inherited-members: -.. autoclass:: b2sdk.v2.NewerFileSyncMode +.. autoclass:: b2sdk.v3.NewerFileSyncMode :inherited-members: -.. autoclass:: b2sdk.v2.CompareVersionMode +.. autoclass:: b2sdk.v3.CompareVersionMode :inherited-members: -.. autoclass:: b2sdk.v2.KeepOrDeleteMode +.. autoclass:: b2sdk.v3.KeepOrDeleteMode :inherited-members: diff --git a/doc/source/api/exception.rst b/doc/source/api/exception.rst index 69e38ec0b..4a0396d93 100644 --- a/doc/source/api/exception.rst +++ b/doc/source/api/exception.rst @@ -4,6 +4,6 @@ Exceptions .. todo:: improve documentation of exceptions, automodule -> autoclass? -.. automodule:: b2sdk.v2.exception +.. automodule:: b2sdk.v3.exception :members: :undoc-members: diff --git a/doc/source/api/file_lock.rst b/doc/source/api/file_lock.rst index a2c4b1ea1..bddfab593 100644 --- a/doc/source/api/file_lock.rst +++ b/doc/source/api/file_lock.rst @@ -1,36 +1,36 @@ File locks ========== -.. autoclass:: b2sdk.v2.LegalHold() +.. autoclass:: b2sdk.v3.LegalHold() :no-members: :special-members: ON, OFF, UNSET, UNKNOWN, is_on, is_off, is_unknown -.. autoclass:: b2sdk.v2.FileRetentionSetting() +.. autoclass:: b2sdk.v3.FileRetentionSetting() :no-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.RetentionMode() +.. autoclass:: b2sdk.v3.RetentionMode() :inherited-members: :members: -.. autoclass:: b2sdk.v2.BucketRetentionSetting() +.. autoclass:: b2sdk.v3.BucketRetentionSetting() :no-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.RetentionPeriod() +.. autoclass:: b2sdk.v3.RetentionPeriod() :inherited-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.FileLockConfiguration() +.. autoclass:: b2sdk.v3.FileLockConfiguration() :no-members: :special-members: __init__ -.. autoclass:: b2sdk.v2.UNKNOWN_BUCKET_RETENTION() +.. autoclass:: b2sdk.v3.UNKNOWN_BUCKET_RETENTION() -.. autoclass:: b2sdk.v2.UNKNOWN_FILE_LOCK_CONFIGURATION() +.. autoclass:: b2sdk.v3.UNKNOWN_FILE_LOCK_CONFIGURATION() -.. autoclass:: b2sdk.v2.NO_RETENTION_BUCKET_SETTING() +.. autoclass:: b2sdk.v3.NO_RETENTION_BUCKET_SETTING() -.. autoclass:: b2sdk.v2.NO_RETENTION_FILE_SETTING() +.. autoclass:: b2sdk.v3.NO_RETENTION_FILE_SETTING() -.. autoclass:: b2sdk.v2.UNKNOWN_FILE_RETENTION_SETTING() +.. autoclass:: b2sdk.v3.UNKNOWN_FILE_RETENTION_SETTING() diff --git a/doc/source/api/progress.rst b/doc/source/api/progress.rst index 94ccc2062..95f3c4434 100644 --- a/doc/source/api/progress.rst +++ b/doc/source/api/progress.rst @@ -9,20 +9,20 @@ Progress reporters include info about sync progress -.. autoclass:: b2sdk.v2.AbstractProgressListener +.. autoclass:: b2sdk.v3.AbstractProgressListener :inherited-members: :members: -.. autoclass:: b2sdk.v2.TqdmProgressListener +.. autoclass:: b2sdk.v3.TqdmProgressListener :no-members: -.. autoclass:: b2sdk.v2.SimpleProgressListener +.. autoclass:: b2sdk.v3.SimpleProgressListener :no-members: -.. autoclass:: b2sdk.v2.DoNothingProgressListener +.. autoclass:: b2sdk.v3.DoNothingProgressListener :no-members: -.. autoclass:: b2sdk.v2.ProgressListenerForTest +.. autoclass:: b2sdk.v3.ProgressListenerForTest :no-members: -.. autofunction:: b2sdk.v2.make_progress_listener +.. autofunction:: b2sdk.v3.make_progress_listener diff --git a/doc/source/api/sync.rst b/doc/source/api/sync.rst index ef42cb692..517532e5a 100644 --- a/doc/source/api/sync.rst +++ b/doc/source/api/sync.rst @@ -24,18 +24,18 @@ Sync Options Following are the important optional arguments that can be provided while initializing `Synchronizer` class. -* ``compare_version_mode``: When comparing the source and destination files for finding whether to replace them or not, `compare_version_mode` can be passed to specify the mode of comparison. For possible values see :class:`b2sdk.v2.CompareVersionMode`. Default value is :py:attr:`b2sdk.v2.CompareVersionMode.MODTIME` +* ``compare_version_mode``: When comparing the source and destination files for finding whether to replace them or not, `compare_version_mode` can be passed to specify the mode of comparison. For possible values see :class:`b2sdk.v3.CompareVersionMode`. Default value is :py:attr:`b2sdk.v3.CompareVersionMode.MODTIME` * ``compare_threshold``: It's the minimum size (in bytes)/modification time (in seconds) difference between source and destination files before we assume that it is new and replace. -* ``newer_file_mode``: To identify whether to skip or replace if source is older. For possible values see :class:`b2sdk.v2.NewerFileSyncMode`. If you don't specify this the sync will raise :class:`b2sdk.v2.exception.DestFileNewer` in case any of the source file is older than destination. -* ``keep_days_or_delete``: specify policy to keep or delete older files. For possible values see :class:`b2sdk.v2.KeepOrDeleteMode`. Default is `DO_NOTHING`. -* ``keep_days``: if `keep_days_or_delete` is :py:attr:`b2sdk.v2.KeepOrDeleteMode.KEEP_BEFORE_DELETE` then this specifies for how many days should we keep. +* ``newer_file_mode``: To identify whether to skip or replace if source is older. For possible values see :class:`b2sdk.v3.NewerFileSyncMode`. If you don't specify this the sync will raise :class:`b2sdk.v3.exception.DestFileNewer` in case any of the source file is older than destination. +* ``keep_days_or_delete``: specify policy to keep or delete older files. For possible values see :class:`b2sdk.v3.KeepOrDeleteMode`. Default is `DO_NOTHING`. +* ``keep_days``: if `keep_days_or_delete` is :py:attr:`b2sdk.v3.KeepOrDeleteMode.KEEP_BEFORE_DELETE` then this specifies for how many days should we keep. .. code-block:: python - >>> from b2sdk.v2 import ScanPoliciesManager - >>> from b2sdk.v2 import parse_folder - >>> from b2sdk.v2 import Synchronizer, SyncReport - >>> from b2sdk.v2 import KeepOrDeleteMode, CompareVersionMode, NewerFileSyncMode + >>> from b2sdk.v3 import ScanPoliciesManager + >>> from b2sdk.v3 import parse_folder + >>> from b2sdk.v3 import Synchronizer, SyncReport + >>> from b2sdk.v3 import KeepOrDeleteMode, CompareVersionMode, NewerFileSyncMode >>> import time >>> import sys @@ -198,17 +198,17 @@ and :ref:`encryption_provider` for public API. Public API classes ================== -.. autoclass:: b2sdk.v2.ScanPoliciesManager() +.. autoclass:: b2sdk.v3.ScanPoliciesManager() :inherited-members: :special-members: __init__ :members: -.. autoclass:: b2sdk.v2.Synchronizer() +.. autoclass:: b2sdk.v3.Synchronizer() :inherited-members: :special-members: __init__ :members: -.. autoclass:: b2sdk.v2.SyncReport() +.. autoclass:: b2sdk.v3.SyncReport() :inherited-members: :special-members: __init__ :members: @@ -220,15 +220,15 @@ Sync Encryption Settings Providers ================================== -.. autoclass:: b2sdk.v2.AbstractSyncEncryptionSettingsProvider() +.. autoclass:: b2sdk.v3.AbstractSyncEncryptionSettingsProvider() :inherited-members: :members: -.. autoclass:: b2sdk.v2.ServerDefaultSyncEncryptionSettingsProvider() +.. autoclass:: b2sdk.v3.ServerDefaultSyncEncryptionSettingsProvider() :no-members: -.. autoclass:: b2sdk.v2.BasicSyncEncryptionSettingsProvider() +.. autoclass:: b2sdk.v3.BasicSyncEncryptionSettingsProvider() :special-members: __init__ :no-members: diff --git a/doc/source/api/transfer/emerge/write_intent.rst b/doc/source/api/transfer/emerge/write_intent.rst index bed93e218..55c6ca607 100644 --- a/doc/source/api/transfer/emerge/write_intent.rst +++ b/doc/source/api/transfer/emerge/write_intent.rst @@ -1,6 +1,6 @@ Write intent ============ -.. autoclass:: b2sdk.v2.WriteIntent() +.. autoclass:: b2sdk.v3.WriteIntent() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/transfer/outbound/outbound_source.rst b/doc/source/api/transfer/outbound/outbound_source.rst index 67809ba83..4e629f5e6 100644 --- a/doc/source/api/transfer/outbound/outbound_source.rst +++ b/doc/source/api/transfer/outbound/outbound_source.rst @@ -1,6 +1,6 @@ Outbound Transfer Source ======================== -.. autoclass:: b2sdk.v2.OutboundTransferSource() +.. autoclass:: b2sdk.v3.OutboundTransferSource() :inherited-members: :special-members: __init__ diff --git a/doc/source/api/utils.rst b/doc/source/api/utils.rst index 1bfebec94..bf51315f8 100644 --- a/doc/source/api/utils.rst +++ b/doc/source/api/utils.rst @@ -1,15 +1,11 @@ B2 Utility functions ==================== -.. autofunction:: b2sdk.v2.b2_url_encode -.. autofunction:: b2sdk.v2.b2_url_decode -.. autofunction:: b2sdk.v2.choose_part_ranges -.. autofunction:: b2sdk.v2.fix_windows_path_limit -.. autofunction:: b2sdk.v2.format_and_scale_fraction -.. autofunction:: b2sdk.v2.format_and_scale_number -.. autofunction:: b2sdk.v2.hex_sha1_of_stream -.. autofunction:: b2sdk.v2.hex_sha1_of_bytes - -.. autoclass:: b2sdk.v2.TempDir() - :inherited-members: - :special-members: __enter__, __exit__ +.. autofunction:: b2sdk.v3.b2_url_encode +.. autofunction:: b2sdk.v3.b2_url_decode +.. autofunction:: b2sdk.v3.choose_part_ranges +.. autofunction:: b2sdk.v3.fix_windows_path_limit +.. autofunction:: b2sdk.v3.format_and_scale_fraction +.. autofunction:: b2sdk.v3.format_and_scale_number +.. autofunction:: b2sdk.v3.hex_sha1_of_stream +.. autofunction:: b2sdk.v3.hex_sha1_of_bytes diff --git a/doc/source/api_types.rst b/doc/source/api_types.rst index 1e305889a..3995def72 100644 --- a/doc/source/api_types.rst +++ b/doc/source/api_types.rst @@ -24,8 +24,8 @@ Therefore when setting up **b2sdk** as a dependency, please make sure to match t Interface versions ****************** -You might notice that the import structure provided in the documentation looks a little odd: ``from b2sdk.v2 import ...``. -The ``.v2`` part is used to keep the interface fluid without risk of breaking applications that use the old signatures. +You might notice that the import structure provided in the documentation looks a little odd: ``from b2sdk.v3 import ...``. +The ``.v3`` part is used to keep the interface fluid without risk of breaking applications that use the old signatures. With new versions, **b2sdk** will provide functions with signatures matching the old ones, wrapping the new interface in place of the old one. What this means for a developer using **b2sdk**, is that it will just keep working. We have already deleted some legacy functions when moving from ``.v0`` to ``.v1``, providing equivalent wrappers to reduce the migration effort for applications using pre-1.0 versions of **b2sdk** to fixing imports. It also means that **b2sdk** developers may change the interface in the future and will not need to maintain many branches and backport fixes to keep compatibility of for users of those old branches. @@ -41,7 +41,7 @@ A :term:`numbered interface` will not be exactly identi Exceptions ========== -The exception hierarchy may change in a backwards compatible manner and the developer must anticipate it. For example, if ``b2sdk.v2.ExceptionC`` inherits directly from ``b2sdk.v2.ExceptionA``, it may one day inherit from ``b2sdk.v2.ExceptionB``, which in turn inherits from ``b2sdk.v2.ExceptionA``. Normally this is not a problem if you use ``isinstance()`` and ``super()`` properly, but your code should not call the constructor of a parent class by directly naming it or it might skip the middle class of the hierarchy (``ExceptionB`` in this example). +The exception hierarchy may change in a backwards compatible manner and the developer must anticipate it. For example, if ``b2sdk.v3.ExceptionC`` inherits directly from ``b2sdk.v3.ExceptionA``, it may one day inherit from ``b2sdk.v3.ExceptionB``, which in turn inherits from ``b2sdk.v3.ExceptionA``. Normally this is not a problem if you use ``isinstance()`` and ``super()`` properly, but your code should not call the constructor of a parent class by directly naming it or it might skip the middle class of the hierarchy (``ExceptionB`` in this example). Extensions ========== diff --git a/doc/source/glossary.rst b/doc/source/glossary.rst index 17d52fb52..d895a9329 100644 --- a/doc/source/glossary.rst +++ b/doc/source/glossary.rst @@ -20,7 +20,7 @@ Glossary Looks like this: ``v1.0.0`` or ``1.0.0`` and makes version numbers meaningful. See :ref:`Pinning versions ` for more details. b2sdk interface version - Looks like this: ``v2`` or ``b2sdk.v2`` and makes maintaining backward compatibility much easier. See :ref:`interface versions ` for more details. + Looks like this: ``v3`` or ``b2sdk.v3`` and makes maintaining backward compatibility much easier. See :ref:`interface versions ` for more details. master application key This is the first key you have access to, it is available on the B2 web application. This key has all capabilities, access to all :term:`buckets`, and has no file prefix restrictions or expiration. The :term:`application key ID` of the master application key is equal to :term:`account ID`. diff --git a/doc/source/quick_start.rst b/doc/source/quick_start.rst index 8c2fe5928..4c74ecebc 100644 --- a/doc/source/quick_start.rst +++ b/doc/source/quick_start.rst @@ -10,7 +10,7 @@ Prepare b2sdk .. code-block:: python - >>> from b2sdk.v2 import * + >>> from b2sdk.v3 import * >>> info = InMemoryAccountInfo() >>> b2_api = B2Api(info, cache=AuthInfoCache(info)) >>> application_key_id = '4a5b6c7d8e9f' @@ -27,10 +27,10 @@ Synchronization .. code-block:: python - >>> from b2sdk.v2 import ScanPoliciesManager - >>> from b2sdk.v2 import parse_folder - >>> from b2sdk.v2 import Synchronizer - >>> from b2sdk.v2 import SyncReport + >>> from b2sdk.v3 import ScanPoliciesManager + >>> from b2sdk.v3 import parse_folder + >>> from b2sdk.v3 import Synchronizer + >>> from b2sdk.v3 import SyncReport >>> import time >>> import sys @@ -81,7 +81,7 @@ unique keys, or key identifiers, across files. This is covered in greater detail In the example above, Sync will assume `SSE-B2` for all files in `bucket1`, `SSE-C` with the key provided for `bucket2` and rely on bucket default for `bucket3`. Should developers need to provide keys per file (and not per bucket), they -need to implement their own :class:`b2sdk.v2.AbstractSyncEncryptionSettingsProvider`. +need to implement their own :class:`b2sdk.v3.AbstractSyncEncryptionSettingsProvider`. ************** Bucket actions @@ -109,7 +109,7 @@ Create a bucket >>> b2_api.create_bucket(bucket_name, bucket_type) Bucket<346501784642eb3e60980d10,example-mybucket-b2-1,allPublic> -You can optionally store bucket info, CORS rules and lifecycle rules with the bucket. See :meth:`b2sdk.v2.B2Api.create_bucket`. +You can optionally store bucket info, CORS rules and lifecycle rules with the bucket. See :meth:`b2sdk.v3.B2Api.create_bucket`. Delete a bucket @@ -149,7 +149,7 @@ Update bucket info 'value': {'algorithm': 'AES256', 'mode': 'SSE-B2'}}}, } -For more information see :meth:`b2sdk.v2.Bucket.update`. +For more information see :meth:`b2sdk.v3.Bucket.update`. ************ @@ -182,7 +182,7 @@ Upload file This will work regardless of the size of the file - ``upload_local_file`` automatically uses large file upload API when necessary. -For more information see :meth:`b2sdk.v2.Bucket.upload_local_file`. +For more information see :meth:`b2sdk.v3.Bucket.upload_local_file`. Upload file encrypted with SSE-C -------------------------------- @@ -215,7 +215,7 @@ By id .. code-block:: python - >>> from b2sdk.v2 import DoNothingProgressListener + >>> from b2sdk.v3 import DoNothingProgressListener >>> local_file_path = '/home/user1/b2_example/new2.pdf' >>> file_id = '4_z5485a1682662eb3e60980d10_f1195145f42952533_d20190403_m130258_c002_v0001111_t0002' @@ -303,7 +303,7 @@ The folder_name is returned only for the first file in the folder. som2.pdf 1554296578000 None some.pdf 1554296579000 None -For more information see :meth:`b2sdk.v2.Bucket.ls`. +For more information see :meth:`b2sdk.v3.Bucket.ls`. Get file metadata @@ -379,7 +379,7 @@ If you want to copy just the part of the file, then you can specify the offset a Note that content length is required for offset values other than zero. -For more information see :meth:`b2sdk.v2.Bucket.copy`. +For more information see :meth:`b2sdk.v3.Bucket.copy`. Delete file @@ -412,14 +412,14 @@ Direct file operations Methods for manipulating object (file) state mentioned in sections above are low level and useful when users have access to basic information, like file id and name. Many API methods, however, return python objects representing files -(:class:`b2sdk.v2.FileVersion` and :class:`b2sdk.v2.DownloadVersion`), that provide high-level access to methods +(:class:`b2sdk.v3.FileVersion` and :class:`b2sdk.v3.DownloadVersion`), that provide high-level access to methods manipulating their state. As a rule, these methods don't change properties of python objects they are called on, but return new objects instead. Obtain file representing objects ================================ -:class:`b2sdk.v2.FileVersion` +:class:`b2sdk.v3.FileVersion` ----------------------------- By id @@ -439,7 +439,7 @@ By listing >>> ... -:class:`b2sdk.v2.DownloadVersion` +:class:`b2sdk.v3.DownloadVersion` --------------------------------- By id @@ -462,7 +462,7 @@ By name >>> download_version = downloaded_file.download_version -Download (only for :class:`b2sdk.v2.FileVersion`) +Download (only for :class:`b2sdk.v3.FileVersion`) ================================================= .. code-block:: python diff --git a/doc/source/server_side_encryption.rst b/doc/source/server_side_encryption.rst index 2d8fc1fbf..a20d1e687 100644 --- a/doc/source/server_side_encryption.rst +++ b/doc/source/server_side_encryption.rst @@ -32,7 +32,7 @@ In applications requiring enhanced security, using unique key per file is a good that makes managing such keys easier: `EncryptionSetting` holds a key identifier, aside from the key itself. This key identifier is saved in the metadata of all files uploaded, created or copied via **b2sdk** methods using `SSE-C`, under `sse_c_key_id` in `fileInfo`. This allows developers to create key managers that map those ids to keys, stored -securely in a file or a database. Implementing such managers, and linking them to :class:`b2sdk.v2.AbstractSyncEncryptionSettingsProvider` +securely in a file or a database. Implementing such managers, and linking them to :class:`b2sdk.v3.AbstractSyncEncryptionSettingsProvider` implementations (necessary for using Sync) is outside of the scope of this library. There is, however, a convention to such managers that authors of this library strongly suggest: if a manager needs to generate diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index e0bd961cc..3e19e21d4 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -10,11 +10,11 @@ AccountInfo It is the first object that you need to create to use **b2sdk**. Using ``AccountInfo``, we'll be able to create a ``B2Api`` object to manage a B2 account. -In the tutorial we will use :py:class:`b2sdk.v2.InMemoryAccountInfo`: +In the tutorial we will use :py:class:`b2sdk.v3.InMemoryAccountInfo`: .. code-block:: python - >>> from b2sdk.v2 import InMemoryAccountInfo + >>> from b2sdk.v3 import InMemoryAccountInfo >>> info = InMemoryAccountInfo() # store credentials, tokens and cache in memory @@ -29,7 +29,7 @@ Account authorization .. code-block:: python - >>> from b2sdk.v2 import B2Api + >>> from b2sdk.v3 import B2Api >>> b2_api = B2Api(info) >>> application_key_id = '4a5b6c7d8e9f' >>> application_key = '001b8e23c26ff6efb941e237deb182b9599a84bef7' @@ -38,7 +38,7 @@ Account authorization .. tip:: Get credentials from B2 website -To find out more about account authorization, see :meth:`b2sdk.v2.B2Api.authorize_account` +To find out more about account authorization, see :meth:`b2sdk.v3.B2Api.authorize_account` *************************** @@ -50,7 +50,7 @@ B2Api Typical B2Api operations ======================== -.. currentmodule:: b2sdk.v2.B2Api +.. currentmodule:: b2sdk.v3.B2Api .. autosummary:: :nosignatures: @@ -72,9 +72,9 @@ Typical B2Api operations >>> b2_api = B2Api(info) -to find out more, see :class:`b2sdk.v2.B2Api`. +to find out more, see :class:`b2sdk.v3.B2Api`. -The most practical operation on ``B2Api`` object is :meth:`b2sdk.v2.B2Api.get_bucket_by_name`. +The most practical operation on ``B2Api`` object is :meth:`b2sdk.v3.B2Api.get_bucket_by_name`. *Bucket* allows for operations such as listing a remote bucket or transferring files. @@ -108,7 +108,7 @@ To create a bucket: >>> b2_api.create_bucket(bucket_name, bucket_type) Bucket<346501784642eb3e60980d10,example-mybucket-b2-1,allPublic> -You can optionally store bucket info, CORS rules and lifecycle rules with the bucket. See :meth:`b2sdk.v2.B2Api.create_bucket` for more details. +You can optionally store bucket info, CORS rules and lifecycle rules with the bucket. See :meth:`b2sdk.v3.B2Api.create_bucket` for more details. .. note:: Bucket name must be unique in B2 (across all accounts!). Your application should be able to cope with a bucket name collision with another B2 user. @@ -117,7 +117,7 @@ You can optionally store bucket info, CORS rules and lifecycle rules with the bu Typical Bucket operations ========================= -.. currentmodule:: b2sdk.v2.Bucket +.. currentmodule:: b2sdk.v3.Bucket .. autosummary:: :nosignatures: @@ -135,7 +135,7 @@ Typical Bucket operations set_info -To find out more, see :class:`b2sdk.v2.Bucket`. +To find out more, see :class:`b2sdk.v3.Bucket`. *************************** diff --git a/pyproject.toml b/pyproject.toml index 4ddae3eaa..f76c4e178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ quote-style = "single" [tool.ruff.per-file-ignores] "__init__.py" = ["I", "F401"] -"b2sdk/_v3/__init__.py" = ["E402"] +"b2sdk/v3/__init__.py" = ["E402"] "b2sdk/v*/**" = ["I", "F403", "F405"] "b2sdk/_v*/**" = ["I", "F403", "F405"] "test/**" = ["D", "F403", "F405"] diff --git a/test/integration/bucket_cleaner.py b/test/integration/bucket_cleaner.py index 909941ca8..05ffc8863 100644 --- a/test/integration/bucket_cleaner.py +++ b/test/integration/bucket_cleaner.py @@ -11,7 +11,7 @@ import logging -from b2sdk.v2 import ( +from b2sdk.v3 import ( NO_RETENTION_FILE_SETTING, B2Api, Bucket, @@ -19,7 +19,7 @@ RetentionMode, current_time_millis, ) -from b2sdk.v2.exception import BadRequest +from b2sdk.v3.exception import BadRequest from .helpers import BUCKET_CREATED_AT_MILLIS, GENERAL_BUCKET_NAME_PREFIX diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 141474760..bf44eacbe 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -12,7 +12,7 @@ import os import secrets -from b2sdk.v2 import ( +from b2sdk.v3 import ( BUCKET_NAME_CHARS_UNIQ, BUCKET_NAME_LENGTH_RANGE, DEFAULT_HTTP_API_CONFIG, @@ -43,5 +43,5 @@ def authorize(b2_auth_data, api_config=DEFAULT_HTTP_API_CONFIG): info = InMemoryAccountInfo() b2_api = B2Api(info, api_config=api_config) realm = os.environ.get('B2_TEST_ENVIRONMENT', 'production') - b2_api.authorize_account(realm, *b2_auth_data) + b2_api.authorize_account(*b2_auth_data, realm=realm) return b2_api, info diff --git a/test/integration/test_download.py b/test/integration/test_download.py index ee7a8fafa..9b9ab691b 100644 --- a/test/integration/test_download.py +++ b/test/integration/test_download.py @@ -22,7 +22,7 @@ from b2sdk._internal.utils import Sha1HexDigest from b2sdk._internal.utils.filesystem import _IS_WINDOWS -from b2sdk.v2 import * +from b2sdk.v3 import * from .base import IntegrationTestBase from .helpers import authorize diff --git a/test/integration/test_raw_api.py b/test/integration/test_raw_api.py index 8dcdf4e2d..c60ec55b2 100644 --- a/test/integration/test_raw_api.py +++ b/test/integration/test_raw_api.py @@ -119,7 +119,7 @@ def raw_api_test_helper(raw_api, should_cleanup_old_buckets): set(ALL_CAPABILITIES) - {'readBuckets', 'listAllBucketNames'} - preview_feature_caps - - set(auth_dict['apiInfo']['storageApi']['capabilities']) + - set(auth_dict['apiInfo']['storageApi']['allowed']['capabilities']) ) assert not missing_capabilities, f'it appears that the raw_api integration test is being run with a non-full key. Missing capabilities: {missing_capabilities}' @@ -599,7 +599,10 @@ def raw_api_test_helper(raw_api, should_cleanup_old_buckets): def _subtest_bucket_notification_rules(raw_api, auth_dict, api_url, account_auth_token, bucket_id): - if 'writeBucketNotifications' not in auth_dict['apiInfo']['storageApi']['capabilities']: + if ( + 'writeBucketNotifications' + not in auth_dict['apiInfo']['storageApi']['allowed']['capabilities'] + ): pytest.skip('Test account does not have writeBucketNotifications capability') notification_rule = { diff --git a/test/integration/test_sync.py b/test/integration/test_sync.py index b74701998..2d29cb868 100644 --- a/test/integration/test_sync.py +++ b/test/integration/test_sync.py @@ -14,7 +14,7 @@ import pytest -from b2sdk.v2 import ( +from b2sdk.v3 import ( CompareVersionMode, NewerFileSyncMode, Synchronizer, diff --git a/test/unit/account_info/fixtures.py b/test/unit/account_info/fixtures.py index d3efebfec..0e220a067 100644 --- a/test/unit/account_info/fixtures.py +++ b/test/unit/account_info/fixtures.py @@ -13,6 +13,9 @@ from apiver_deps import InMemoryAccountInfo, SqliteAccountInfo from pytest_lazy_fixtures import lf +from b2sdk.v2 import SqliteAccountInfo as V2SqliteAccountInfo +from b2sdk.v3 import SqliteAccountInfo as V3SqliteAccountInfo + @pytest.fixture def account_info_default_data_schema_0(): @@ -78,6 +81,26 @@ def get_account_info(file_name=None, schema_0=False): return get_account_info +@pytest.fixture +def v2_sqlite_account_info_factory(tmpdir): + def get_account_info(file_name=None): + if file_name is None: + file_name = str(tmpdir.join('b2_account_info')) + return V2SqliteAccountInfo(file_name, last_upgrade_to_run=4) + + return get_account_info + + +@pytest.fixture +def v3_sqlite_account_info_factory(tmpdir): + def get_account_info(file_name=None): + if file_name is None: + file_name = str(tmpdir.join('b2_account_info')) + return V3SqliteAccountInfo(file_name) + + return get_account_info + + @pytest.fixture def sqlite_account_info(sqlite_account_info_factory): return sqlite_account_info_factory() diff --git a/test/unit/account_info/test_account_info.py b/test/unit/account_info/test_account_info.py index 143122e83..65069d8ff 100644 --- a/test/unit/account_info/test_account_info.py +++ b/test/unit/account_info/test_account_info.py @@ -184,7 +184,23 @@ def test_clear(self, account_info_default_data, apiver): with pytest.raises(MissingAccountData): account_info.get_absolute_minimum_part_size() - def test_set_auth_data_compatibility(self, account_info_default_data): + @pytest.fixture + def allowed(self, apiver): + if apiver in ['v0', 'v1', 'v2']: + return dict( + capabilities=['readFiles'], + namePrefix=None, + bucketId=None, + bucketName=None, + ) + + return dict( + capabilities=['readFiles'], + namePrefix=None, + buckets=None, + ) + + def test_set_auth_data_compatibility(self, account_info_default_data, allowed): account_info = self._make_info() # The original set_auth_data @@ -193,19 +209,13 @@ def test_set_auth_data_compatibility(self, account_info_default_data): assert AbstractAccountInfo.DEFAULT_ALLOWED == actual, 'default allowed' # allowed was added later - allowed = dict( - bucketId=None, - bucketName=None, - capabilities=['readFiles'], - namePrefix=None, - ) account_info.set_auth_data( **{ **account_info_default_data, 'allowed': allowed, } ) - assert allowed == account_info.get_allowed() + assert account_info.get_allowed() == allowed def test_clear_bucket_upload_data(self): account_info = self._make_info() diff --git a/test/unit/account_info/test_sqlite_account_info.py b/test/unit/account_info/test_sqlite_account_info.py index d7ed1f4a8..52e4848bd 100644 --- a/test/unit/account_info/test_sqlite_account_info.py +++ b/test/unit/account_info/test_sqlite_account_info.py @@ -19,6 +19,7 @@ AbstractAccountInfo, SqliteAccountInfo, ) +from apiver_deps_exception import MissingAccountData from .fixtures import * @@ -64,6 +65,107 @@ def test_migrate_to_4(self): ).fetchone() assert (100, 5000000) == sizes + @pytest.mark.parametrize( + 'allowed', + [ + dict( + bucketId='123', + bucketName='bucket1', + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + dict( + bucketId=None, + bucketName=None, + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + ], + ) + @pytest.mark.apiver(2) + def test_migrate_to_5_v2( + self, v2_sqlite_account_info_factory, account_info_default_data, allowed + ): + old_account_info = v2_sqlite_account_info_factory() + account_info_default_data.update({'allowed': allowed}) + old_account_info.set_auth_data(**account_info_default_data) + assert old_account_info.get_allowed() == allowed + + new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename) + + assert new_account_info.get_allowed() == allowed + + @pytest.mark.parametrize( + ('old_allowed', 'exp_allowed'), + [ + ( + dict( + bucketId='123', + bucketName='bucket1', + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + dict( + buckets=[{'id': '123', 'name': 'bucket1'}], + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + ), + ( + dict( + bucketId=None, + bucketName=None, + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + dict( + buckets=None, + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ), + ), + ], + ) + @pytest.mark.apiver(from_ver=3) + def test_migrate_to_5_v3( + self, + v2_sqlite_account_info_factory, + account_info_default_data, + old_allowed, + exp_allowed, + ): + old_account_info = v2_sqlite_account_info_factory() + account_info_default_data.update({'allowed': old_allowed}) + old_account_info.set_auth_data(**account_info_default_data) + assert old_account_info.get_allowed() == old_allowed + + new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename) + + assert new_account_info.get_allowed() == exp_allowed + + @pytest.mark.apiver(2) + def test_multi_bucket_key_error_apiver_v2( + self, + v2_sqlite_account_info_factory, + v3_sqlite_account_info_factory, + account_info_default_data, + ): + allowed = dict( + buckets=[{'id': 1, 'name': 'bucket1'}, {'id': 2, 'name': 'bucket2'}], + capabilities=['listBuckets', 'readBuckets'], + namePrefix=None, + ) + + v3_account_info = v3_sqlite_account_info_factory() + account_info_default_data.update({'allowed': allowed}) + v3_account_info.set_auth_data(**account_info_default_data) + + assert v3_account_info.get_allowed() == allowed + + v2_account_info = v2_sqlite_account_info_factory(file_name=v3_account_info.filename) + with pytest.raises(MissingAccountData): + v2_account_info.get_allowed() + class TestSqliteAccountProfileFileLocation: @pytest.fixture(autouse=True) diff --git a/test/unit/api/test_api.py b/test/unit/api/test_api.py index 22aeeacb9..d3127f84f 100644 --- a/test/unit/api/test_api.py +++ b/test/unit/api/test_api.py @@ -36,7 +36,7 @@ ) from apiver_deps_exception import AccessDenied, FileNotPresent, InvalidArgument, RestrictedBucket -from ..test_base import create_key +from ..test_base import create_key, create_key_multibucket if apiver_deps.V <= 1: from apiver_deps import FileVersionInfo as VFileVersion @@ -385,6 +385,23 @@ def test_list_buckets_with_restriction(self): ) assert [b.name for b in self.api.list_buckets(bucket_name=bucket1.name)] == ['bucket1'] + @pytest.mark.apiver(from_ver=3) + def test_list_buckets_with_restriction_multi_bucket(self): + self._authorize_account() + bucket1 = self.api.create_bucket('bucket1', 'allPrivate') + bucket2 = self.api.create_bucket('bucket2', 'allPrivate') + self.api.create_bucket('bucket3', 'allPrivate') + key = create_key_multibucket( + self.api, ['listBuckets'], 'key1', bucket_ids=[bucket1.id_, bucket2.id_] + ) + self.api.authorize_account( + application_key_id=key.id_, + application_key=key.application_key, + realm='production', + ) + assert [b.name for b in self.api.list_buckets(bucket_name=bucket1.name)] == ['bucket1'] + assert [b.name for b in self.api.list_buckets(bucket_name=bucket2.name)] == ['bucket2'] + def test_get_bucket_by_name_with_bucket_restriction(self): self._authorize_account() bucket1 = self.api.create_bucket('bucket1', 'allPrivate') @@ -396,7 +413,8 @@ def test_get_bucket_by_name_with_bucket_restriction(self): ) assert self.api.get_bucket_by_name('bucket1').id_ == bucket1.id_ - def test_list_buckets_with_restriction_and_wrong_name(self): + @pytest.mark.apiver(to_ver=2) + def test_list_buckets_with_restriction_and_wrong_name_v2(self): self._authorize_account() bucket1 = self.api.create_bucket('bucket1', 'allPrivate') bucket2 = self.api.create_bucket('bucket2', 'allPrivate') @@ -410,7 +428,25 @@ def test_list_buckets_with_restriction_and_wrong_name(self): self.api.list_buckets(bucket_name=bucket2.name) assert str(excinfo.value) == 'Application key is restricted to bucket: bucket1' - def test_list_buckets_with_restriction_and_no_name(self): + @pytest.mark.apiver(from_ver=3) + def test_list_buckets_with_restriction_and_wrong_name_v3(self): + self._authorize_account() + bucket1 = self.api.create_bucket('bucket1', 'allPrivate') + bucket2 = self.api.create_bucket('bucket2', 'allPrivate') + bucket3 = self.api.create_bucket('bucket3', 'allPrivate') + key = create_key_multibucket( + self.api, ['listBuckets'], 'key1', bucket_ids=[bucket1.id_, bucket2.id_] + ) + self.api.authorize_account( + application_key_id=key.id_, + application_key=key.application_key, + realm='production', + ) + with pytest.raises(RestrictedBucket, match="\['bucket1', 'bucket2'\]"): + self.api.list_buckets(bucket_name=bucket3.name) + + @pytest.mark.apiver(to_ver=2) + def test_list_buckets_with_restriction_and_no_name_v2(self): self._authorize_account() bucket1 = self.api.create_bucket('bucket1', 'allPrivate') self.api.create_bucket('bucket2', 'allPrivate') @@ -424,7 +460,25 @@ def test_list_buckets_with_restriction_and_no_name(self): self.api.list_buckets() assert str(excinfo.value) == 'Application key is restricted to bucket: bucket1' - def test_list_buckets_with_restriction_and_wrong_id(self): + @pytest.mark.apiver(from_ver=3) + def test_list_buckets_with_restriction_and_no_name_v3(self): + self._authorize_account() + bucket1 = self.api.create_bucket('bucket1', 'allPrivate') + bucket2 = self.api.create_bucket('bucket2', 'allPrivate') + self.api.create_bucket('bucket3', 'allPrivate') + key = create_key_multibucket( + self.api, ['listBuckets'], 'key1', bucket_ids=[bucket1.id_, bucket2.id_] + ) + self.api.authorize_account( + application_key_id=key.id_, + application_key=key.application_key, + realm='production', + ) + with pytest.raises(RestrictedBucket): + self.api.list_buckets() + + @pytest.mark.apiver(to_ver=2) + def test_list_buckets_with_restriction_and_wrong_id_v2(self): self._authorize_account() bucket1 = self.api.create_bucket('bucket1', 'allPrivate') self.api.create_bucket('bucket2', 'allPrivate') @@ -438,6 +492,23 @@ def test_list_buckets_with_restriction_and_wrong_id(self): self.api.list_buckets(bucket_id='not the one bound to the key') assert str(excinfo.value) == f'Application key is restricted to bucket: {bucket1.id_}' + @pytest.mark.apiver(from_ver=3) + def test_list_buckets_with_restriction_and_wrong_id_v3(self): + self._authorize_account() + bucket1 = self.api.create_bucket('bucket1', 'allPrivate') + bucket2 = self.api.create_bucket('bucket2', 'allPrivate') + self.api.create_bucket('bucket3', 'allPrivate') + key = create_key_multibucket( + self.api, ['listBuckets'], 'key1', bucket_ids=[bucket1.id_, bucket2.id_] + ) + self.api.authorize_account( + application_key_id=key.id_, + application_key=key.application_key, + realm='production', + ) + with pytest.raises(RestrictedBucket): + self.api.list_buckets(bucket_id='not the one bound to the key') + def _authorize_account(self): self.api.authorize_account( application_key_id=self.application_key_id, @@ -542,7 +613,7 @@ def test_create_and_delete_key_v1(self): delete_result = self.api.delete_key_by_id(create_result['applicationKeyId']) self.assertDeleteAndCreateResult(create_result, delete_result.as_dict()) - @pytest.mark.apiver(from_ver=2) + @pytest.mark.apiver(2) def test_create_and_delete_key_v2(self): self._authorize_account() bucket = self.api.create_bucket('bucket', 'allPrivate') @@ -580,6 +651,35 @@ def test_create_and_delete_key_v2(self): delete_result = self.api.delete_key_by_id(create_result.id_) self.assertDeleteAndCreateResult(create_result, delete_result) + @pytest.mark.apiver(from_ver=3) + def test_create_and_delete_key_v3(self): + self._authorize_account() + bucket1 = self.api.create_bucket('bucket1', 'allPrivate') + bucket2 = self.api.create_bucket('bucket2', 'allPrivate') + now = time.time() + create_result = self.api.create_key( + ['readFiles'], + 'testkey', + valid_duration_seconds=100, + bucket_ids=[bucket1.id_, bucket2.id_], + name_prefix='name', + ) + assert isinstance(create_result, FullApplicationKey) + assert create_result.key_name == 'testkey' + assert create_result.capabilities == ['readFiles'] + assert create_result.account_id == self.account_info.get_account_id() + assert ( + (now + 100 - 10) * 1000 + < create_result.expiration_timestamp_millis + < (now + 100 + 10) * 1000 + ) + assert create_result.bucket_ids == [bucket1.id_, bucket2.id_] + assert create_result.name_prefix == 'name' + # assert create_result.options == ... TODO + + delete_result = self.api.delete_key(create_result) + self.assertDeleteAndCreateResult(create_result, delete_result) + def assertDeleteAndCreateResult(self, create_result, delete_result): if apiver_deps.V <= 1: create_result.pop('applicationKey') @@ -593,7 +693,10 @@ def assertDeleteAndCreateResult(self, create_result, delete_result): delete_result.expiration_timestamp_millis == create_result.expiration_timestamp_millis ) - assert delete_result.bucket_id == create_result.bucket_id + if apiver_deps.V < 3: + assert delete_result.bucket_id == create_result.bucket_id + else: + assert delete_result.bucket_ids == create_result.bucket_ids assert delete_result.name_prefix == create_result.name_prefix @pytest.mark.apiver(to_ver=1) diff --git a/test/unit/fixtures/raw_api.py b/test/unit/fixtures/raw_api.py index 1495fca17..fc7166e7a 100644 --- a/test/unit/fixtures/raw_api.py +++ b/test/unit/fixtures/raw_api.py @@ -16,23 +16,40 @@ @pytest.fixture -def fake_b2_raw_api_responses(): +def fake_b2_raw_api_responses(apiver_int): + capabilities = copy(ALL_CAPABILITIES) + namePrefix = None + + storage_api = { + 'downloadUrl': 'https://f000.backblazeb2.xyz:8180', + 'absoluteMinimumPartSize': 5000000, + 'recommendedPartSize': 100000000, + 'apiUrl': 'https://api000.backblazeb2.xyz:8180', + 's3ApiUrl': 'https://s3.us-west-000.backblazeb2.xyz:8180', + } + + if apiver_int < 3: + storage_api.update( + { + 'capabilities': capabilities, + 'namePrefix': namePrefix, + 'bucketId': None, + 'bucketName': None, + } + ) + else: + storage_api['allowed'] = { + 'buckets': None, + 'capabilities': capabilities, + 'namePrefix': namePrefix, + } + return { 'authorize_account': { 'accountId': '6012deadbeef', 'apiInfo': { 'groupsApi': {}, - 'storageApi': { - 'bucketId': None, - 'bucketName': None, - 'capabilities': copy(ALL_CAPABILITIES), - 'namePrefix': None, - 'downloadUrl': 'https://f000.backblazeb2.xyz:8180', - 'absoluteMinimumPartSize': 5000000, - 'recommendedPartSize': 100000000, - 'apiUrl': 'https://api000.backblazeb2.xyz:8180', - 's3ApiUrl': 'https://s3.us-west-000.backblazeb2.xyz:8180', - }, + 'storageApi': storage_api, }, 'authorizationToken': '4_1111111111111111111111111_11111111_111111_1111_1111111111111_1111_11111111=', } diff --git a/test/unit/test_base.py b/test/unit/test_base.py index 4aa1c3785..85f34f40f 100644 --- a/test/unit/test_base.py +++ b/test/unit/test_base.py @@ -14,9 +14,7 @@ from contextlib import contextmanager import apiver_deps -from apiver_deps import B2Api - -from b2sdk.v2 import FullApplicationKey +from apiver_deps import B2Api, FullApplicationKey class TestBase(unittest.TestCase): @@ -51,13 +49,40 @@ def create_key( name_prefix: str | None = None, ) -> FullApplicationKey: """apiver-agnostic B2Api.create_key""" - result = api.create_key( + kwargs = dict( capabilities=capabilities, key_name=key_name, valid_duration_seconds=valid_duration_seconds, - bucket_id=bucket_id, name_prefix=name_prefix, ) + + if apiver_deps.V >= 3: + kwargs['bucket_ids'] = [bucket_id] if bucket_id else None + else: + kwargs['bucket_id'] = bucket_id + + result = api.create_key(**kwargs) if apiver_deps.V <= 1: return FullApplicationKey.from_create_response(result) return result + + +def create_key_multibucket( + api: B2Api, + capabilities: list[str], + key_name: str, + valid_duration_seconds: int | None = None, + bucket_ids: list[str] | None = None, + name_prefix: str | None = None, +) -> FullApplicationKey: + """Create a multi-bucket key""" + if apiver_deps.V < 3: + raise RuntimeError('Multibucket keys are only available in apiver >= 3') + + return api.create_key( + capabilities=capabilities, + key_name=key_name, + valid_duration_seconds=valid_duration_seconds, + bucket_ids=bucket_ids, + name_prefix=name_prefix, + ) diff --git a/test/unit/test_raw_simulator.py b/test/unit/test_raw_simulator.py index a3c789540..64350c9a9 100644 --- a/test/unit/test_raw_simulator.py +++ b/test/unit/test_raw_simulator.py @@ -14,7 +14,7 @@ import pytest -from b2sdk import _v3 as v3 +from b2sdk import v3 from test.helpers import patch_bind_params diff --git a/test/unit/test_session.py b/test/unit/test_session.py index bee5e1475..a19cb522a 100644 --- a/test/unit/test_session.py +++ b/test/unit/test_session.py @@ -11,13 +11,15 @@ from unittest import mock +import pytest from apiver_deps import AuthInfoCache, B2Session, DummyCache, InMemoryAccountInfo +from apiver_deps_exception import Unauthorized from .account_info.fixtures import * # noqa from .fixtures import * # noqa -class TestAuthorizeAccount: +class TestB2Session: @pytest.fixture(autouse=True) def setup(self, b2_session): self.b2_session = b2_session @@ -74,6 +76,50 @@ def test_clear_cache(self): assert self.b2_session.cache.clear.called is True + @pytest.mark.apiver(from_ver=3) + def test_app_key_info_no_info(self): + self.b2_session.account_info.get_allowed.return_value = dict( + buckets=None, + capabilities=ALL_CAPABILITIES, + namePrefix=None, + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('no_go', 'code') + with pytest.raises( + Unauthorized, match=r'no_go for application key with no restrictions \(code\)' + ): + self.b2_session.get_file_info_by_id(None) + + @pytest.mark.apiver(from_ver=3) + def test_app_key_info_no_info_no_message(self): + self.b2_session.account_info.get_allowed.return_value = dict( + buckets=None, + capabilities=ALL_CAPABILITIES, + namePrefix=None, + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('', 'code') + with pytest.raises( + Unauthorized, match=r'unauthorized for application key with no restrictions \(code\)' + ): + self.b2_session.get_file_info_by_id(None) + + @pytest.mark.apiver(from_ver=3) + def test_app_key_info_all_info(self): + self.b2_session.account_info.get_allowed.return_value = dict( + buckets=[ + {'id': '123456', 'name': 'my-bucket'}, + {'id': '456789', 'name': 'their-bucket'}, + ], + capabilities=['readFiles'], + namePrefix='prefix/', + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('no_go', 'code') + + with pytest.raises( + Unauthorized, + match=r"no_go for application key with capabilities 'readFiles', restricted to buckets \['my-bucket', 'their-bucket'\], restricted to files that start with 'prefix/' \(code\)", + ): + self.b2_session.get_file_info_by_id(None) + def test_session__with_in_memory_account_info(apiver_int): memory_info = InMemoryAccountInfo() diff --git a/test/unit/v2/test_bucket.py b/test/unit/v2/test_bucket.py index db0396925..dbbce3e9a 100644 --- a/test/unit/v2/test_bucket.py +++ b/test/unit/v2/test_bucket.py @@ -13,7 +13,7 @@ import pytest -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk.v2 import B2Api, Bucket from test.helpers import patch_bind_params diff --git a/test/unit/v2/test_raw_api.py b/test/unit/v2/test_raw_api.py index 0417e3491..5f8813d4a 100644 --- a/test/unit/v2/test_raw_api.py +++ b/test/unit/v2/test_raw_api.py @@ -13,7 +13,7 @@ import pytest -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk.v2 import B2Http, B2RawHTTPApi from test.helpers import patch_bind_params diff --git a/test/unit/v2/test_session.py b/test/unit/v2/test_session.py index 7cef0e004..cd5db1cca 100644 --- a/test/unit/v2/test_session.py +++ b/test/unit/v2/test_session.py @@ -12,11 +12,62 @@ from unittest.mock import Mock import pytest +from apiver_deps_exception import Unauthorized -from b2sdk import _v3 as v3 +from b2sdk import v3 from b2sdk.v2 import B2Session from test.helpers import patch_bind_params +from ..account_info.fixtures import * # noqa +from ..fixtures import * + + +class TestSession: + @pytest.fixture(autouse=True) + def setup(self, b2_session): + self.b2_session = b2_session + + def test_app_key_info_no_info(self): + self.b2_session.account_info.get_allowed.return_value = dict( + bucketId=None, + bucketName=None, + capabilities=ALL_CAPABILITIES, + namePrefix=None, + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('no_go', 'code') + with pytest.raises( + Unauthorized, match=r'no_go for application key with no restrictions \(code\)' + ): + self.b2_session.get_file_info_by_id(None) + + def test_app_key_info_no_info_no_message(self): + self.b2_session.account_info.get_allowed.return_value = dict( + bucketId=None, + bucketName=None, + capabilities=ALL_CAPABILITIES, + namePrefix=None, + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('', 'code') + with pytest.raises( + Unauthorized, match=r'unauthorized for application key with no restrictions \(code\)' + ): + self.b2_session.get_file_info_by_id(None) + + def test_app_key_info_all_info(self): + self.b2_session.account_info.get_allowed.return_value = dict( + bucketId='123456', + bucketName='my-bucket', + capabilities=['readFiles'], + namePrefix='prefix/', + ) + self.b2_session.raw_api.get_file_info_by_id.side_effect = Unauthorized('no_go', 'code') + + with pytest.raises( + Unauthorized, + match=r"no_go for application key with capabilities 'readFiles', restricted to bucket 'my-bucket', restricted to files that start with 'prefix/' \(code\)", + ): + self.b2_session.get_file_info_by_id(None) + @pytest.fixture def dummy_session(): diff --git a/test/unit/v3/apiver/apiver_deps.py b/test/unit/v3/apiver/apiver_deps.py index 80bb0ab83..918fd2a74 100644 --- a/test/unit/v3/apiver/apiver_deps.py +++ b/test/unit/v3/apiver/apiver_deps.py @@ -9,6 +9,6 @@ ###################################################################### from __future__ import annotations -from b2sdk._v3 import * # noqa +from b2sdk.v3 import * # noqa V = 3 diff --git a/test/unit/v3/apiver/apiver_deps_exception.py b/test/unit/v3/apiver/apiver_deps_exception.py index a4a258b42..5dc11a13f 100644 --- a/test/unit/v3/apiver/apiver_deps_exception.py +++ b/test/unit/v3/apiver/apiver_deps_exception.py @@ -9,4 +9,4 @@ ###################################################################### from __future__ import annotations -from b2sdk._v3.exception import * # noqa +from b2sdk.v3.exception import * # noqa diff --git a/test/unit/v_all/test_api.py b/test/unit/v_all/test_api.py index 409fb7856..93c610d24 100644 --- a/test/unit/v_all/test_api.py +++ b/test/unit/v_all/test_api.py @@ -9,6 +9,7 @@ ###################################################################### from __future__ import annotations +import apiver_deps import pytest from apiver_deps import ( B2Api, @@ -158,7 +159,9 @@ def test_get_download_url_for_fileid(self): download_url = self.api.get_download_url_for_fileid('file-id') - assert ( - download_url - == 'http://download.example.com/b2api/v3/b2_download_file_by_id?fileId=file-id' - ) + if apiver_deps.V >= 3: + exp_url = 'http://download.example.com/b2api/v4/b2_download_file_by_id?fileId=file-id' + else: + exp_url = 'http://download.example.com/b2api/v3/b2_download_file_by_id?fileId=file-id' + + assert download_url == exp_url