Skip to content

Commit cac5e0a

Browse files
Merge pull request #538 from reef-technologies/multi-bucket-keys
Add support for multi-bucket application keys
2 parents e2c4804 + c4cc0e0 commit cac5e0a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+1497
-331
lines changed

b2sdk/_internal/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"""
1111
b2sdk._internal package contains internal modules, and should not be used directly.
1212
13-
Please use chosen apiver package instead, e.g. b2sdk.v2
13+
Please use chosen apiver package instead, e.g. b2sdk.v3
1414
"""

b2sdk/_internal/account_info/abstract.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ class AbstractAccountInfo(metaclass=B2TraceMetaAbstract):
3131

3232
# The 'allowed' structure to use for old account info that was saved without 'allowed'.
3333
DEFAULT_ALLOWED = dict(
34-
bucketId=None,
35-
bucketName=None,
34+
buckets=None,
3635
capabilities=ALL_CAPABILITIES,
3736
namePrefix=None,
3837
)
@@ -318,7 +317,7 @@ def set_auth_data(
318317
"""
319318
if allowed is None:
320319
allowed = self.DEFAULT_ALLOWED
321-
assert self.allowed_is_valid(allowed)
320+
assert self.allowed_is_valid(allowed), allowed
322321

323322
self._set_auth_data(
324323
account_id,
@@ -337,22 +336,15 @@ def set_auth_data(
337336
@classmethod
338337
def allowed_is_valid(cls, allowed):
339338
"""
340-
Make sure that all of the required fields are present, and that
341-
bucketId is set if bucketName is.
339+
Make sure that all of the required fields are present
342340
343341
If the bucketId is for a bucket that no longer exists, or the
344342
capabilities do not allow for listBuckets, then we will not have a bucketName.
345343
346344
:param dict allowed: the structure to use for old account info that was saved without 'allowed'
347345
:rtype: bool
348346
"""
349-
return (
350-
('bucketId' in allowed)
351-
and ('bucketName' in allowed)
352-
and ((allowed['bucketId'] is not None) or (allowed['bucketName'] is None))
353-
and ('capabilities' in allowed)
354-
and ('namePrefix' in allowed)
355-
)
347+
return ('buckets' in allowed) and ('capabilities' in allowed) and ('namePrefix' in allowed)
356348

357349
@abstractmethod
358350
def _set_auth_data(

b2sdk/_internal/account_info/sqlite_account_info.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ def _create_tables(self, conn, last_upgrade_to_run):
280280
"""
281281
)
282282
# By default, we run all the upgrades
283-
last_upgrade_to_run = 4 if last_upgrade_to_run is None else last_upgrade_to_run
283+
last_upgrade_to_run = 5 if last_upgrade_to_run is None else last_upgrade_to_run
284284
# Add the 'allowed' column if it hasn't been yet.
285285
if 1 <= last_upgrade_to_run:
286286
self._ensure_update(1, ['ALTER TABLE account ADD COLUMN allowed TEXT;'])
@@ -384,26 +384,67 @@ def _create_tables(self, conn, last_upgrade_to_run):
384384
],
385385
)
386386

387+
if 5 <= last_upgrade_to_run:
388+
self._migrate_allowed_to_multi_bucket()
389+
390+
def _migrate_allowed_to_multi_bucket(self):
391+
"""
392+
Migrate existing allowed json dict to a new multi-bucket keys format
393+
"""
394+
if self._get_update_count(5) > 0:
395+
return
396+
397+
try:
398+
allowed_json = self._get_account_info_or_raise('allowed')
399+
except MissingAccountData:
400+
allowed_json = None
401+
402+
if allowed_json is None:
403+
self._perform_update(5, [])
404+
return
405+
406+
allowed = json.loads(allowed_json)
407+
408+
bucket_id = allowed.pop('bucketId')
409+
bucket_name = allowed.pop('bucketName')
410+
411+
if bucket_id is not None:
412+
allowed['buckets'] = [{'id': bucket_id, 'name': bucket_name}]
413+
else:
414+
allowed['buckets'] = None
415+
416+
allowed_text = json.dumps(allowed)
417+
stmt = f"UPDATE account SET allowed = ('{allowed_text}');"
418+
419+
self._perform_update(5, [stmt])
420+
387421
def _ensure_update(self, update_number, update_commands: list[str]):
388422
"""
389423
Run the update with the given number if it hasn't been done yet.
390424
391425
Does the update and stores the number as a single transaction,
392426
so they will always be in sync.
393427
"""
428+
update_count = self._get_update_count(update_number)
429+
if update_count > 0:
430+
return
431+
432+
self._perform_update(update_number, update_commands)
433+
434+
def _get_update_count(self, update_number: int):
394435
with self._get_connection() as conn:
395-
conn.execute('BEGIN')
396436
cursor = conn.execute(
397437
'SELECT COUNT(*) AS count FROM update_done WHERE update_number = ?;',
398438
(update_number,),
399439
)
400-
update_count = cursor.fetchone()[0]
401-
if update_count == 0:
402-
for command in update_commands:
403-
conn.execute(command)
404-
conn.execute(
405-
'INSERT INTO update_done (update_number) VALUES (?);', (update_number,)
406-
)
440+
return cursor.fetchone()[0]
441+
442+
def _perform_update(self, update_number, update_commands: list[str]):
443+
with self._get_connection() as conn:
444+
conn.execute('BEGIN')
445+
for command in update_commands:
446+
conn.execute(command)
447+
conn.execute('INSERT INTO update_done (update_number) VALUES (?);', (update_number,))
407448

408449
def clear(self):
409450
"""
@@ -551,8 +592,7 @@ def get_allowed(self):
551592
.. code-block:: python
552593
553594
{
554-
"bucketId": null,
555-
"bucketName": null,
595+
"buckets": null,
556596
"capabilities": [
557597
"listKeys",
558598
"writeKeys"
@@ -567,8 +607,8 @@ def get_allowed(self):
567607
allowed_json = self._get_account_info_or_raise('allowed')
568608
if allowed_json is None:
569609
return self.DEFAULT_ALLOWED
570-
else:
571-
return json.loads(allowed_json)
610+
611+
return json.loads(allowed_json)
572612

573613
def get_s3_api_url(self):
574614
result = self._get_account_info_or_raise('s3_api_url')

b2sdk/_internal/api.py

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
)
3838
from .large_file.services import LargeFileServices
3939
from .progress import AbstractProgressListener
40-
from .raw_api import API_VERSION, LifecycleRule
40+
from .raw_api import API_VERSION as RAW_API_VERSION
41+
from .raw_api import LifecycleRule
4142
from .replication.setting import ReplicationConfiguration
4243
from .session import B2Session
4344
from .transfer import (
@@ -52,21 +53,6 @@
5253
logger = logging.getLogger(__name__)
5354

5455

55-
def url_for_api(info, api_name):
56-
"""
57-
Return URL for an API endpoint.
58-
59-
:param info: account info
60-
:param str api_nam:
61-
:rtype: str
62-
"""
63-
if api_name in ['b2_download_file_by_id']:
64-
base = info.get_download_url()
65-
else:
66-
base = info.get_api_url()
67-
return f'{base}/b2api/{API_VERSION}/{api_name}'
68-
69-
7056
class Services:
7157
"""Gathers objects that provide high level logic over raw api usage."""
7258

@@ -141,7 +127,10 @@ class handles several things that simplify the task of uploading
141127
FILE_VERSION_FACTORY_CLASS = staticmethod(FileVersionFactory)
142128
DOWNLOAD_VERSION_FACTORY_CLASS = staticmethod(DownloadVersionFactory)
143129
SERVICES_CLASS = staticmethod(Services)
130+
APPLICATION_KEY_CLASS = ApplicationKey
131+
FULL_APPLICATION_KEY_CLASS = FullApplicationKey
144132
DEFAULT_LIST_KEY_COUNT = 1000
133+
API_VERSION = RAW_API_VERSION
145134

146135
def __init__(
147136
self,
@@ -484,14 +473,29 @@ def delete_file_version(
484473
response = self.session.delete_file_version(file_id, file_name, bypass_governance)
485474
return FileIdAndName.from_cancel_or_delete_response(response)
486475

476+
@classmethod
477+
def _get_url_for_api(cls, info, api_name) -> str:
478+
"""
479+
Return URL for an API endpoint.
480+
481+
:param info: account info
482+
:param str api_nam:
483+
:rtype: str
484+
"""
485+
if api_name in ['b2_download_file_by_id']:
486+
base = info.get_download_url()
487+
else:
488+
base = info.get_api_url()
489+
return f'{base}/b2api/{cls.API_VERSION}/{api_name}'
490+
487491
# download
488492
def get_download_url_for_fileid(self, file_id):
489493
"""
490494
Return a URL to download the given file by ID.
491495
492496
:param str file_id: a file ID
493497
"""
494-
url = url_for_api(self.account_info, 'b2_download_file_by_id')
498+
url = self._get_url_for_api(self.account_info, 'b2_download_file_by_id')
495499
return f'{url}?fileId={file_id}'
496500

497501
def get_download_url_for_file_name(self, bucket_name, file_name):
@@ -512,7 +516,7 @@ def create_key(
512516
capabilities: list[str],
513517
key_name: str,
514518
valid_duration_seconds: int | None = None,
515-
bucket_id: str | None = None,
519+
bucket_ids: list[str] | None = None,
516520
name_prefix: str | None = None,
517521
) -> FullApplicationKey:
518522
"""
@@ -531,14 +535,14 @@ def create_key(
531535
capabilities=capabilities,
532536
key_name=key_name,
533537
valid_duration_seconds=valid_duration_seconds,
534-
bucket_id=bucket_id,
538+
bucket_ids=bucket_ids,
535539
name_prefix=name_prefix,
536540
)
537541

538542
assert set(response['capabilities']) == set(capabilities)
539543
assert response['keyName'] == key_name
540544

541-
return FullApplicationKey.from_create_response(response)
545+
return self.FULL_APPLICATION_KEY_CLASS.from_create_response(response)
542546

543547
def delete_key(self, application_key: BaseApplicationKey):
544548
"""
@@ -557,7 +561,7 @@ def delete_key_by_id(self, application_key_id: str) -> ApplicationKey:
557561
"""
558562

559563
response = self.session.delete_key(application_key_id=application_key_id)
560-
return ApplicationKey.from_api_response(response)
564+
return self.APPLICATION_KEY_CLASS.from_api_response(response)
561565

562566
def list_keys(
563567
self, start_application_key_id: str | None = None
@@ -576,7 +580,7 @@ def list_keys(
576580
start_application_key_id=start_application_key_id,
577581
)
578582
for entry in response['keys']:
579-
yield ApplicationKey.from_api_response(entry)
583+
yield self.APPLICATION_KEY_CLASS.from_api_response(entry)
580584

581585
next_application_key_id = response['nextApplicationKeyId']
582586
if next_application_key_id is None:
@@ -599,6 +603,8 @@ def get_key(self, key_id: str) -> ApplicationKey | None:
599603
if key.id_ == key_id:
600604
return key
601605

606+
return None
607+
602608
# other
603609
def get_file_info(self, file_id: str) -> FileVersion:
604610
"""
@@ -630,7 +636,7 @@ def check_bucket_name_restrictions(self, bucket_name: str):
630636
631637
:raises b2sdk.v2.exception.RestrictedBucket: if the account is not allowed to use this bucket
632638
"""
633-
self._check_bucket_restrictions('bucketName', bucket_name)
639+
self._check_bucket_restrictions('name', bucket_name)
634640

635641
def check_bucket_id_restrictions(self, bucket_id: str):
636642
"""
@@ -641,15 +647,21 @@ def check_bucket_id_restrictions(self, bucket_id: str):
641647
642648
:raises b2sdk.v2.exception.RestrictedBucket: if the account is not allowed to use this bucket
643649
"""
644-
self._check_bucket_restrictions('bucketId', bucket_id)
650+
self._check_bucket_restrictions('id', bucket_id)
645651

646652
def _check_bucket_restrictions(self, key, value):
647-
allowed = self.account_info.get_allowed()
648-
allowed_bucket_identifier = allowed[key]
653+
buckets = self.account_info.get_allowed()['buckets']
649654

650-
if allowed_bucket_identifier is not None:
651-
if allowed_bucket_identifier != value:
652-
raise RestrictedBucket(allowed_bucket_identifier)
655+
if not buckets:
656+
return
657+
658+
for item in buckets:
659+
if item[key] == value:
660+
return
661+
662+
msg = str([b['name'] for b in buckets])
663+
664+
raise RestrictedBucket(msg)
653665

654666
def _populate_bucket_cache_from_key(self):
655667
# 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):
658670
except MissingAccountData:
659671
return
660672

661-
allowed_bucket_id = allowed.get('bucketId')
662-
if allowed_bucket_id is None:
673+
allowed_buckets = allowed.get('buckets')
674+
if not allowed_buckets:
663675
return
664676

665-
allowed_bucket_name = allowed.get('bucketName')
666-
667677
# If we have bucketId set we still need to check bucketName. If the bucketName is None,
668678
# it means that the bucketId belongs to a bucket that was already removed.
669-
if allowed_bucket_name is None:
670-
raise RestrictedBucketMissing()
671679

672-
self.cache.save_bucket(self.BUCKET_CLASS(self, allowed_bucket_id, name=allowed_bucket_name))
680+
for item in allowed_buckets:
681+
if item['name'] is None:
682+
raise RestrictedBucketMissing
683+
684+
for item in allowed_buckets:
685+
self.cache.save_bucket(self.BUCKET_CLASS(self, item['id'], name=item['name']))

b2sdk/_internal/application_key.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def __init__(
2020
capabilities: list[str],
2121
account_id: str,
2222
expiration_timestamp_millis: int | None = None,
23-
bucket_id: str | None = None,
23+
bucket_ids: list[str] | None = None,
2424
name_prefix: str | None = None,
2525
options: list[str] | None = None,
2626
):
@@ -39,7 +39,7 @@ def __init__(
3939
self.capabilities = capabilities
4040
self.account_id = account_id
4141
self.expiration_timestamp_millis = expiration_timestamp_millis
42-
self.bucket_id = bucket_id
42+
self.bucket_ids = bucket_ids
4343
self.name_prefix = name_prefix
4444
self.options = options
4545

@@ -54,7 +54,7 @@ def parse_response_dict(cls, response: dict):
5454

5555
optional_args = {
5656
'expiration_timestamp_millis': response.get('expirationTimestamp'),
57-
'bucket_id': response.get('bucketId'),
57+
'bucket_ids': response.get('bucketIds'),
5858
'name_prefix': response.get('namePrefix'),
5959
'options': response.get('options'),
6060
}
@@ -77,7 +77,7 @@ def as_dict(self):
7777
}
7878
optional_keys = {
7979
'expirationTimestamp': self.expiration_timestamp_millis,
80-
'bucketId': self.bucket_id,
80+
'bucketIds': self.bucket_ids,
8181
'namePrefix': self.name_prefix,
8282
'options': self.options,
8383
}
@@ -107,7 +107,7 @@ def __init__(
107107
capabilities: list[str],
108108
account_id: str,
109109
expiration_timestamp_millis: int | None = None,
110-
bucket_id: str | None = None,
110+
bucket_ids: list[str] | None = None,
111111
name_prefix: str | None = None,
112112
options: list[str] | None = None,
113113
):
@@ -129,7 +129,7 @@ def __init__(
129129
capabilities=capabilities,
130130
account_id=account_id,
131131
expiration_timestamp_millis=expiration_timestamp_millis,
132-
bucket_id=bucket_id,
132+
bucket_ids=bucket_ids,
133133
name_prefix=name_prefix,
134134
options=options,
135135
)

0 commit comments

Comments
 (0)