Skip to content

Commit f7c1626

Browse files
authored
Merge pull request #362 from reef-technologies/no_listBuckets
Cache the bucket if the key is limited to one
2 parents 5c640b6 + 9907425 commit f7c1626

File tree

7 files changed

+109
-28
lines changed

7 files changed

+109
-28
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
* Authorizing a key for a single bucket ensures that this bucket is cached
11+
912
### Infrastructure
1013
* Additional tests for listing files/versions
1114

b2sdk/_v3/exception.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from b2sdk.exception import NotAllowedByAppKeyError
5555
from b2sdk.exception import PartSha1Mismatch
5656
from b2sdk.exception import RestrictedBucket
57+
from b2sdk.exception import RestrictedBucketMissing
5758
from b2sdk.exception import RetentionWriteError
5859
from b2sdk.exception import SSECKeyError
5960
from b2sdk.exception import SSECKeyIdMismatchInCopy
@@ -134,6 +135,7 @@
134135
'NotAllowedByAppKeyError',
135136
'PartSha1Mismatch',
136137
'RestrictedBucket',
138+
'RestrictedBucketMissing',
137139
'RetentionWriteError',
138140
'ServiceError',
139141
'SourceReplicationConflict',

b2sdk/api.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
from contextlib import suppress
1313

1414
from .account_info.abstract import AbstractAccountInfo
15+
from .account_info.exception import MissingAccountData
1516
from .api_config import B2HttpApiConfig, DEFAULT_HTTP_API_CONFIG
1617
from .application_key import ApplicationKey, BaseApplicationKey, FullApplicationKey
1718
from .cache import AbstractCache
1819
from .bucket import Bucket, BucketFactory
1920
from .encryption.setting import EncryptionSetting
2021
from .replication.setting import ReplicationConfiguration
21-
from .exception import BucketIdNotFound, NonExistentBucket, RestrictedBucket
22+
from .exception import BucketIdNotFound, NonExistentBucket, RestrictedBucket, RestrictedBucketMissing
2223
from .file_lock import FileRetentionSetting, LegalHold
2324
from .file_version import DownloadVersionFactory, FileIdAndName, FileVersion, FileVersionFactory
2425
from .large_file.services import LargeFileServices
@@ -201,6 +202,7 @@ def authorize_account(self, realm, application_key_id, application_key):
201202
:param str application_key: user's :term:`application key`
202203
"""
203204
self.session.authorize_account(realm, application_key_id, application_key)
205+
self._populate_bucket_cache_from_key()
204206

205207
def get_account_id(self):
206208
"""
@@ -595,3 +597,23 @@ def _check_bucket_restrictions(self, key, value):
595597
if allowed_bucket_identifier is not None:
596598
if allowed_bucket_identifier != value:
597599
raise RestrictedBucket(allowed_bucket_identifier)
600+
601+
def _populate_bucket_cache_from_key(self):
602+
# If the key is restricted to the bucket, pre-populate the cache with it
603+
try:
604+
allowed = self.account_info.get_allowed()
605+
except MissingAccountData:
606+
return
607+
608+
allowed_bucket_id = allowed.get('bucketId')
609+
if allowed_bucket_id is None:
610+
return
611+
612+
allowed_bucket_name = allowed.get('bucketName')
613+
614+
# If we have bucketId set we still need to check bucketName. If the bucketName is None,
615+
# it means that the bucketId belongs to a bucket that was already removed.
616+
if allowed_bucket_name is None:
617+
raise RestrictedBucketMissing()
618+
619+
self.cache.save_bucket(self.BUCKET_CLASS(self, allowed_bucket_id, name=allowed_bucket_name))

b2sdk/exception.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self, *args, **kwargs):
3939
# If the exception is caused by a b2 server response,
4040
# the server MAY have included instructions to pause the thread before issuing any more requests
4141
self.retry_after_seconds = None
42-
super(B2Error, self).__init__(*args, **kwargs)
42+
super().__init__(*args, **kwargs)
4343

4444
@property
4545
def prefix(self):
@@ -82,7 +82,7 @@ class B2SimpleError(B2Error, metaclass=ABCMeta):
8282
"""
8383

8484
def __str__(self):
85-
return '%s: %s' % (self.prefix, super(B2SimpleError, self).__str__())
85+
return '%s: %s' % (self.prefix, super().__str__())
8686

8787

8888
class NotAllowedByAppKeyError(B2SimpleError, metaclass=ABCMeta):
@@ -134,7 +134,7 @@ class CapabilityNotAllowed(NotAllowedByAppKeyError):
134134

135135
class ChecksumMismatch(TransientErrorMixin, B2Error):
136136
def __init__(self, checksum_type, expected, actual):
137-
super(ChecksumMismatch, self).__init__()
137+
super().__init__()
138138
self.checksum_type = checksum_type
139139
self.expected = expected
140140
self.actual = actual
@@ -168,7 +168,7 @@ def __init__(self, clock_skew_seconds):
168168
"""
169169
:param int clock_skew_seconds: The difference: local_clock - server_clock
170170
"""
171-
super(ClockSkew, self).__init__()
171+
super().__init__()
172172
self.clock_skew_seconds = clock_skew_seconds
173173

174174
def __str__(self):
@@ -210,7 +210,7 @@ def should_retry_http(self):
210210

211211
class DestFileNewer(B2Error):
212212
def __init__(self, dest_path, source_path, dest_prefix, source_prefix):
213-
super(DestFileNewer, self).__init__()
213+
super().__init__()
214214
self.dest_path = dest_path
215215
self.source_path = source_path
216216
self.dest_prefix = dest_prefix
@@ -240,7 +240,7 @@ class ResourceNotFound(B2SimpleError):
240240

241241
class FileOrBucketNotFound(ResourceNotFound):
242242
def __init__(self, bucket_name=None, file_id_or_name=None):
243-
super(FileOrBucketNotFound, self).__init__()
243+
super().__init__()
244244
self.bucket_name = bucket_name
245245
self.file_id_or_name = file_id_or_name
246246

@@ -291,7 +291,7 @@ class SSECKeyIdMismatchInCopy(InvalidMetadataDirective):
291291

292292
class InvalidRange(B2Error):
293293
def __init__(self, content_length, range_):
294-
super(InvalidRange, self).__init__()
294+
super().__init__()
295295
self.content_length = content_length
296296
self.range_ = range_
297297

@@ -310,7 +310,7 @@ class InvalidUploadSource(B2SimpleError):
310310

311311
class BadRequest(B2Error):
312312
def __init__(self, message, code):
313-
super(BadRequest, self).__init__()
313+
super().__init__()
314314
self.message = message
315315
self.code = code
316316

@@ -326,7 +326,7 @@ def __init__(self, message, code, size: int):
326326

327327
class Unauthorized(B2Error):
328328
def __init__(self, message, code):
329-
super(Unauthorized, self).__init__()
329+
super().__init__()
330330
self.message = message
331331
self.code = code
332332

@@ -350,22 +350,29 @@ class InvalidAuthToken(Unauthorized):
350350
"""
351351

352352
def __init__(self, message, code):
353-
super(InvalidAuthToken,
354-
self).__init__('Invalid authorization token. Server said: ' + message, code)
353+
super().__init__('Invalid authorization token. Server said: ' + message, code)
355354

356355

357356
class RestrictedBucket(B2Error):
358357
def __init__(self, bucket_name):
359-
super(RestrictedBucket, self).__init__()
358+
super().__init__()
360359
self.bucket_name = bucket_name
361360

362361
def __str__(self):
363362
return 'Application key is restricted to bucket: %s' % self.bucket_name
364363

365364

365+
class RestrictedBucketMissing(RestrictedBucket):
366+
def __init__(self):
367+
super().__init__('')
368+
369+
def __str__(self):
370+
return 'Application key is restricted to a bucket that doesn\'t exist'
371+
372+
366373
class MaxFileSizeExceeded(B2Error):
367374
def __init__(self, size, max_allowed_size):
368-
super(MaxFileSizeExceeded, self).__init__()
375+
super().__init__()
369376
self.size = size
370377
self.max_allowed_size = max_allowed_size
371378

@@ -378,7 +385,7 @@ def __str__(self):
378385

379386
class MaxRetriesExceeded(B2Error):
380387
def __init__(self, limit, exception_info_list):
381-
super(MaxRetriesExceeded, self).__init__()
388+
super().__init__()
382389
self.limit = limit
383390
self.exception_info_list = exception_info_list
384391

@@ -405,7 +412,7 @@ class FileSha1Mismatch(B2SimpleError):
405412

406413
class PartSha1Mismatch(B2Error):
407414
def __init__(self, key):
408-
super(PartSha1Mismatch, self).__init__()
415+
super().__init__()
409416
self.key = key
410417

411418
def __str__(self):
@@ -435,7 +442,7 @@ def __str__(self):
435442

436443
class TooManyRequests(B2Error):
437444
def __init__(self, retry_after_seconds=None):
438-
super(TooManyRequests, self).__init__()
445+
super().__init__()
439446
self.retry_after_seconds = retry_after_seconds
440447

441448
def __str__(self):
@@ -447,7 +454,7 @@ def should_retry_http(self):
447454

448455
class TruncatedOutput(TransientErrorMixin, B2Error):
449456
def __init__(self, bytes_read, file_size):
450-
super(TruncatedOutput, self).__init__()
457+
super().__init__()
451458
self.bytes_read = bytes_read
452459
self.file_size = file_size
453460

@@ -482,7 +489,7 @@ def __str__(self):
482489

483490
class UploadTokenUsedConcurrently(B2Error):
484491
def __init__(self, token):
485-
super(UploadTokenUsedConcurrently, self).__init__()
492+
super().__init__()
486493
self.token = token
487494

488495
def __str__(self):

b2sdk/raw_simulator.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import threading
1717
import time
1818

19-
from contextlib import contextmanager
19+
from contextlib import contextmanager, suppress
2020
from typing import Optional
2121

2222
from b2sdk.http_constants import FILE_INFO_HEADER_PREFIX, HEX_DIGITS_AT_END
@@ -91,6 +91,7 @@ def __init__(
9191
self.capabilities = capabilities
9292
self.expiration_timestamp_or_none = expiration_timestamp_or_none
9393
self.bucket_id_or_none = bucket_id_or_none
94+
self.bucket_name_or_none = bucket_name_or_none
9495
self.name_prefix_or_none = name_prefix_or_none
9596

9697
def as_key(self):
@@ -121,6 +122,7 @@ def get_allowed(self):
121122
"""
122123
return dict(
123124
bucketId=self.bucket_id_or_none,
125+
bucketName=self.bucket_name_or_none,
124126
capabilities=self.capabilities,
125127
namePrefix=self.name_prefix_or_none,
126128
)
@@ -1305,10 +1307,13 @@ def create_key(
13051307
self.app_key_counter += 1
13061308
application_key_id = 'appKeyId%d' % (index,)
13071309
app_key = 'appKey%d' % (index,)
1308-
if bucket_id is None:
1309-
bucket_name_or_none = None
1310-
else:
1311-
bucket_name_or_none = self._get_bucket_by_id(bucket_id).bucket_name
1310+
bucket_name_or_none = None
1311+
if bucket_id is not None:
1312+
# It is possible for bucketId to be filled and bucketName to be empty.
1313+
# It can happen when the bucket was deleted.
1314+
with suppress(NonExistentBucket):
1315+
bucket_name_or_none = self._get_bucket_by_id(bucket_id).bucket_name
1316+
13121317
key_sim = KeySimulator(
13131318
account_id=account_id,
13141319
name=key_name,

b2sdk/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def authorize_account(self, realm, application_key_id, application_key):
131131
realm=realm,
132132
s3_api_url=response['s3ApiUrl'],
133133
allowed=allowed,
134-
application_key_id=application_key_id
134+
application_key_id=application_key_id,
135135
)
136136

137137
def cancel_large_file(self, file_id):

test/unit/bucket/test_bucket.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
InvalidRange,
3333
InvalidUploadSource,
3434
MaxRetriesExceeded,
35+
RestrictedBucketMissing,
3536
SSECKeyError,
3637
SourceReplicationConflict,
3738
UnsatisfiableRange,
@@ -57,6 +58,7 @@
5758
from apiver_deps import Range
5859
from apiver_deps import SimpleDownloader
5960
from apiver_deps import UploadSourceBytes
61+
from apiver_deps import DummyCache, InMemoryCache
6062
from apiver_deps import hex_sha1_of_bytes, TempDir
6163
from apiver_deps import EncryptionAlgorithm, EncryptionSetting, EncryptionMode, EncryptionKey, SSE_NONE, SSE_B2_AES
6264
from apiver_deps import CopySource, UploadSourceLocalFile, WriteIntent
@@ -200,10 +202,13 @@ def bucket_ls(bucket, *args, show_versions=False, **kwargs):
200202

201203
class TestCaseWithBucket(TestBase):
202204
RAW_SIMULATOR_CLASS = RawSimulator
205+
CACHE_CLASS = DummyCache
203206

204207
def get_api(self):
205208
return B2Api(
206-
self.account_info, api_config=B2HttpApiConfig(_raw_api_class=self.RAW_SIMULATOR_CLASS)
209+
self.account_info,
210+
cache=self.CACHE_CLASS(),
211+
api_config=B2HttpApiConfig(_raw_api_class=self.RAW_SIMULATOR_CLASS),
207212
)
208213

209214
def setUp(self):
@@ -215,7 +220,7 @@ def setUp(self):
215220
self.api.authorize_account('production', self.account_id, self.master_key)
216221
self.api_url = self.account_info.get_api_url()
217222
self.account_auth_token = self.account_info.get_account_auth_token()
218-
self.bucket = self.api.create_bucket('my-bucket', 'allPublic')
223+
self.bucket = self.api.create_bucket(self.bucket_name, 'allPublic')
219224
self.bucket_id = self.bucket.id_
220225

221226
def bucket_ls(self, *args, show_versions=False, **kwargs):
@@ -2175,9 +2180,46 @@ def test_file_info_4(self):
21752180
assert download_version.file_name == 'test.txt%253Ffoo%253Dbar'
21762181

21772182

2178-
# Listing where every other response returns no entries and pointer to the next file
2183+
class TestAuthorizeForBucket(TestCaseWithBucket):
2184+
CACHE_CLASS = InMemoryCache
2185+
2186+
@pytest.mark.apiver(from_ver=2)
2187+
def test_authorize_for_bucket_ensures_cache(self):
2188+
key = create_key(
2189+
self.api,
2190+
key_name='singlebucket',
2191+
capabilities=[
2192+
'listBuckets',
2193+
],
2194+
bucket_id=self.bucket_id,
2195+
)
2196+
2197+
self.api.authorize_account('production', key.id_, key.application_key)
21792198

2199+
# Check whether the bucket fetching performs an API call.
2200+
with mock.patch.object(self.api, 'list_buckets') as mock_list_buckets:
2201+
self.api.get_bucket_by_id(self.bucket_id)
2202+
mock_list_buckets.assert_not_called()
21802203

2204+
self.api.get_bucket_by_name(self.bucket_name)
2205+
mock_list_buckets.assert_not_called()
2206+
2207+
@pytest.mark.apiver(from_ver=2)
2208+
def test_authorize_for_non_existing_bucket(self):
2209+
key = create_key(
2210+
self.api,
2211+
key_name='singlebucket',
2212+
capabilities=[
2213+
'listBuckets',
2214+
],
2215+
bucket_id=self.bucket_id + 'x',
2216+
)
2217+
2218+
with self.assertRaises(RestrictedBucketMissing):
2219+
self.api.authorize_account('production', key.id_, key.application_key)
2220+
2221+
2222+
# Listing where every other response returns no entries and pointer to the next file
21812223
class EmptyListBucketSimulator(BucketSimulator):
21822224
def __init__(self, *args, **kwargs):
21832225
super().__init__(*args, **kwargs)

0 commit comments

Comments
 (0)