diff --git a/CHANGELOG.md b/CHANGELOG.md index 16cdd00ff..0e68c466b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +* Add `BucketStructure` to hold info about a bucket +* Add `include_existing_files` parameter to `ReplicationSetupHelper` +* Modify `create_key` to accept any iterable for `capabilities`, not only list + ## [1.17.3] - 2022-07-15 ### Fixed diff --git a/b2sdk/_v3/__init__.py b/b2sdk/_v3/__init__.py index 26583f542..55a6f1289 100644 --- a/b2sdk/_v3/__init__.py +++ b/b2sdk/_v3/__init__.py @@ -17,6 +17,8 @@ from b2sdk.api import Services from b2sdk.bucket import Bucket from b2sdk.bucket import BucketFactory +from b2sdk.bucket import BucketStructure +from b2sdk.bucket import ValueNotSet from b2sdk.raw_api import ALL_CAPABILITIES, REALM_URLS # encryption @@ -219,6 +221,12 @@ from b2sdk.replication.monitoring import ReplicationScanResult from b2sdk.replication.monitoring import ReplicationReport from b2sdk.replication.monitoring import ReplicationMonitor +from b2sdk.replication.check import TwoWayReplicationCheckGenerator +from b2sdk.replication.check import ReplicationSourceCheck +from b2sdk.replication.check import ReplicationDestinationCheck +from b2sdk.replication.check import TwoWayReplicationCheck +from b2sdk.replication.check import OtherPartyReplicationCheckData +from b2sdk.replication.check import CheckState # other diff --git a/b2sdk/api.py b/b2sdk/api.py index 1e3714f6f..4ddc05e2c 100644 --- a/b2sdk/api.py +++ b/b2sdk/api.py @@ -9,6 +9,7 @@ ###################################################################### from typing import Optional, Tuple, List, Generator +from contextlib import suppress from .account_info.abstract import AbstractAccountInfo from .api_config import B2HttpApiConfig, DEFAULT_HTTP_API_CONFIG @@ -533,16 +534,19 @@ def list_keys(self, start_application_key_id: Optional[str] = None def get_key(self, key_id: str) -> Optional[ApplicationKey]: """ - Gets information about a single key: it's capabilities, prefix, name etc + Gets information about a single key: its capabilities, prefix, name etc Returns `None` if the key does not exist. Raises an exception if profile is not permitted to list keys. """ - return next( - self.list_keys(start_application_key_id=key_id), - None, - ) + with suppress(StopIteration): + key = next(self.list_keys(start_application_key_id=key_id)) + + # list_keys() may return some other key if `key_id` does not exist; + # thus manually check that we retrieved the right key + if key.id_ == key_id: + return key # other def get_file_info(self, file_id: str) -> FileVersion: diff --git a/b2sdk/bucket.py b/b2sdk/bucket.py index 2d4f2a9f1..7aad092d0 100644 --- a/b2sdk/bucket.py +++ b/b2sdk/bucket.py @@ -10,7 +10,10 @@ import logging -from typing import Optional, Tuple +from typing import Optional, Tuple, Union, Type + +if False: + from b2sdk.api import B2Api from .encryption.setting import EncryptionSetting, EncryptionSettingFactory from .encryption.types import EncryptionMode @@ -48,16 +51,44 @@ logger = logging.getLogger(__name__) -class Bucket(metaclass=B2TraceMeta): +class ValueNotSet: + """Sentry class for signifying no value for a property was supplied""" + pass + + +NOT_SET = ValueNotSet() + + +class BucketStructure(metaclass=B2TraceMeta): """ - Provide access to a bucket in B2: listing files, uploading and downloading. + Structure holding all attributes of a bucket. + + This structure doesn't hold reference to B2Api, so unlike `Bucket` class + it cannot be used to perform any actions. Instead, this class is used + to only hold Bucket's fields for serializing / deserializing. + + Also important difference from `Bucket` is that this structure + allows storing subset of fields, setting others to `ValueNotSet`, + which preserves from serializing too much information. """ - DEFAULT_CONTENT_TYPE = AUTO_CONTENT_TYPE + id_: Union[str, ValueNotSet] + account_id: Union[str, ValueNotSet] + name: Union[str, ValueNotSet] + type_: Union[str, ValueNotSet] + bucket_info: Union[dict, ValueNotSet] + cors_rules: Union[dict, ValueNotSet] + lifecycle_rules: Union[dict, ValueNotSet] + revision: Union[int, ValueNotSet] + bucket_dict: Union[dict, ValueNotSet] + options_set: Union[set, ValueNotSet] + default_server_side_encryption: Union[EncryptionSetting, ValueNotSet] + default_retention: Union[BucketRetentionSetting, ValueNotSet] + is_file_lock_enabled: Union[Optional[bool], ValueNotSet] + replication: Union[Optional[ReplicationConfiguration], ValueNotSet] def __init__( self, - api, id_, name=None, type_=None, @@ -73,9 +104,10 @@ def __init__( default_retention: BucketRetentionSetting = UNKNOWN_BUCKET_RETENTION, is_file_lock_enabled: Optional[bool] = None, replication: Optional[ReplicationConfiguration] = None, + *, + account_id, ): """ - :param b2sdk.v2.B2Api api: an API object :param str id_: a bucket id :param str name: a bucket name :param str type_: a bucket type @@ -89,9 +121,10 @@ def __init__( :param b2sdk.v2.BucketRetentionSetting default_retention: default retention setting :param bool is_file_lock_enabled: whether file locking is enabled or not :param b2sdk.v2.ReplicationConfiguration replication: replication rules for the bucket + :param str account_id: id of the account owning the bucket """ - self.api = api self.id_ = id_ + self.account_id = account_id self.name = name self.type_ = type_ self.bucket_info = bucket_info or {} @@ -105,6 +138,45 @@ def __init__( self.is_file_lock_enabled = is_file_lock_enabled self.replication = replication + def __repr__(self): + return '%s<%s,%s,%s>' % (type(self).__name__, self.id_, self.name, self.type_) + + +class Bucket(BucketStructure): + """ + Provide access to a bucket in B2: listing files, uploading and downloading. + """ + + api: 'B2Api' + id_: str + account_id: str + name: str + type_: str + bucket_info: dict + cors_rules: dict + lifecycle_rules: dict + revision: int + bucket_dict: dict + options_set: set + default_server_side_encryption: EncryptionSetting + default_retention: BucketRetentionSetting + is_file_lock_enabled: Optional[bool] + replication: Optional[ReplicationConfiguration] + + DEFAULT_CONTENT_TYPE = AUTO_CONTENT_TYPE + + def __init__( + self, + api, + *args, + **kwargs, + ): + """ + :param b2sdk.v2.B2Api api: an API object + """ + self.api = api + super().__init__(*args, account_id=self.api.account_info.get_account_id(), **kwargs) + def get_fresh_state(self) -> 'Bucket': """ Fetch all the information about this bucket and return a new bucket object. @@ -960,15 +1032,13 @@ def as_dict(self): return result - def __repr__(self): - return 'Bucket<%s,%s,%s>' % (self.id_, self.name, self.type_) - class BucketFactory: """ This is a factory for creating bucket objects from different kind of objects. """ - BUCKET_CLASS = staticmethod(Bucket) + BUCKET_CLASS = Bucket + BUCKET_STRUCTURE_CLASS = BucketStructure @classmethod def from_api_response(cls, api, response): @@ -982,7 +1052,127 @@ def from_api_response(cls, api, response): return [cls.from_api_bucket_dict(api, bucket_dict) for bucket_dict in response['buckets']] @classmethod - def from_api_bucket_dict(cls, api, bucket_dict): + def bucket_structure_from_dict(cls, bucket_dict) -> Type[BUCKET_STRUCTURE_CLASS]: + """ + Turn a dictionary, like this: + + .. code-block:: python + + { + "bucketType": "allPrivate", + "accountId": "0991231", + "bucketId": "a4ba6a39d8b6b5fd561f0010", + "bucketName": "zsdfrtsazsdfafr", + "accountId": "4aa9865d6f00", + "bucketInfo": {}, + "options": [], + "revision": 1, + "defaultServerSideEncryption": { + "isClientAuthorizedToRead" : true, + "value": { + "algorithm" : "AES256", + "mode" : "SSE-B2" + } + }, + "fileLockConfiguration": { + "isClientAuthorizedToRead": true, + "value": { + "defaultRetention": { + "mode": null, + "period": null + }, + "isFileLockEnabled": false + } + }, + "replicationConfiguration": { + "clientIsAllowedToRead": true, + "value": { + "asReplicationSource": { + "replicationRules": [ + { + "destinationBucketId": "c5f35d53a90a7ea284fb0719", + "fileNamePrefix": "", + "includeExistingFiles": True, + "isEnabled": true, + "priority": 1, + "replicationRuleName": "replication-us-west" + }, + { + "destinationBucketId": "55f34d53a96a7ea284fb0719", + "fileNamePrefix": "", + "includeExistingFiles": True, + "isEnabled": true, + "priority": 2, + "replicationRuleName": "replication-us-west-2" + } + ], + "sourceApplicationKeyId": "10053d55ae26b790000000006" + }, + "asReplicationDestination": { + "sourceToDestinationKeyMapping": { + "10053d55ae26b790000000045": "10053d55ae26b790000000004", + "10053d55ae26b790000000046": "10053d55ae26b790030000004" + } + } + } + } + } + + into a BucketStructure object. + + :param dict bucket_dict: a dictionary with bucket properties + :rtype: BucketStructure + + """ + type_ = bucket_dict.get('bucketType', NOT_SET) + bucket_name = bucket_dict.get('bucketName', NOT_SET) + bucket_id = bucket_dict.get('bucketId', NOT_SET) + bucket_info = bucket_dict.get('bucketInfo', NOT_SET) + cors_rules = bucket_dict.get('corsRules', NOT_SET) + lifecycle_rules = bucket_dict.get('lifecycleRules', NOT_SET) + revision = bucket_dict.get('revision', NOT_SET) + options = set(bucket_dict['options']) if 'options' in bucket_dict else NOT_SET + account_id = bucket_dict.get('accountId', NOT_SET) + + # The existence of these complex settings is checked below, instead of inside of their respective factory + # classes, because those would either break or return objects indistinguishable from objects representing + # insufficient permission to read set values. + default_server_side_encryption = ( + EncryptionSettingFactory.from_bucket_dict(bucket_dict) + if EncryptionSettingFactory.TOP_LEVEL_KEY in bucket_dict else NOT_SET + ) + replication = ( + ReplicationConfigurationFactory.from_bucket_dict(bucket_dict).value + if ReplicationConfigurationFactory.TOP_LEVEL_KEY in bucket_dict else NOT_SET + ) + + if FileLockConfiguration.TOP_LEVEL_KEY in bucket_dict: + file_lock_configuration = FileLockConfiguration.from_bucket_dict(bucket_dict) + default_retention = file_lock_configuration.default_retention + is_file_lock_enabled = file_lock_configuration.is_file_lock_enabled + else: + default_retention = NOT_SET + is_file_lock_enabled = NOT_SET + + return cls.BUCKET_STRUCTURE_CLASS( + bucket_id, + bucket_name, + type_, + bucket_info, + cors_rules, + lifecycle_rules, + revision, + bucket_dict, + options, + default_server_side_encryption, + default_retention, + is_file_lock_enabled, + replication, + account_id=account_id, + ) + + @classmethod + def from_api_bucket_dict(cls, api, bucket_dict) -> Type[BUCKET_CLASS]: """ Turn a dictionary, like this: diff --git a/b2sdk/encryption/setting.py b/b2sdk/encryption/setting.py index de4fdbd6d..99493d3fb 100644 --- a/b2sdk/encryption/setting.py +++ b/b2sdk/encryption/setting.py @@ -220,6 +220,7 @@ def __repr__(self): class EncryptionSettingFactory: + TOP_LEVEL_KEY = 'defaultServerSideEncryption' # 2021-03-17: for the bucket the response of the server is: # if authorized to read: # "mode": "none" @@ -301,7 +302,7 @@ def from_bucket_dict(cls, bucket_dict: dict) -> Optional[EncryptionSetting]: """ default_sse = bucket_dict.get( - 'defaultServerSideEncryption', + cls.TOP_LEVEL_KEY, {'isClientAuthorizedToRead': False}, ) diff --git a/b2sdk/file_lock.py b/b2sdk/file_lock.py index 0484376ca..e5db35f09 100644 --- a/b2sdk/file_lock.py +++ b/b2sdk/file_lock.py @@ -284,6 +284,8 @@ def as_dict(self): } if self.period is not None: result['period'] = self.period.as_dict() + else: + result['period'] = None return result def serialize_to_json_for_request(self): @@ -301,6 +303,7 @@ def __repr__(self): class FileLockConfiguration: """Represent bucket's file lock configuration, i.e. whether the file lock mechanism is enabled and default file retention""" + TOP_LEVEL_KEY = 'fileLockConfiguration' def __init__( self, @@ -339,12 +342,12 @@ def from_bucket_dict(cls, bucket_dict): } """ - if not bucket_dict['fileLockConfiguration']['isClientAuthorizedToRead']: + if not bucket_dict[cls.TOP_LEVEL_KEY]['isClientAuthorizedToRead']: return cls(UNKNOWN_BUCKET_RETENTION, None) retention = BucketRetentionSetting.from_bucket_retention_dict( - bucket_dict['fileLockConfiguration']['value']['defaultRetention'] + bucket_dict[cls.TOP_LEVEL_KEY]['value']['defaultRetention'] ) - is_file_lock_enabled = bucket_dict['fileLockConfiguration']['value']['isFileLockEnabled'] + is_file_lock_enabled = bucket_dict[cls.TOP_LEVEL_KEY]['value']['isFileLockEnabled'] return cls(retention, is_file_lock_enabled) def as_dict(self): diff --git a/b2sdk/raw_api.py b/b2sdk/raw_api.py index ed2c9db57..d6268b223 100644 --- a/b2sdk/raw_api.py +++ b/b2sdk/raw_api.py @@ -13,7 +13,7 @@ from abc import ABCMeta, abstractmethod from enum import Enum, unique from logging import getLogger -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Iterable from .exception import FileOrBucketNotFound, ResourceNotFound, UnusableFileName, InvalidMetadataDirective, WrongEncryptionModeForBucketDefault, AccessDenied, SSECKeyError, RetentionWriteError from .encryption.setting import EncryptionMode, EncryptionSetting @@ -462,7 +462,7 @@ def create_bucket( ) def create_key( - self, api_url, account_auth_token, account_id, capabilities, key_name, + self, api_url, account_auth_token, account_id, capabilities: Iterable[str], key_name, valid_duration_seconds, bucket_id, name_prefix ): return self._post_json( @@ -470,7 +470,7 @@ def create_key( 'b2_create_key', account_auth_token, accountId=account_id, - capabilities=capabilities, + capabilities=list(capabilities), keyName=key_name, validDurationInSeconds=valid_duration_seconds, bucketId=bucket_id, diff --git a/b2sdk/raw_simulator.py b/b2sdk/raw_simulator.py index 074808ae4..c108a40ea 100644 --- a/b2sdk/raw_simulator.py +++ b/b2sdk/raw_simulator.py @@ -526,9 +526,6 @@ def __init__( self.is_file_lock_enabled = is_file_lock_enabled self.default_retention = NO_RETENTION_BUCKET_SETTING self.replication = replication - if self.replication is not None: - assert self.replication.asReplicationSource is None or self.replication.asReplicationSource.rules - assert self.replication.asReplicationDestination is None or self.replication.asReplicationDestination.sourceToDestinationKeyMapping def is_allowed_to_read_bucket_encryption_setting(self, account_auth_token): return self._check_capability(account_auth_token, 'readBucketEncryption') @@ -1400,6 +1397,9 @@ def delete_key(self, api_url, account_auth_token, application_key_id): 'application key does not exist: %s' % (application_key_id,), 'bad_request', ) + self.all_application_keys = [ + key for key in self.all_application_keys if key.application_key_id != application_key_id + ] return key_sim.as_key() def finish_large_file(self, api_url, account_auth_token, file_id, part_sha1_array): @@ -1644,8 +1644,8 @@ def list_keys( next_application_key_id = all_keys_sorted[ind + 1].application_key_id break - key_dicts = map(lambda key: key.as_key(), keys) - return dict(keys=list(key_dicts), nextApplicationKeyId=next_application_key_id) + key_dicts = [key.as_key() for key in keys] + return dict(keys=key_dicts, nextApplicationKeyId=next_application_key_id) def list_parts(self, api_url, account_auth_token, file_id, start_part_number, max_part_count): bucket_id = self.file_id_to_bucket_id[file_id] diff --git a/b2sdk/replication/check.py b/b2sdk/replication/check.py new file mode 100644 index 000000000..f2aea0ab5 --- /dev/null +++ b/b2sdk/replication/check.py @@ -0,0 +1,357 @@ +###################################################################### +# +# File: b2sdk/replication/check.py +# +# Copyright 2022 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### + +import enum +import warnings + +from dataclasses import dataclass, fields +from typing import Dict, Generator, Optional, Union + +from b2sdk import version +from b2sdk.api import B2Api +from b2sdk.application_key import ApplicationKey +from b2sdk.bucket import Bucket, BucketFactory, BucketStructure +from b2sdk.encryption.types import EncryptionMode +from b2sdk.exception import AccessDenied, BucketIdNotFound + + +def _safe_get_key(api: B2Api, key_id: str) -> Union[None, AccessDenied, ApplicationKey]: + try: + return api.get_key(key_id) + except AccessDenied: + return AccessDenied() + + +@dataclass +class TwoWayReplicationCheckGenerator: + source_api: B2Api + destination_api: B2Api + filter_source_bucket_name: Optional[str] = None + filter_destination_bucket_name: Optional[str] = None + filter_replication_rule_name: Optional[str] = None + file_name_prefix: Optional[str] = None + + def iter_checks(self) -> Generator['ReplicationCheck', None, None]: + source_buckets = self.source_api.list_buckets(bucket_name=self.filter_source_bucket_name) + for source_bucket in source_buckets: + yield from self._iter_source_bucket_checks(source_bucket) + + def _iter_source_bucket_checks(self, source_bucket: Bucket + ) -> Generator['ReplicationCheck', None, None]: + if not source_bucket.replication: + return + + if not source_bucket.replication.rules: + return + + source_key = _safe_get_key(self.source_api, source_bucket.replication.source_key_id) + for rule in source_bucket.replication.rules: + if ( + self.filter_replication_rule_name and rule.name != self.filter_replication_rule_name + ): + continue + + if self.file_name_prefix and rule.file_name_prefix != self.file_name_prefix: + continue + + try: + destination_bucket_list = self.destination_api.list_buckets( + bucket_id=rule.destination_bucket_id + ) + if not destination_bucket_list: + raise BucketIdNotFound(rule.destination_bucket_id) + except (AccessDenied, BucketIdNotFound): + yield ReplicationSourceCheck.from_data(source_bucket, rule.name) + continue + + if ( + self.filter_destination_bucket_name is not None and + destination_bucket_list[0].name != self.filter_destination_bucket_name + ): + continue + + yield TwoWayReplicationCheck.from_data( + source_bucket=source_bucket, + replication_rule_name=rule.name, + source_application_key=source_key, + destination_bucket=destination_bucket_list[0], + destination_application_keys=self._get_destination_bucket_keys( + destination_bucket_list[0] + ), + ) + + @classmethod + def _get_destination_bucket_keys(cls, destination_bucket: Bucket) -> \ + Dict[str, Union[None, ApplicationKey, AccessDenied]]: + if not destination_bucket.replication: + return {} + + key_ids = destination_bucket.replication.source_to_destination_key_mapping.values() + try: + return {key_id: destination_bucket.api.get_key(key_id) for key_id in key_ids} + except AccessDenied: + return dict.fromkeys(key_ids, AccessDenied()) + + +@enum.unique +class CheckState(enum.Enum): + OK = 'ok' + NOT_OK = 'not_ok' + UNKNOWN = 'unknown' + + def is_ok(self): + return self == self.OK + + @classmethod + def from_bool(cls, value: bool) -> 'CheckState': + return cls.OK if value else cls.NOT_OK + + +class ReplicationCheck: + @classmethod + def _check_key( + cls, + key: Union[None, ApplicationKey, AccessDenied], + capability: str, + replication_name_prefix: str, + bucket_id: str, + ) -> Dict[str, CheckState]: + + result = { + 'key_exists': CheckState.UNKNOWN, + 'key_bucket_match': CheckState.UNKNOWN, + 'key_capabilities': CheckState.UNKNOWN, + 'key_name_prefix_match': CheckState.UNKNOWN, + } + + if isinstance(key, AccessDenied): + pass + + elif key is None: + result = {k: CheckState.NOT_OK for k in result.keys()} + + else: + result.update( + { + 'key_exists': + CheckState.OK, + 'key_bucket_match': + CheckState.from_bool(key.bucket_id is None or key.bucket_id == bucket_id), + 'key_capabilities': + CheckState.from_bool(capability in key.capabilities), + 'key_name_prefix_match': + CheckState.from_bool( + key.name_prefix is None or + replication_name_prefix.startswith(key.name_prefix) + ), + } + ) + + return result + + def as_dict(self) -> dict: + result = {} + for field in fields(self): + field_value = getattr(self, field.name) + + if isinstance(field_value, ReplicationCheck): + # source.key_exists = OK ===> {'source_key_exists': OK} + result.update( + { + f'{field.name}_{key}' if not key[0] == '_' else f'_{field.name}_{key[1:]}': + value + for key, value in field_value.as_dict().items() + } + ) + else: + result[field.name] = field_value + + return result + + +@dataclass +class ReplicationSourceCheck(ReplicationCheck): + key_exists: CheckState + key_bucket_match: CheckState + key_capabilities: CheckState + key_name_prefix_match: CheckState + + is_enabled: CheckState + is_sse_c_disabled: CheckState + + _bucket: Bucket + _rule_name: str + _application_key: Union[None, AccessDenied, ApplicationKey] + + @classmethod + def from_data(cls, bucket: Bucket, rule_name: str) -> 'ReplicationSourceCheck': + application_key = _safe_get_key(bucket.api, bucket.replication.source_key_id) + + rules = [rule for rule in bucket.replication.rules if rule.name == rule_name] + assert rules + rule = rules[0] + + kwargs = { + '_bucket': + bucket, + '_rule_name': + rule_name, + '_application_key': + application_key, + 'is_enabled': + CheckState.from_bool(rule.is_enabled), + 'is_sse_c_disabled': + CheckState.from_bool( + bucket.default_server_side_encryption.mode != EncryptionMode.SSE_C + ), + **cls._check_key(application_key, 'readFiles', rule.file_name_prefix, bucket.id_), + } + + return cls(**kwargs) + + def other_party_data(self): + return OtherPartyReplicationCheckData( + bucket=self._bucket, + keys_mapping={self._bucket.replication.source_key_id: self._application_key}, + ) + + +@dataclass +class ReplicationDestinationCheck(ReplicationCheck): + key_exists: CheckState + key_capabilities: CheckState + key_bucket_match: CheckState + key_name_prefix_match: CheckState + + _bucket: Bucket + _application_key: Union[None, AccessDenied, ApplicationKey] + + @classmethod + def iter_by_keys(cls, bucket: Bucket) -> Generator['ReplicationDestinationCheck', None, None]: + keys_to_check = bucket.replication.source_to_destination_key_mapping.values() + for key_id in keys_to_check: + yield cls.from_data(bucket=bucket, key_id=key_id) + + @classmethod + def from_data(cls, bucket: Bucket, key_id: str) -> 'ReplicationDestinationCheck': + application_key = _safe_get_key(bucket.api, key_id) + kwargs = { + '_bucket': bucket, + '_application_key': application_key, + **cls._check_key(application_key, 'writeFiles', '', bucket.id_), + } + return cls(**kwargs) + + def other_party_data(self): + return OtherPartyReplicationCheckData( + bucket=self._bucket, + keys_mapping=self._keys, + ) + + +@dataclass +class TwoWayReplicationCheck(ReplicationCheck): + source: ReplicationSourceCheck + destination: ReplicationDestinationCheck + source_key_accepted_in_target_bucket: CheckState + file_lock_match: CheckState + + @classmethod + def from_data( + cls, + source_bucket: BucketStructure, + replication_rule_name: str, + source_application_key: Union[None, ApplicationKey, AccessDenied], + destination_bucket: BucketStructure, + destination_application_keys: Dict[str, Union[None, ApplicationKey, AccessDenied]], + ) -> 'TwoWayReplicationCheck': + + destination_application_key_id = destination_bucket.replication and destination_bucket.replication.source_to_destination_key_mapping.get( + source_bucket.replication.source_key_id + ) + + if destination_bucket.is_file_lock_enabled: + file_lock_match = CheckState.OK + elif source_bucket.is_file_lock_enabled is False: + file_lock_match = CheckState.OK + elif source_bucket.is_file_lock_enabled is None or destination_bucket.is_file_lock_enabled is None: + file_lock_match = CheckState.UNKNOWN + else: + file_lock_match = CheckState.NOT_OK + + kwargs = { + 'source': + ReplicationSourceCheck.from_data( + bucket=source_bucket, + rule_name=replication_rule_name, + ), + 'destination': + ReplicationDestinationCheck.from_data( + bucket=destination_bucket, + key_id=destination_application_key_id, + ), + 'source_key_accepted_in_target_bucket': + CheckState.from_bool(destination_application_key_id is not None), + 'file_lock_match': + file_lock_match, + } + + return cls(**kwargs) + + +@dataclass +class OtherPartyReplicationCheckData: + bucket: BucketStructure + keys_mapping: Dict[str, Union[None, ApplicationKey, AccessDenied]] + b2sdk_version: version = version.VERSION + + @classmethod + def _dump_key(self, key: Union[None, ApplicationKey, AccessDenied]): + if key is None: + return None + if isinstance(key, AccessDenied): + return key.__class__.__name__ + return key.as_dict() + + @classmethod + def _parse_key(cls, key_representation: Union[None, str, dict] + ) -> Union[None, ApplicationKey, AccessDenied]: + if key_representation is None: + return None + + if key_representation == AccessDenied.__name__: + return AccessDenied() + + return ApplicationKey.from_dict(key_representation) + + def as_dict(self): + return { + 'b2sdk_version': self.b2sdk_version, + 'bucket': self.bucket.as_dict(), + 'keys_mapping': {k: self._dump_key(v) + for k, v in self.keys_mapping.items()}, + } + + @classmethod + def from_dict(cls, dict_: dict): + other_party_version = dict_['b2sdk_version'] + if other_party_version != cls.b2sdk_version: + warnings.warn( + f'Other party used a different version of b2sdk ({other_party_version}, this version: ' + f'{cls.b2sdk_version}) when dumping data for checking replication health. Check may not be ' + f'complete.' + ) + + return cls( + b2sdk_version=other_party_version, + bucket=BucketFactory.bucket_structure_from_dict(dict_['bucket']), + keys_mapping={k: cls._parse_key(v) + for k, v in dict_['keys_mapping'].items()} + ) diff --git a/b2sdk/replication/setting.py b/b2sdk/replication/setting.py index f80ce99a2..350f2f397 100644 --- a/b2sdk/replication/setting.py +++ b/b2sdk/replication/setting.py @@ -192,6 +192,7 @@ def from_dict(cls, value_dict: dict) -> 'ReplicationConfiguration': @dataclass class ReplicationConfigurationFactory: + TOP_LEVEL_KEY = 'replicationConfiguration' is_client_authorized_to_read: bool value: Optional[ReplicationConfiguration] @@ -201,7 +202,7 @@ def from_bucket_dict(cls, bucket_dict: dict) -> 'ReplicationConfigurationFactory Returns ReplicationConfigurationFactory for the given bucket dict retrieved from the api. """ - replication_dict = bucket_dict.get('replicationConfiguration') or {} + replication_dict = bucket_dict.get(cls.TOP_LEVEL_KEY) or {} value_dict = replication_dict.get('value') or {} return cls( diff --git a/b2sdk/v1/api.py b/b2sdk/v1/api.py index 516317054..6636e934b 100644 --- a/b2sdk/v1/api.py +++ b/b2sdk/v1/api.py @@ -8,7 +8,7 @@ # ###################################################################### -from typing import Any, Dict, Optional, overload, Tuple, List +from typing import Any, Dict, Optional, overload, Tuple, Iterable from .download_dest import AbstractDownloadDestination from b2sdk import v2 @@ -188,7 +188,7 @@ def list_keys(self, start_application_key_id=None) -> dict: def create_key( self, - capabilities: List[str], + capabilities: Iterable[str], key_name: str, valid_duration_seconds: Optional[int] = None, bucket_id: Optional[str] = None, diff --git a/doc/source/api/bucket.rst b/doc/source/api/bucket.rst index 0d6110542..143d6e23d 100644 --- a/doc/source/api/bucket.rst +++ b/doc/source/api/bucket.rst @@ -4,3 +4,11 @@ B2 Bucket .. autoclass:: b2sdk.v2.Bucket() :inherited-members: :special-members: __init__ + + +.. autoclass:: b2sdk.v2.BucketStructure() + :inherited-members: + :special-members: __init__ + + +.. autoclass:: b2sdk.v2.ValueNotSet() diff --git a/noxfile.py b/noxfile.py index 6fac81a29..e30410a4e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -39,6 +39,7 @@ 'pytest-lazy-fixture==0.6.3', 'pyfakefs==4.5.6', 'pytest-xdist==2.5.0', + 'more_itertools==8.13.0', ] REQUIREMENTS_BUILD = ['setuptools>=20.2'] diff --git a/test/unit/bucket/test_bucket.py b/test/unit/bucket/test_bucket.py index 6d56e33bf..16984b5b0 100644 --- a/test/unit/bucket/test_bucket.py +++ b/test/unit/bucket/test_bucket.py @@ -8,14 +8,17 @@ # ###################################################################### import io +import itertools from contextlib import suppress from io import BytesIO import os import platform import unittest.mock as mock +from typing import List, Iterable import pytest +from .test_bucket_typing import get_all_annotations from ..test_base import TestBase, create_key import apiver_deps @@ -41,7 +44,7 @@ from apiver_deps import FileVersion as VFileVersionInfo from apiver_deps import B2Api from apiver_deps import B2HttpApiConfig -from apiver_deps import Bucket, BucketFactory +from apiver_deps import Bucket, BucketFactory, BucketStructure, ValueNotSet from apiver_deps import DownloadedFile from apiver_deps import DownloadVersion from apiver_deps import LargeFileUploadState @@ -203,6 +206,13 @@ def get_api(self): self.account_info, api_config=B2HttpApiConfig(_raw_api_class=self.RAW_SIMULATOR_CLASS) ) + def new_api_with_new_key(self, capabilities: Iterable[str]) -> B2Api: + new_key = create_key(self.api, capabilities=capabilities, key_name='newtestkey') + new_api = B2Api(StubAccountInfo()) + new_api.session.raw_api = self.simulator + new_api.authorize_account('production', new_key.id_, new_key.application_key) + return new_api + def setUp(self): self.bucket_name = 'my-bucket' self.account_info = StubAccountInfo() @@ -400,12 +410,7 @@ def test_version_by_name_file_lock(self): actual = (file_version.legal_hold, file_version.file_retention) self.assertEqual((legal_hold, file_retention), actual) - low_perm_account_info = StubAccountInfo() - low_perm_api = B2Api(low_perm_account_info) - low_perm_api.session.raw_api = self.simulator - low_perm_key = create_key( - self.api, - key_name='lowperm', + low_perm_api = self.new_api_with_new_key( capabilities=[ 'listKeys', 'listBuckets', @@ -413,8 +418,6 @@ def test_version_by_name_file_lock(self): 'readFiles', ] ) - - low_perm_api.authorize_account('production', low_perm_key.id_, low_perm_key.application_key) low_perm_bucket = low_perm_api.get_bucket_by_name('my-bucket-with-file-lock') file_version = low_perm_bucket.get_file_info_by_name('a') @@ -2122,3 +2125,54 @@ def test_file_info_3(self): def test_file_info_4(self): download_version = self.bucket.get_file_info_by_name('test.txt%253Ffoo%253Dbar') assert download_version.file_name == 'test.txt%253Ffoo%253Dbar' + + +class TestBucketStructure(TestCaseWithBucket): + def test_create_with_all_attributes(self): + recreated_structure = BucketFactory.bucket_structure_from_dict(self.bucket.bucket_dict) + for attr_name in get_all_annotations(BucketStructure): + assert getattr(self.bucket, + attr_name) == getattr(recreated_structure, attr_name), attr_name + + def test_create_with_all_attributes_low_permissions(self): + low_perm_api = self.new_api_with_new_key(capabilities=['listBuckets']) + low_perm_bucket = low_perm_api.get_bucket_by_name(self.bucket.name) + recreated_structure = BucketFactory.bucket_structure_from_dict(low_perm_bucket.bucket_dict) + + comparison_exclusion_list = [ + 'bucket_dict', 'default_server_side_encryption', 'default_retention', + 'is_file_lock_enabled', 'replication' + ] + for attr_name in comparison_exclusion_list: + assert hasattr(self.bucket, attr_name), attr_name + + for attr_name in get_all_annotations(BucketStructure): + assert not isinstance(getattr(recreated_structure, attr_name), ValueNotSet), attr_name + if attr_name not in comparison_exclusion_list: + assert getattr(self.bucket, + attr_name) == getattr(recreated_structure, attr_name), attr_name + + def test_create_with_some_attributes(self): + attributes_to_drop = { + 'cors_rules': 'corsRules', + 'default_server_side_encryption': 'defaultServerSideEncryption', + 'name': 'bucketName' + } + comparison_exclusion_list = ['bucket_dict'] + for attr_name in itertools.chain(attributes_to_drop, comparison_exclusion_list): + assert hasattr(self.bucket, attr_name), attr_name + + new_bucket_dict = self.bucket.bucket_dict.copy() + for key in attributes_to_drop.values(): + new_bucket_dict.pop(key) + + recreated_structure = BucketFactory.bucket_structure_from_dict(new_bucket_dict) + + for attr_name in get_all_annotations(BucketStructure): + if attr_name in comparison_exclusion_list: + assert not isinstance(getattr(recreated_structure, attr_name), ValueNotSet) + elif attr_name in attributes_to_drop: + assert isinstance(getattr(recreated_structure, attr_name), ValueNotSet) + else: + assert getattr(self.bucket, + attr_name) == getattr(recreated_structure, attr_name), attr_name diff --git a/test/unit/bucket/test_bucket_typing.py b/test/unit/bucket/test_bucket_typing.py new file mode 100644 index 000000000..92187282c --- /dev/null +++ b/test/unit/bucket/test_bucket_typing.py @@ -0,0 +1,29 @@ +###################################################################### +# +# File: test/unit/bucket/test_bucket_typing.py +# +# Copyright 2022 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### + +import collections +from typing import Union + +from apiver_deps import Bucket, BucketStructure, ValueNotSet + + +def get_all_annotations(class_: type): + return dict( + collections.ChainMap(*(getattr(cls, '__annotations__', {}) for cls in class_.__mro__)) + ) + + +def test_bucket_annotations(): + expected_structure_annotations = {} + for instance_var_name, type_ in get_all_annotations(Bucket).items(): + if instance_var_name == 'api': + continue + expected_structure_annotations[instance_var_name] = Union[type_, ValueNotSet] + assert expected_structure_annotations == get_all_annotations(BucketStructure) diff --git a/test/unit/replication/conftest.py b/test/unit/replication/conftest.py index 719f29816..378f7965a 100644 --- a/test/unit/replication/conftest.py +++ b/test/unit/replication/conftest.py @@ -8,9 +8,11 @@ # ###################################################################### +from typing import Union + import pytest -from apiver_deps import B2Api, B2HttpApiConfig, Bucket, RawSimulator, ReplicationConfiguration, ReplicationMonitor, ReplicationRule, StubAccountInfo +from apiver_deps import B2Api, B2HttpApiConfig, Bucket, FullApplicationKey, RawSimulator, ReplicationConfiguration, ReplicationMonitor, ReplicationRule, StubAccountInfo, TwoWayReplicationCheckGenerator @pytest.fixture @@ -24,32 +26,60 @@ def api() -> B2Api: simulator = api.session.raw_api account_id, master_key = simulator.create_account() api.authorize_account('production', account_id, master_key) - # api_url = account_info.get_api_url() - # account_auth_token = account_info.get_account_auth_token()1 return api @pytest.fixture -def destination_bucket(api) -> Bucket: - return api.create_bucket('destination-bucket', 'allPublic') +def destination_key(api) -> Union[FullApplicationKey, dict]: + return api.create_key(capabilities='writeFiles', key_name='destination-key') @pytest.fixture -def source_bucket(api, destination_bucket) -> Bucket: - bucket = api.create_bucket('source-bucket', 'allPublic') - - bucket.replication = ReplicationConfiguration( - rules=[ - ReplicationRule( - destination_bucket_id=destination_bucket.id_, - name='name', - file_name_prefix='folder/', # TODO: is last slash needed? - ), - ], - source_key_id='hoho|trololo', +def destination_key_id(destination_key) -> str: + return destination_key.id_ + + +@pytest.fixture +def source_key(api) -> Union[FullApplicationKey, dict]: + return api.create_key(capabilities='readFiles', key_name='source-key') + + +@pytest.fixture +def source_key_id(source_key) -> str: + return source_key.id_ + + +@pytest.fixture +def destination_bucket(api, source_key_id, destination_key_id) -> Bucket: + return api.create_bucket( + name='destination-bucket', + bucket_type='allPublic', + is_file_lock_enabled=False, + replication=ReplicationConfiguration( + source_to_destination_key_mapping={ + source_key_id: destination_key_id, + }, + ), ) - return bucket + +@pytest.fixture +def source_bucket(api, destination_bucket, source_key_id) -> Bucket: + return api.create_bucket( + name='source-bucket', + bucket_type='allPublic', + is_file_lock_enabled=False, + replication=ReplicationConfiguration( + rules=[ + ReplicationRule( + destination_bucket_id=destination_bucket.id_, + name='name', + file_name_prefix='folder/', # TODO: is last slash needed? + ), + ], + source_key_id=source_key_id, + ), + ) @pytest.fixture @@ -72,3 +102,11 @@ def monitor(source_bucket) -> ReplicationMonitor: source_bucket, rule=source_bucket.replication.rules[0], ) + + +@pytest.fixture +def troubleshooter(source_bucket, destination_bucket) -> TwoWayReplicationCheckGenerator: + return TwoWayReplicationCheckGenerator( + source_api=source_bucket.api, + destination_api=destination_bucket.api, + ) diff --git a/test/unit/replication/test_troubleshooter.py b/test/unit/replication/test_troubleshooter.py new file mode 100644 index 000000000..950072f48 --- /dev/null +++ b/test/unit/replication/test_troubleshooter.py @@ -0,0 +1,274 @@ +###################################################################### +# +# File: test/unit/replication/test_troubleshooter.py +# +# Copyright 2022 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### + +import pytest + +from apiver_deps import CheckState, EncryptionAlgorithm, EncryptionKey, EncryptionMode, EncryptionSetting, TwoWayReplicationCheck, TwoWayReplicationCheckGenerator +from more_itertools import one + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_bucket_name_filter(api, source_bucket, destination_bucket): + bucket_name = source_bucket.name + + # check original name filter + troubleshooter = TwoWayReplicationCheckGenerator( + source_api=source_bucket.api, + destination_api=destination_bucket.api, + filter_source_bucket_name=bucket_name, + ) + assert len(list(troubleshooter.iter_checks())) == 1 + + # check other name filter + troubleshooter = TwoWayReplicationCheckGenerator( + source_api=source_bucket.api, + destination_api=destination_bucket.api, + filter_source_bucket_name=bucket_name + '-other', + ) + assert len(list(troubleshooter.iter_checks())) == 0 + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_rule_name_filter(api, source_bucket, destination_bucket): + rule_name = source_bucket.replication.rules[0].name + + # check original name filter + troubleshooter = TwoWayReplicationCheckGenerator( + source_api=source_bucket.api, + destination_api=destination_bucket.api, + filter_replication_rule_name=rule_name, + ) + assert len(list(troubleshooter.iter_checks())) == 1 + + # check other name filter + troubleshooter = TwoWayReplicationCheckGenerator( + source_api=source_bucket.api, + destination_api=destination_bucket.api, + filter_replication_rule_name=rule_name + '-other', + ) + assert len(list(troubleshooter.iter_checks())) == 0 + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_all_ok(api, source_bucket, troubleshooter): + check = one(troubleshooter.iter_checks()) + assert isinstance(check, TwoWayReplicationCheck) + + assert check.source.is_enabled == CheckState.OK + assert check.source.key_exists == CheckState.OK + assert check.source.key_bucket_match == CheckState.OK + assert check.source.key_capabilities == CheckState.OK + assert check.source.key_name_prefix_match == CheckState.OK + assert check.source.is_sse_c_disabled == CheckState.OK + + assert check.source_key_accepted_in_target_bucket == CheckState.OK + + assert check.destination.key_exists == CheckState.OK + assert check.destination.key_bucket_match == CheckState.OK + assert check.destination.key_capabilities == CheckState.OK + assert check.destination.key_name_prefix_match == CheckState.OK + assert check.file_lock_match == CheckState.OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_not_enabled(api, source_bucket, troubleshooter): + replication = source_bucket.replication + replication.rules[0].is_enabled = False + source_bucket.update(replication=replication) + + check = one(troubleshooter.iter_checks()) + assert check.source.is_enabled == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_key_does_not_exist(api, source_bucket, source_key, troubleshooter): + api.delete_key(source_key) + assert not api.get_key(source_key.id_) + + check = one(troubleshooter.iter_checks()) + assert check.source.key_exists == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_key_bucket_match(api, source_bucket, source_key, troubleshooter): + key = api.raw_api.key_id_to_key[source_key.id_] + + key.bucket_id_or_none = None + check = one(troubleshooter.iter_checks()) + assert check.source.key_bucket_match == CheckState.OK + + key.bucket_id_or_none = source_bucket.id_ + check = one(troubleshooter.iter_checks()) + assert check.source.key_bucket_match == CheckState.OK + + key.bucket_id_or_none = 'hehe-trololo' + check = one(troubleshooter.iter_checks()) + assert check.source.key_bucket_match == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_key_capabilities(api, source_bucket, source_key, troubleshooter): + key = api.raw_api.key_id_to_key[source_key.id_] + + key.capabilities = ['readFilesWithPepper'] + check = one(troubleshooter.iter_checks()) + assert check.source.key_capabilities == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_key_name_prefix_match( + api, source_bucket, source_key, troubleshooter +): + key = api.raw_api.key_id_to_key[source_key.id_] + + key.name_prefix_or_none = None + check = one(troubleshooter.iter_checks()) + assert check.source.key_name_prefix_match == CheckState.OK + + key.name_prefix_or_none = 'folder/' + check = one(troubleshooter.iter_checks()) + assert check.source.key_name_prefix_match == CheckState.OK + + key.name_prefix_or_none = 'hoho-trololo/' + check = one(troubleshooter.iter_checks()) + assert check.source.key_name_prefix_match == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_sse_c_disabled(api, source_bucket, source_key, troubleshooter): + source_bucket.update( + default_server_side_encryption=EncryptionSetting( + mode=EncryptionMode.SSE_B2, + algorithm=EncryptionAlgorithm.AES256, + ), + replication=source_bucket.replication, + ) + check = one(troubleshooter.iter_checks()) + assert check.source.is_sse_c_disabled == CheckState.OK + + source_bucket.update( + default_server_side_encryption=EncryptionSetting( + mode=EncryptionMode.SSE_C, + algorithm=EncryptionAlgorithm.AES256, + key=EncryptionKey(secret='hoho', key_id='haha'), + ), + replication=source_bucket.replication, + ) + check = one(troubleshooter.iter_checks()) + assert check.source.is_sse_c_disabled == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_source_key_accepted_in_target_bucket( + api, source_bucket, source_key, destination_bucket, troubleshooter +): + destination_replication = destination_bucket.replication + destination_replication.source_to_destination_key_mapping = {} + destination_bucket.update(replication=destination_replication) + + check = one(troubleshooter.iter_checks()) + assert check.source_key_accepted_in_target_bucket == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_file_lock_match( + api, source_bucket, source_key, destination_bucket, troubleshooter +): + source_bucket_obj = source_bucket.api.raw_api.bucket_id_to_bucket[source_bucket.id_] + destination_bucket_obj = destination_bucket.api.raw_api.bucket_id_to_bucket[ + destination_bucket.id_] + + # False, True + source_bucket_obj.is_file_lock_enabled = False + destination_bucket_obj.is_file_lock_enabled = True + + check = one(troubleshooter.iter_checks()) + assert check.file_lock_match == CheckState.OK + + # None, False + source_bucket_obj.is_file_lock_enabled = None + destination_bucket_obj.is_file_lock_enabled = False + + check = one(troubleshooter.iter_checks()) + assert check.file_lock_match == CheckState.UNKNOWN + + # True, None + source_bucket_obj.is_file_lock_enabled = True + destination_bucket_obj.is_file_lock_enabled = None + + check = one(troubleshooter.iter_checks()) + assert check.file_lock_match == CheckState.UNKNOWN + + # True, None + source_bucket_obj.is_file_lock_enabled = True + destination_bucket_obj.is_file_lock_enabled = False + + check = one(troubleshooter.iter_checks()) + assert check.file_lock_match == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_destination_key_exists( + api, destination_bucket, destination_key, troubleshooter +): + api.delete_key(destination_key) + assert not api.get_key(destination_key.id_) + + check = one(troubleshooter.iter_checks()) + assert check.destination.key_exists == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_destination_key_bucket_match( + api, destination_bucket, destination_key, troubleshooter +): + key = api.raw_api.key_id_to_key[destination_key.id_] + + key.bucket_id_or_none = None + check = one(troubleshooter.iter_checks()) + assert check.destination.key_bucket_match == CheckState.OK + + key.bucket_id_or_none = destination_bucket.id_ + check = one(troubleshooter.iter_checks()) + assert check.destination.key_bucket_match == CheckState.OK + + key.bucket_id_or_none = 'hehe-trololo' + check = one(troubleshooter.iter_checks()) + assert check.destination.key_bucket_match == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_destination_key_capabilities( + api, destination_bucket, destination_key, troubleshooter +): + key = api.raw_api.key_id_to_key[destination_key.id_] + + key.capabilities = ['readFilesWithPepper'] + check = one(troubleshooter.iter_checks()) + assert check.destination.key_capabilities == CheckState.NOT_OK + + +@pytest.mark.apiver(from_ver=2) +def test_troubleshooter_destination_key_name_prefix_match( + api, destination_bucket, destination_key, troubleshooter +): + key = api.raw_api.key_id_to_key[destination_key.id_] + + key.name_prefix_or_none = None + check = one(troubleshooter.iter_checks()) + assert check.destination.key_name_prefix_match == CheckState.OK + + key.name_prefix_or_none = '' + check = one(troubleshooter.iter_checks()) + assert check.destination.key_name_prefix_match == CheckState.OK + + key.name_prefix_or_none = 'hoho-trololo/' + check = one(troubleshooter.iter_checks()) + assert check.destination.key_name_prefix_match == CheckState.NOT_OK diff --git a/test/unit/test_base.py b/test/unit/test_base.py index 820b0c093..b94424cec 100644 --- a/test/unit/test_base.py +++ b/test/unit/test_base.py @@ -8,12 +8,11 @@ # ###################################################################### +from contextlib import contextmanager +from typing import Optional, Iterable import re import unittest -from contextlib import contextmanager -from typing import List, Optional - import apiver_deps from apiver_deps import B2Api @@ -46,7 +45,7 @@ def assertRaisesRegexp(self, expected_exception, expected_regexp): def create_key( api: B2Api, - capabilities: List[str], + capabilities: Iterable[str], key_name: str, valid_duration_seconds: Optional[int] = None, bucket_id: Optional[str] = None, diff --git a/test/unit/v_all/test_api.py b/test/unit/v_all/test_api.py index 43ecee13f..89a70c340 100644 --- a/test/unit/v_all/test_api.py +++ b/test/unit/v_all/test_api.py @@ -37,6 +37,7 @@ def _authorize_account(self): @pytest.mark.apiver(to_ver=1) def test_get_bucket_by_id_up_to_v1(self): + self._authorize_account() bucket = self.api.get_bucket_by_id("this id doesn't even exist") assert bucket.id_ == "this id doesn't even exist" for att_name, att_value in [