Skip to content

Commit 13db2de

Browse files
committed
Add copy file to b2-cli
1 parent b0d7249 commit 13db2de

File tree

5 files changed

+303
-2
lines changed

5 files changed

+303
-2
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ this:
2424
b2 cancel-all-unfinished-large-files <bucketName>
2525
b2 cancel-large-file <fileId>
2626
b2 copy-file [--metadataDirective [copy|replace]] [--contentType <contentType>] \
27-
[--info <key>=<value>]* [--range start,end] [--noProgress] <sourceFileId> <destinationBucketName> <b2FileName>
27+
[--info <key>=<value>]* [--range start,end] <sourceFileId> <destinationBucketName> <b2FileName>
2828
b2 clear-account
2929
b2 create-bucket [--bucketInfo <json>] [--corsRules <json>] [--lifecycleRules <json>] <bucketName> [allPublic | allPrivate]
3030
b2 create-key [--duration <validDurationSeconds>] [--bucket <bucketName>] [--namePrefix <namePrefix>] <keyName> <capabilities>

b2/console_tool.py

+95-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from b2sdk.sync.scan_policies import ScanPoliciesManager
3939
from b2sdk.file_version import (FileVersionInfo)
4040
from b2sdk.progress import (make_progress_listener)
41-
from b2sdk.raw_api import (SRC_LAST_MODIFIED_MILLIS)
41+
from b2sdk.raw_api import (MetadataDirectiveMode, SRC_LAST_MODIFIED_MILLIS)
4242
from b2sdk.sync import parse_sync_folder, sync_folders
4343
from b2.version import (VERSION)
4444
from b2.parse_args import parse_arg_list
@@ -355,6 +355,100 @@ def run(self, args):
355355
return 0
356356

357357

358+
class CopyFile(Command):
359+
"""
360+
b2 copy-file [--metadataDirective [copy|replace]] [--contentType <contentType>] \\
361+
[--info <key>=<value>]* [--range start,end] \\
362+
<sourceFileId> <destinationBucketName> <b2FileName>
363+
364+
Copy a file to the given bucket. Uploads the contents
365+
of the source B2 file to destination bucket,
366+
and assigns the given name to the new B2 file.
367+
368+
By default, it copies the file info and content type but you can replace
369+
by setting the metadataDirective to 'replace'.
370+
371+
Content type is optional. If not set, it will be set based on the
372+
source file. It should only be provided when metadataDirective is replace and
373+
should not be provided when metadataDirective is copy.
374+
375+
By default, the whole file gets copied but you can copy a range of bytes
376+
from the source file to the new file.
377+
378+
info is optional. Each fileInfo is of the form "a=b".
379+
If not set, it will be set based on the source file.
380+
It should only be provided when metadataDirective is replace and
381+
should not be provided when metadataDirective is copy.
382+
383+
Requires capability: readFiles (if sourceFileId bucket is private) and writeFiles
384+
"""
385+
386+
REQUIRED = ['sourceFileId', 'destinationBucketName', 'b2FileName']
387+
OPTION_ARGS = ['metadataDirective', 'contentType', 'range']
388+
LIST_ARGS = ['info']
389+
390+
def run(self, args):
391+
file_infos = None
392+
if args.info:
393+
file_infos = {}
394+
for info in args.info:
395+
parts = info.split('=', 1)
396+
if len(parts) == 1:
397+
raise BadFileInfo(info)
398+
file_infos[parts[0]] = parts[1]
399+
400+
bytes_range = None
401+
if args.range:
402+
bytes_range = args.range.split(',')
403+
if len(bytes_range) != 2:
404+
logger.error(
405+
'ConsoleTool \'range\' must be exact 2 values, start and end. provided: %s',
406+
len(bytes_range),
407+
)
408+
self._print_stderr('ERROR: --range can must have exact 2 values, start and end')
409+
return 1
410+
try:
411+
bytes_range = tuple([int(i) for i in bytes_range])
412+
except ValueError:
413+
logger.error('ConsoleTool \'range\' start and end must be integers',)
414+
self._print_stderr('ERROR: --range start,end must be integers')
415+
return 1
416+
417+
metadata_directive = None
418+
if args.metadataDirective:
419+
if args.metadataDirective.upper() == 'COPY':
420+
metadata_directive = MetadataDirectiveMode.COPY
421+
elif args.metadataDirective.upper() == 'REPLACE':
422+
metadata_directive = MetadataDirectiveMode.REPLACE
423+
else:
424+
logger.error(
425+
'ConsoleTool \'metadataDirective\' must be either '
426+
'\'copy\' or \'replace\', provided: %s',
427+
args.metadataDirective,
428+
)
429+
self._print_stderr(
430+
'ERROR: --metadataDirective must be either \'copy\' or \'replace\''
431+
)
432+
return 1
433+
434+
bucket = self.api.get_bucket_by_name(args.destinationBucketName)
435+
436+
try:
437+
response = bucket.copy_file(
438+
args.sourceFileId,
439+
args.b2FileName,
440+
bytes_range=bytes_range,
441+
metadata_directive=metadata_directive,
442+
content_type=args.contentType,
443+
file_info=file_infos,
444+
)
445+
except Exception as e:
446+
logger.error(sys.exc_info())
447+
raise
448+
self._print(json.dumps(response, indent=2, sort_keys=True))
449+
return 0
450+
451+
358452
class CreateBucket(Command):
359453
"""
360454
b2 create-bucket [--bucketInfo <json>] [--corsRules <json>] [--lifecycleRules <json>] <bucketName> [allPublic | allPrivate]

contrib/bash_completion/b2

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ _b2 () {
33
local _b2_subcommands=(
44
'authorize_account'
55
'clear_account'
6+
'copy_file'
67
'create_bucket'
78
'delete_bucket'
89
'delete_file_version'

test/test_console_tool.py

+201
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,207 @@ def test_files(self):
589589

590590
self._run_command(['delete_file_version', '9999'], expected_stdout, '', 0)
591591

592+
def test_copy_file(self):
593+
self._authorize_account()
594+
self._create_my_bucket()
595+
596+
with TempDir() as temp_dir:
597+
local_file1 = self._make_local_file(temp_dir, 'file1.txt')
598+
# For this test, use a mod time without millis. My mac truncates
599+
# millis and just leaves seconds.
600+
mod_time = 1500111222
601+
os.utime(local_file1, (mod_time, mod_time))
602+
self.assertEqual(1500111222, os.path.getmtime(local_file1))
603+
604+
# Upload a file
605+
expected_stdout = '''
606+
URL by file name: http://download.example.com/file/my-bucket/file1.txt
607+
URL by fileId: http://download.example.com/b2api/{api_version}/b2_download_file_by_id?fileId=9999
608+
{{
609+
"action": "upload",
610+
"fileId": "9999",
611+
"fileName": "file1.txt",
612+
"size": 11,
613+
"uploadTimestamp": 5000
614+
}}
615+
'''
616+
617+
self._run_command(
618+
['upload_file', '--noProgress', 'my-bucket', local_file1, 'file1.txt'],
619+
expected_stdout, '', 0
620+
)
621+
622+
# Copy File
623+
expected_stdout = '''
624+
{{
625+
"accountId": "{account_id}",
626+
"action": "copy",
627+
"bucketId": "bucket_0",
628+
"contentLength": 11,
629+
"contentSha1": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
630+
"contentType": "b2/x-auto",
631+
"fileId": "9998",
632+
"fileInfo": {{
633+
"src_last_modified_millis": "1500111222000"
634+
}},
635+
"fileName": "file1_copy.txt",
636+
"uploadTimestamp": 5001
637+
}}
638+
'''
639+
self._run_command(
640+
['copy_file', '9999', 'my-bucket', 'file1_copy.txt'], expected_stdout, '', 0
641+
)
642+
643+
# Copy File with range parameter
644+
expected_stdout = '''
645+
{{
646+
"accountId": "{account_id}",
647+
"action": "copy",
648+
"bucketId": "bucket_0",
649+
"contentLength": 6,
650+
"contentSha1": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
651+
"contentType": "b2/x-auto",
652+
"fileId": "9997",
653+
"fileInfo": {{
654+
"src_last_modified_millis": "1500111222000"
655+
}},
656+
"fileName": "file1_copy.txt",
657+
"uploadTimestamp": 5002
658+
}}
659+
'''
660+
self._run_command(
661+
['copy_file', '--range', '3,9', '9999', 'my-bucket', 'file1_copy.txt'],
662+
expected_stdout,
663+
'',
664+
0,
665+
)
666+
667+
# Invalid range size
668+
expected_stderr = "ERROR: --range can must have exact 2 values, start and end\n"
669+
self._run_command(
670+
['copy_file', '--range', '3,9,11', '9999', 'my-bucket', 'file1_copy.txt'],
671+
'',
672+
expected_stderr,
673+
1,
674+
)
675+
676+
# Invalid range values
677+
expected_stderr = "ERROR: --range start,end must be integers\n"
678+
self._run_command(
679+
['copy_file', '--range', '3,abc', '9999', 'my-bucket', 'file1_copy.txt'],
680+
'',
681+
expected_stderr,
682+
1,
683+
)
684+
685+
# Invalid metadata value
686+
expected_stderr = "ERROR: --metadataDirective must be either 'copy' or 'replace'\n"
687+
self._run_command(
688+
[
689+
'copy_file', '--metadataDirective', 'random', '9999', 'my-bucket',
690+
'file1_copy.txt'
691+
],
692+
'',
693+
expected_stderr,
694+
1,
695+
)
696+
697+
# Invalid metadata copy with file info
698+
expected_stderr = "ERROR: content_type and file_info should be None when metadata_directive is COPY\n"
699+
self._run_command(
700+
[
701+
'copy_file',
702+
'--metadataDirective',
703+
'copy',
704+
'--info',
705+
'a=b',
706+
'9999',
707+
'my-bucket',
708+
'file1_copy.txt',
709+
],
710+
'',
711+
expected_stderr,
712+
1,
713+
)
714+
715+
# Invalid metadata replace without file info
716+
expected_stderr = "ERROR: content_type cannot be None when metadata_directive is REPLACE\n"
717+
self._run_command(
718+
[
719+
'copy_file', '--metadataDirective', 'replace', '9999', 'my-bucket',
720+
'file1_copy.txt'
721+
],
722+
'',
723+
expected_stderr,
724+
1,
725+
)
726+
727+
# replace with content type and file info
728+
expected_stdout = '''
729+
{{
730+
"accountId": "{account_id}",
731+
"action": "copy",
732+
"bucketId": "bucket_0",
733+
"contentLength": 11,
734+
"contentSha1": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
735+
"contentType": "text/plain",
736+
"fileId": "9996",
737+
"fileInfo": {{
738+
"a": "b"
739+
}},
740+
"fileName": "file1_copy.txt",
741+
"uploadTimestamp": 5003
742+
}}
743+
'''
744+
self._run_command(
745+
[
746+
'copy_file',
747+
'--metadataDirective',
748+
'replace',
749+
'--contentType',
750+
'text/plain',
751+
'--info',
752+
'a=b',
753+
'9999',
754+
'my-bucket',
755+
'file1_copy.txt',
756+
],
757+
expected_stdout,
758+
'',
759+
0,
760+
)
761+
762+
# UnsatisfiableRange
763+
expected_stderr = "ERROR: The range in the request is outside the size of the file\n"
764+
self._run_command(
765+
['copy_file', '--range', '12,20', '9999', 'my-bucket', 'file1_copy.txt'],
766+
'',
767+
expected_stderr,
768+
1,
769+
)
770+
771+
# Copy in different bucket
772+
self._run_command(['create_bucket', 'my-bucket1', 'allPublic'], 'bucket_1\n', '', 0)
773+
expected_stdout = '''
774+
{{
775+
"accountId": "{account_id}",
776+
"action": "copy",
777+
"bucketId": "bucket_1",
778+
"contentLength": 11,
779+
"contentSha1": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
780+
"contentType": "b2/x-auto",
781+
"fileId": "9994",
782+
"fileInfo": {{
783+
"src_last_modified_millis": "1500111222000"
784+
}},
785+
"fileName": "file1_copy.txt",
786+
"uploadTimestamp": 5004
787+
}}
788+
'''
789+
self._run_command(
790+
['copy_file', '9999', 'my-bucket1', 'file1_copy.txt'], expected_stdout, '', 0
791+
)
792+
592793
def test_get_download_auth_defaults(self):
593794
self._authorize_account()
594795
self._create_my_bucket()

test_b2_command_line.py

+5
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,9 @@ def basic_test(b2_tool, bucket_name):
415415
['upload', 'upload', 'upload', 'upload', 'hide', 'upload', 'upload'],
416416
[f['action'] for f in list_of_files['files']]
417417
)
418+
419+
first_a_version = list_of_files['files'][0]
420+
418421
first_c_version = list_of_files['files'][4]
419422
second_c_version = list_of_files['files'][5]
420423
list_of_files = b2_tool.should_succeed_json(['list_file_versions', bucket_name, 'c'])
@@ -428,6 +431,8 @@ def basic_test(b2_tool, bucket_name):
428431
)
429432
should_equal(['c'], [f['fileName'] for f in list_of_files['files']])
430433

434+
b2_tool.should_succeed(['copy_file', first_a_version['fileId'], bucket_name, 'x'])
435+
431436
b2_tool.should_succeed(['ls', bucket_name], '^a{0}b/{0}d{0}'.format(os.linesep))
432437
b2_tool.should_succeed(
433438
['ls', '--long', bucket_name],

0 commit comments

Comments
 (0)