forked from Backblaze/B2_Command_Line_Tool
-
Notifications
You must be signed in to change notification settings - Fork 0
/
b2
executable file
·1930 lines (1556 loc) · 64 KB
/
b2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
######################################################################
#
# File: b2
#
# Copyright 2015 Backblaze Inc. All Rights Reserved.
#
# License https://www.backblaze.com/using_b2_code.html
#
######################################################################
# Note on #! line: There doesn't seem to be one that works for
# everybody. Most users of this program are Mac users who use the
# default Python installed in OSX, which is called "python" or
# "python2.7", but not "python2". So we don't use "python2".
"""
This is a B2 command-line tool. See the USAGE message for details.
"""
from abc import ABCMeta, abstractmethod
import base64
import datetime
import functools
import getpass
import hashlib
import httplib
import json
import os
import socket
import stat
import sys
import time
import traceback
import urllib
import urllib2
try:
from tqdm import tqdm # displays a nice progress bar
except ImportError:
tqdm = None # noqa
# To avoid confusion between official Backblaze releases of this tool and
# the versions on Github, we use the convention that the third number is
# odd for Github, and even for Backblaze releases.
VERSION = '0.3.11'
USAGE = """This program provides command-line access to the B2 service.
Usages:
b2 authorize_account [--dev | --staging | --production] [accountId] [applicationKey]
Prompts for Backblaze accountID and applicationKey (unless they are given
on the command line).
The account ID is a 12-digit hex number that you can get from
your account page on backblaze.com.
The application key is a 40-digit hex number that you can get from
your account page on backblaze.com.
Stores an account auth token in ~/.b2_account_info. This can be overridden using the
B2_ACCOUNT_INFO environment variable.
b2 clear_account
Erases everything in ~/.b2_account_info
b2 create_bucket <bucketName> <bucketType>
Creates a new bucket. Prints the ID of the bucket created.
b2 delete_bucket <bucketName>
Deletes the bucket with the given name.
b2 delete_file_version <fileName> <fileId>
Permanently and irrevocably deletes one version of a file.
b2 download_file_by_id <fileId> <localFileName>
Downloads the given file, and stores it in the given local file.
b2 download_file_by_name <bucketName> <fileName> <localFileName>
Downloads the given file, and stores it in the given local file.
b2 get_file_info <fileId>
Prints all of the information about the file, but not its contents.
b2 hide_file <bucketName> <fileName>
Uploads a new, hidden, version of the given file.
b2 list_buckets
Lists all of the buckets in the current account.
b2 list_file_names <bucketName> [<startingName>] [<numberToShow>]
Lists the names of the files in a bucket, starting at the
given point.
b2 list_file_versions <bucketName> [<startingName>] [<startingFileId>] [<numberToShow>]
Lists the names of the files in a bucket, starting at the
given point.
b2 ls [--long] [--versions] <bucketName> [<folderName>]
Using the file naming convention that "/" separates folder
names from their contents, returns a list of the files
and folders in a given folder. If no folder name is given,
lists all files at the top level.
The --long option produces very wide multi-column output
showing the upload date/time, file size, file id, whether it
is an uploaded file or the hiding of a file, and the file
name. Folders don't really exist in B2, so folders are
shown with "-" in each of the fields other than the name.
The --version option shows all of versions of each file, not
just the most recent.
b2 make_url <fileId>
Prints an URL that can be used to download the given file, if
it is public.
b2 sync [--delete] [--hide] <source> <destination>
UNDER DEVELOPMENT -- there may be changes coming to this command
Uploads or downloads multiple files from source to destination.
One of the paths must be a local file path, and the other must be
a B2 bucket path. Use "b2:<bucketName>/<prefix>" for B2 paths, e.g.
"b2:my-bucket-name/a/path/prefix/".
If the --delete or --hide flags are specified, destination files
are deleted or hidden if not present in the source path. Note that
files are matched only by name and size.
b2 update_bucket <bucketName> <bucketType>
Updates the bucketType of an existing bucket. Prints the ID
of the bucket updated.
b2 upload_file [--sha1 <sha1sum>] [--contentType <contentType>] [--info <key>=<value>]* <bucketName> <localFilePath> <b2FileName>
Uploads one file to the given bucket. Uploads the contents
of the local file, and assigns the given name to the B2 file.
By default, upload_file will compute the sha1 checksum of the file
to be uploaded. But, you you already have it, you can provide it
on the command line to save a little time.
Content type is optional. If not set, it will be set based on the
file extension.
If `tqdm` library is installed, progress bar is displayed on stderr.
(use pip install tqdm to install it)
Each fileInfo is of the form "a=b".
b2 version
Echos the version number of this program.
"""
## Exceptions
class B2Error(Exception):
pass
class BadJson(B2Error):
def __init__(self, message):
self.message = message
def __str__(self):
return 'Generic api error ("bad_json"): %s' % (self.message,)
class BadFileInfo(B2Error):
def __init__(self, data):
self.data = data
def __str__(self):
return 'Bad file info: %s' % (self.data,)
class ChecksumMismatch(B2Error):
def __init__(self, checksum_type, expected, actual):
self.checksum_type = checksum_type
self.expected = expected
self.actual = actual
def __str__(self):
return '%s checksum mismatch -- bad data' % (self.checksum_type,)
class DuplicateBucketName(B2Error):
def __init__(self, bucket_name):
self.bucket_name = bucket_name
def __str__(self):
return 'Bucket name is already in use: %s' % (self.bucket_name,)
class FileAlreadyHidden(B2Error):
def __init__(self, file_name):
self.file_name = file_name
def __str__(self):
return 'File already hidden: %s' % (self.file_name,)
class FatalError(B2Error):
def __init__(self, message, exception_tuples):
self.message = message
self.exception_tuples = exception_tuples
def __str__(self):
return 'FATAL ERROR: %s\nstacktraces:\n%s' % (
self.message,
"\n\n".join(
"".join(traceback.format_exception(type_, value, tb))
for type_, value, tb in self.exception_tuples
),
)
class FileNotPresent(B2Error):
def __init__(self, file_name):
self.file_name = file_name
def __str__(self):
return 'File not present: %s' % (self.file_name,)
class MaxFileSizeExceeded(B2Error):
def __init__(self, file_description, size, max_allowed_size):
self.file_description = file_description
self.size = size
self.max_allowed_size = max_allowed_size
def __str__(self):
return 'Allowed file size of exceeded for %s: %s > %s' % (
self.file_description,
self.size,
self.max_allowed_size,
)
class MaxRetriesExceeded(B2Error):
def __init__(self, limit, exception_info_list):
self.limit = limit
self.exception_info_list = exception_info_list
def __str__(self):
exceptions = '\n'.join(
wrapped_error.format_exception() for wrapped_error in self.exception_info_list
)
return 'FAILED to upload after %s tries. Encountered exceptions: %s' % (
self.limit,
exceptions,
)
class MissingAccountData(B2Error):
def __init__(self, key):
self.key = key
def __str__(self):
return 'Missing account data: %s' % (self.key,)
class NonExistentBucket(B2Error):
def __init__(self, bucket_name):
self.bucket_name = bucket_name
def __str__(self):
return 'No such bucket: %s' % (self.bucket_name,)
class StorageCapExceeded(B2Error):
def __str__(self):
return 'Cannot upload files, storage cap exceeded.'
class TruncatedOutput(B2Error):
def __init__(self, bytes_read, file_size):
self.bytes_read = bytes_read
self.file_size = file_size
def __str__(self):
return 'only %d of %d bytes read' % (self.bytes_read, self.file_size,)
class UnrecognizedBucketType(B2Error):
def __init__(self, type_):
self.type_ = type_
def __str__(self):
return 'Unrecognized bucket type: %s' % (self.type_,)
class AbstractWrappedError(B2Error):
__metaclass__ = ABCMeta
def __init__(self, data, url, params, headers, exc_info):
self.data = data
self.url = url
self.params = params
self.headers = headers
self.exc_info = exc_info
def format_exception(self):
"""
example output:
Error returned from server:
URL: https://pod-000-1004-00.backblaze.com/b2api/v1/b2_upload_file/424242424242424242424242/c001_v0001004_t0028
Params: None
Headers: {'X-Bz-Content-Sha1': '753ca1c2d0f3e8748320b38f5da057767029a036', 'X-Bz-File-Name': 'LICENSE', 'Content-Type': 'b2/x-auto', 'Content-Length': '1350'}
{
"code": "internal_error",
"message": "Internal server error",
"status": 500
}
Traceback (most recent call last):
File "./b2", line 873, in __enter__
self.file = urllib2.urlopen(request)
File "/usr/lib/python2.7/urllib2.py", line 127, in urlopen
return _opener.open(url, data, timeout)
File "/usr/lib/python2.7/urllib2.py", line 410, in open
response = meth(req, response)
File "/usr/lib/python2.7/urllib2.py", line 523, in http_response
'http', request, response, code, msg, hdrs)
File "/usr/lib/python2.7/urllib2.py", line 448, in error
return self._call_chain(*args)
File "/usr/lib/python2.7/urllib2.py", line 382, in _call_chain
result = func(*args)
File "/usr/lib/python2.7/urllib2.py", line 531, in http_error_default
raise HTTPError(req.get_full_url(), code, msg, hdrs, fp)
HTTPError: HTTP Error 500: Internal server error
"""
exc_type, exc_value, exc_traceback = self.exc_info
return '%s\n\n%s\n' % (
self, "".join(
traceback.format_exception(
exc_type,
exc_value,
exc_traceback,
)
)
)
@abstractmethod
def should_retry(self):
pass
def __str__(self):
return """Error returned from server:
URL: %s
Params: %s
Headers: %s
%s""" % (self.url, self.params, self.headers, self.data)
class WrappedHttpError(AbstractWrappedError):
@property
def code(self):
return self.exc_info[1].code
def should_retry(self):
return 500 <= self.code < 600
class WrappedHttplibError(AbstractWrappedError):
def should_retry(self):
return not isinstance(
self.exc_info[0], httplib.InvalidURL
) # raised if a port is given and is either non-numeric or empty
class WrappedUrlError(AbstractWrappedError):
def should_retry(self):
"""
common case is that self.data == (104, 'Connection reset by peer')
but there are others like timeout etc
"""
return True
class WrappedSocketError(AbstractWrappedError):
def should_retry(self):
return True
## Bucket
class Bucket(object):
__metaclass__ = ABCMeta
DEFAULT_CONTENT_TYPE = 'b2/x-auto'
MAX_UPLOAD_ATTEMPTS = 5
MAX_UPLOADED_FILE_SIZE = 5 * 1000 * 1000 * 1000
def __init__(self, api, id_, name=None, type_=None):
self.api = api
self.id_ = id_
self.name = name
self.type_ = type_
def get_id(self):
return self.id_
def set_type(self, type_):
account_info = self.api.account_info
auth_token = account_info.get_account_auth_token()
account_id = account_info.get_account_id()
return self.api.raw_api.update_bucket(
account_info.get_api_url(), auth_token, account_id, self.id_, type_
)
def ls(self, folder_to_list='', show_versions=False, max_entries=None, recursive=False):
"""Pretends that folders exist, and yields the information about the files in a folder.
B2 has a flat namespace for the files in a bucket, but there is a convention
of using "/" as if there were folders. This method searches through the
flat namespace to find the files and "folders" that live within a given
folder.
When the `recursive` flag is set, lists all of the files in the given
folder, and all of its sub-folders.
:param folder: The name of the folder to list. Must not start with "/".
Empty string means top-level folder.
:param show_versions: When true returns info about all versions of a file,
when false, just returns info about the most recent
versions.
:param max_entries: How many entries to return. 1 - 1000
:param recursive:
:return:
"""
auth_token = self.api.account_info.get_account_auth_token()
# Every file returned must have a name that starts with the
# folder name and a "/".
prefix = folder_to_list
if prefix != '' and not prefix.endswith('/'):
prefix += '/'
# Loop until all files in the named directory have been listed.
# The starting point of the first list_file_names request is the
# prefix we're looking for. The prefix ends with '/', which is
# now allowed for file names, so no file name will match exactly,
# but the first one after that point is the first file in that
# "folder". If the first search doesn't produce enough results,
# then we keep calling list_file_names until we get all of the
# names in this "folder".
current_dir = None
if show_versions:
api_name = 'b2_list_file_versions'
else:
api_name = 'b2_list_file_names'
url = url_for_api(self.api.account_info, api_name)
start_file_name = prefix
start_file_id = None
while True:
params = {'bucketId': self.id_, 'startFileName': start_file_name}
if start_file_id is not None:
params['startFileId'] = start_file_id
response = post_json(url, params, auth_token)
for entry in response['files']:
file_version_info = FileVersionInfoFactory.from_api_response(entry)
if not file_version_info.file_name.startswith(prefix):
# We're past the files we care about
return
after_prefix = file_version_info.file_name[len(prefix):]
if '/' not in after_prefix or recursive:
# This is not a folder, so we'll print it out and
# continue on.
yield file_version_info, None
current_dir = None
else:
# This is a folder. If it's different than the folder
# we're already in, then we can print it. This check
# is needed, because all of the files in the folder
# will be in the list.
folder_with_slash = after_prefix.split('/')[0] + '/'
if folder_with_slash != current_dir:
folder_name = prefix + folder_with_slash
yield file_version_info, folder_name
current_dir = folder_with_slash
if response['nextFileName'] is None:
# The response says there are no more files in the bucket,
# so we can stop.
return
# Now we need to set up the next search. The response from
# B2 has the starting point to continue with the next file,
# but if we're in the middle of a "folder", we can skip ahead
# to the end of the folder. The character after '/' is '0',
# so we'll replace the '/' with a '0' and start there.
#
# When recursive is True, current_dir is always None.
if current_dir is None:
start_file_name = response.get('nextFileName')
start_file_id = response.get('nextFileId')
else:
start_file_name = max(response['nextFileName'], prefix + current_dir[:-1] + '0',)
def list_file_names(self, start_filename=None, max_entries=None):
""" legacy interface which just returns whatever remote API returns """
auth_token = self.api.account_info.get_account_auth_token()
url = url_for_api(self.api.account_info, 'b2_list_file_names')
params = {
'bucketId': self.id_,
'startFileName': start_filename,
'maxFileCount': max_entries,
}
return post_json(url, params, auth_token)
def list_file_versions(self, start_filename=None, start_file_id=None, max_entries=None):
""" legacy interface which just returns whatever remote API returns """
auth_token = self.api.account_info.get_account_auth_token()
url = url_for_api(self.api.account_info, 'b2_list_file_versions')
params = {
'bucketId': self.id_,
'startFileName': start_filename,
'startFileId': start_file_id,
'maxFileCount': max_entries,
}
return post_json(url, params, auth_token)
def upload_file(
self,
local_file,
remote_filename,
content_type=None,
file_infos=None,
sha1_sum=None,
extra_headers=None,
quiet=False
):
if file_infos is None:
file_infos = {}
if content_type is None:
content_type = self.DEFAULT_CONTENT_TYPE
account_info = self.api.account_info
# Double check that the file is not too big.
size = os.path.getsize(local_file)
if size > self.MAX_UPLOADED_FILE_SIZE: # TODO: rather than hardcoding the allowed
# file size in the client library, we
# should let the remote API handle it
raise MaxFileSizeExceeded(local_file, size, self.MAX_UPLOADED_FILE_SIZE)
# Compute the SHA1 of the file being uploaded, if it wasn't provided on the command line.
if sha1_sum is None:
sha1_sum = hex_sha1_of_file(local_file)
exception_info_list = []
for i in xrange(self.MAX_UPLOAD_ATTEMPTS):
# refresh upload data in every attempt to work around a "busy storage pod"
upload_url, upload_auth_token = self._get_upload_data()
headers = {
'Authorization': upload_auth_token,
'X-Bz-File-Name': b2_url_encode(remote_filename),
'Content-Type': content_type,
'X-Bz-Content-Sha1': sha1_sum
}
for (k, v) in file_infos.iteritems():
headers['X-Bz-Info-' + k] = b2_url_encode(v)
try:
response = post_file(upload_url, headers, local_file, progress_bar=not quiet,)
return FileVersionInfoFactory.from_api_response(response)
except AbstractWrappedError as e:
if not e.should_retry():
raise
exception_info_list.append(e)
account_info.clear_bucket_upload_data(self.id_)
raise MaxRetriesExceeded(self.MAX_UPLOAD_ATTEMPTS, exception_info_list)
def _get_upload_data(self):
"""
Makes sure that we have an upload URL and auth token for the given bucket and
returns it.
"""
account_info = self.api.account_info
upload_url, upload_auth_token = account_info.get_bucket_upload_data(self.id_)
if None not in (upload_url, upload_auth_token):
return upload_url, upload_auth_token
auth_token = account_info.get_account_auth_token()
url = url_for_api(account_info, 'b2_get_upload_url')
params = {'bucketId': self.id_}
response = post_json(url, params, auth_token)
account_info.set_bucket_upload_data(
self.id_,
response['uploadUrl'],
response['authorizationToken'],
)
return account_info.get_bucket_upload_data(self.id_)
def get_download_url(self, filename):
return "%s/file/%s/%s" % (
self.api.account_info.get_download_url(),
b2_url_encode(self.name),
b2_url_encode(filename),
)
def hide_file(self, file_name):
account_info = self.api.account_info
auth_token = account_info.get_account_auth_token()
url = url_for_api(account_info, 'b2_hide_file')
params = {'bucketId': self.id_, 'fileName': file_name,}
response = post_json(url, params, auth_token)
return FileVersionInfoFactory.from_api_response(response)
def as_dict(self): # TODO: refactor with other as_dict()
result = {'accountId': self.api.account_info.get_account_id(), 'bucketId': self.id_,}
if self.name is not None:
result['bucketName'] = self.name
if self.type_ is not None:
result['bucketType'] = self.type_
return result
def __repr__(self):
return 'Bucket<%s,%s,%s>' % (self.id_, self.name, self.type_)
class BucketFactory(object):
@classmethod
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):
"""
turns this:
{
"bucketType": "allPrivate",
"bucketId": "a4ba6a39d8b6b5fd561f0010",
"bucketName": "zsdfrtsazsdfafr",
"accountId": "4aa9865d6f00"
}
into a Bucket object
"""
bucket_name = bucket_dict['bucketName']
bucket_id = bucket_dict['bucketId']
type_ = bucket_dict['bucketType']
if type_ is None:
raise UnrecognizedBucketType(bucket_dict['bucketType'])
return Bucket(api, bucket_id, bucket_name, type_)
## DAO
class FileVersionInfo(object):
LS_ENTRY_TEMPLATE = '%83s %6s %10s %8s %9d %s' # order is file_id, action, date, time, size, name
def __init__(self, id_, file_name, size, upload_timestamp, action):
self.id_ = id_
self.file_name = file_name
self.size = size # can be None (unknown)
self.upload_timestamp = upload_timestamp # can be None (unknown)
self.action = action # "upload" or "hide" or "delete"
def as_dict(self):
result = {'fileId': self.id_, 'fileName': self.file_name,}
if self.size is not None:
result['size'] = self.size
if self.upload_timestamp is not None:
result['uploadTimestamp'] = self.upload_timestamp
if self.action is not None:
result['action'] = self.action
return result
def format_ls_entry(self):
dt = datetime.datetime.utcfromtimestamp(self.upload_timestamp / 1000)
date_str = dt.strftime('%Y-%m-%d')
time_str = dt.strftime('%H:%M:%S')
size = self.size or 0 # required if self.action == 'hide'
return self.LS_ENTRY_TEMPLATE % (
self.id_,
self.action,
date_str,
time_str,
size,
self.file_name,
)
@classmethod
def format_folder_ls_entry(cls, name):
return cls.LS_ENTRY_TEMPLATE % ('-', '-', '-', '-', 0, name)
class FileVersionInfoFactory(object):
@classmethod
def from_api_response(cls, file_info_dict, force_action=None):
"""
turns this:
{
"action": "hide",
"fileId": "4_zBucketName_f103b7ca31313c69c_d20151230_m030117_c001_v0001015_t0000",
"fileName": "randomdata",
"size": 0,
"uploadTimestamp": 1451444477000
}
or this:
{
"accountId": "4aa9865d6f00",
"bucketId": "547a2a395826655d561f0010",
"contentLength": 1350,
"contentSha1": "753ca1c2d0f3e8748320b38f5da057767029a036",
"contentType": "application/octet-stream",
"fileId": "4_z547a2a395826655d561f0010_f106d4ca95f8b5b78_d20160104_m003906_c001_v0001013_t0005",
"fileInfo": {},
"fileName": "randomdata"
}
into a FileVersionInfo object
"""
assert file_info_dict.get('action') is None or force_action is None, \
'action was provided by both info_dict and function argument'
action = file_info_dict.get('action') or force_action
file_name = file_info_dict['fileName']
id_ = file_info_dict['fileId']
size = file_info_dict.get('size') or file_info_dict.get('contentLength')
upload_timestamp = file_info_dict.get('uploadTimestamp')
return FileVersionInfo(id_, file_name, size, upload_timestamp, action)
## Cache
class AbstractCache(object):
__metaclass__ = ABCMeta
@abstractmethod
def get_bucket_id_or_none_from_bucket_name(self, name):
pass
@abstractmethod
def save_bucket(self, bucket):
pass
@abstractmethod
def set_bucket_name_cache(self, buckets):
pass
def _name_id_iterator(self, buckets):
return ((bucket.name, bucket.id_) for bucket in buckets)
class DummyCache(AbstractCache):
""" Cache that does nothing """
def get_bucket_id_or_none_from_bucket_name(self, name):
return None
def save_bucket(self, bucket):
pass
def set_bucket_name_cache(self, buckets):
pass
class InMemoryCache(AbstractCache):
""" Cache that stores the information in memory """
def __init__(self):
self.name_id_map = {}
def get_bucket_id_or_none_from_bucket_name(self, name):
return self.name_id_map.get(name)
def save_bucket(self, bucket):
self.name_id_map[bucket.name] = bucket.id_
def set_bucket_name_cache(self, buckets):
self.name_id_map = dict(self._name_id_iterator(buckets))
class AuthInfoCache(AbstractCache):
""" Cache that stores data persistently in StoredAccountInfo """
def __init__(self, info):
self.info = info
def get_bucket_id_or_none_from_bucket_name(self, name):
return self.info.get_bucket_id_or_none_from_bucket_name(name)
def save_bucket(self, bucket):
self.info.save_bucket(bucket)
def set_bucket_name_cache(self, buckets):
self.info.refresh_entire_bucket_name_cache(self._name_id_iterator(buckets))
## B2RawApi
class B2RawApi(object):
"""
Provides access to the B2 web APIs, exactly as they are provided by B2.
Requires that you provide all necessary URLs and auth tokens for each call.
Each API call decodes the returned JSON and returns a dict.
For details on what each method does, see the B2 docs:
https://www.backblaze.com/b2/docs/
This class is intended to be a super-simple, very thin layer on top
of the HTTP calls. It can be mocked-out for testing higher layers.
And this class can be tested by exercising each call just once,
which is relatively quick.
"""
def _post_json(self, base_url, api_name, auth, **params):
"""
Helper method for calling an API with the given auth and params.
:param base_url: Something like "https://api001.backblaze.com/"
:param auth: Passed in Authorization header.
:param api_name: Example: "b2_create_bucket"
:param args: The rest of the parameters are passed to B2.
:return:
"""
url = base_url + '/b2api/v1/' + api_name
return post_json(url, params, auth)
def authorize_account(self, realm_url, account_id, application_key):
auth = 'Basic ' + base64.b64encode('%s:%s' % (account_id, application_key))
return self._post_json(realm_url, 'b2_authorize_account', auth)
def create_bucket(self, api_url, account_auth_token, account_id, bucket_name, bucket_type):
return self._post_json(
api_url,
'b2_create_bucket',
account_auth_token,
accountId=account_id,
bucketName=bucket_name,
bucketType=bucket_type
)
def delete_bucket(self, api_url, account_auth_token, account_id, bucket_id):
return self._post_json(
api_url,
'b2_delete_bucket',
account_auth_token,
accountId=account_id,
bucketId=bucket_id
)
def update_bucket(self, api_url, account_auth_token, account_id, bucket_id, bucket_type):
return self._post_json(
api_url,
'b2_update_bucket',
account_auth_token,
accountId=account_id,
bucketId=bucket_id,
bucketType=bucket_type
)
# TODO: move the rest of the calls from B2Api
## B2Api
class B2Api(object):
"""
Provides high-level access to the B2 API.
Adds an object-oriented layer on top of the raw API, so that
buckets and files returned are Python objects with accessor
methods.
Also, keeps a cache of information needed to access the service,
such as auth tokens and upload URLs.
"""
# TODO: move HTTP code out to B2RawApi
# TODO: ConsoleTool passes the account info cache into the constructor
# TODO: provide method to get the account info cache (so ConsoleTool can save it)
def __init__(self, account_info=None, cache=None):
"""
Initializes the API using the given account info.
:param account_info:
:param cache:
:return:
"""
# TODO: merge account_info and cache into a single object
self.raw_api = B2RawApi()
if account_info is None:
account_info = StoredAccountInfo()
if cache is None:
cache = AuthInfoCache(account_info)
self.account_info = account_info
if cache is None:
cache = DummyCache()
self.cache = cache
# buckets
def create_bucket(self, name, type_):
account_id = self.account_info.get_account_id()
auth_token = self.account_info.get_account_auth_token()
response = self.raw_api.create_bucket(
self.account_info.get_api_url(), auth_token, account_id, name, type_
)
bucket = BucketFactory.from_api_bucket_dict(self, response)
assert name == bucket.name, 'API created a bucket with different name\
than requested: %s != %s' % (name, bucket.name)
assert type_ == bucket.type_, 'API created a bucket with different type\
than requested: %s != %s' % (type_, bucket.type_)
self.cache.save_bucket(bucket)
return bucket
def get_bucket_by_id(self, bucket_id):
return Bucket(self, bucket_id)
def get_bucket_by_name(self, bucket_name):
"""
Returns the bucket_id for the given bucket_name.
If we don't already know it from the cache, try fetching it from
the B2 service.
"""
# If we can get it from the stored info, do that.
id_ = self.cache.get_bucket_id_or_none_from_bucket_name(bucket_name)
if id_ is not None:
return Bucket(self, id_, name=bucket_name)
for bucket in self.list_buckets():
if bucket.name == bucket_name:
return bucket
raise NonExistentBucket(bucket_name)
def delete_bucket(self, bucket):
"""
Deletes the bucket remotely.
For legacy reasons it returns whatever server sends in response,
but API user should not rely on the response: if it doesn't raise
an exception, it means that the operation was a success
"""
account_id = self.account_info.get_account_id()
auth_token = self.account_info.get_account_auth_token()
return self.raw_api.delete_bucket(
self.account_info.get_api_url(), auth_token, account_id, bucket.id_
)
def list_buckets(self):
"""
Calls b2_list_buckets and returns the JSON for *all* buckets.
"""
account_id = self.account_info.get_account_id()
auth_token = self.account_info.get_account_auth_token()
url = url_for_api(self.account_info, 'b2_list_buckets')
params = {'accountId': account_id}
response = post_json(url, params, auth_token)
buckets = BucketFactory.from_api_response(self, response)
self.cache.set_bucket_name_cache(buckets)
return buckets
# delete
def delete_file_version(self, file_id, file_name): # filename argument is not first,
# because one day it may become
# optional
auth_token = self.account_info.get_account_auth_token()
url = url_for_api(self.account_info, 'b2_delete_file_version')
params = {'fileName': file_name, 'fileId': file_id,}
response = post_json(url, params, auth_token)
file_info = FileVersionInfoFactory.from_api_response(response, force_action='delete',)
assert file_info.id_ == file_id
assert file_info.file_name == file_name
assert file_info.action == 'delete'