Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Header args #221

Merged
merged 12 commits into from
Nov 23, 2023
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
* Add `--expires`, `--content-disposition`, `--content-encoding`, `--content-language` options to subcommands `upload-file`, `upload-unbound-stream`, `copy-file-by-id`

### Infrastructure
* Fix `docker run` example in README.md

Expand Down
75 changes: 70 additions & 5 deletions b2/console_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from concurrent.futures import Executor, Future, ThreadPoolExecutor
from contextlib import suppress
from enum import Enum
from typing import Any, BinaryIO, Dict, List, Optional, Tuple
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Tuple

import argcomplete
import b2sdk
Expand Down Expand Up @@ -393,6 +393,68 @@ def _get_file_retention_setting(cls, args):
return FileRetentionSetting(file_retention_mode, args.retainUntil)


class HeaderFlagsMixin(Described):
@classmethod
def _setup_parser(cls, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
'--cache-control',
help=
"optional Cache-Control header, value based on RFC 2616 section 14.9, example: 'public, max-age=86400')"
)
parser.add_argument(
'--content-disposition',
help=
"optional Content-Disposition header, value based on RFC 2616 section 19.5.1, example: 'attachment; filename=\"fname.ext\"'"
)
parser.add_argument(
'--content-encoding',
help=
"optional Content-Encoding header, value based on RFC 2616 section 14.11, example: 'gzip'"
)
parser.add_argument(
'--content-language',
help=
"optional Content-Language header, value based on RFC 2616 section 14.12, example: 'mi, en'"
)
parser.add_argument(
'--expires',
help=
"optional Expires header, value based on RFC 2616 section 14.21, example: 'Thu, 01 Dec 2050 16:00:00 GMT'"
)
super()._setup_parser(parser)

def _file_info_with_header_args(self, args, file_info: dict[str, str] | None) -> dict[str, str] | None:
"""Construct an updated file_info dictionary.
Print a warning if any of file_info items will be overwritten by explicit header arguments.
"""
add_file_info = {}
overwritten = []
if args.cache_control is not None:
add_file_info['b2-cache-control'] = args.cache_control
if args.content_disposition is not None:
add_file_info['b2-content-disposition'] = args.content_disposition
if args.content_encoding is not None:
add_file_info['b2-content-encoding'] = args.content_encoding
if args.content_language is not None:
add_file_info['b2-content-language'] = args.content_language
if args.expires is not None:
add_file_info['b2-expires'] = args.expires

for key, value in add_file_info.items():
if file_info is not None and key in file_info and file_info[key] != value:
overwritten.append(key)

if overwritten:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great stuff!

self._print_stderr(
'The following file info items will be overwritten by explicit arguments:\n ' +
'\n '.join(f'{key} = {add_file_info[key]}' for key in overwritten)
)

if add_file_info:
return {**(file_info or {}), **add_file_info}
return file_info


class LegalHoldMixin(Described):
"""
Setting legal holds requires the **writeFileLegalHolds** capability, and only works in bucket
Expand Down Expand Up @@ -1010,7 +1072,7 @@ def run(self, args):

@B2.register_subcommand
class CopyFileById(
DestinationSseMixin, SourceSseMixin, FileRetentionSettingMixin, LegalHoldMixin, Command
HeaderFlagsMixin, DestinationSseMixin, SourceSseMixin, FileRetentionSettingMixin, LegalHoldMixin, Command
):
"""
Copy a file version to the given bucket (server-side, **not** via download+upload).
Expand Down Expand Up @@ -1072,6 +1134,7 @@ def run(self, args):
file_infos = self._parse_file_infos(args.info)
elif args.noInfo:
file_infos = {}
file_infos = self._file_info_with_header_args(args, file_infos)

if args.metadataDirective is not None:
self._print_stderr(
Expand Down Expand Up @@ -1361,6 +1424,8 @@ def _print_download_info(self, downloaded_file: DownloadedFile):
'Legal hold', self._represent_legal_hold(download_version.legal_hold)
)
for label, attr_name in [
('CacheControl', 'cache_control'),
('Expires', 'expires'),
('ContentDisposition', 'content_disposition'),
('ContentLanguage', 'content_language'),
('ContentEncoding', 'content_encoding'),
Expand Down Expand Up @@ -2860,6 +2925,7 @@ def _setup_parser(cls, parser):


class UploadFileMixin(
HeaderFlagsMixin,
MinPartSizeMixin,
ThreadsMixin,
ProgressMixin,
Expand Down Expand Up @@ -2888,7 +2954,6 @@ def _setup_parser(cls, parser):
parser.add_argument(
'--sha1', help="SHA-1 of the data being uploaded for verifying file integrity"
)
parser.add_argument('--cache-control', default=None)
parser.add_argument(
'--info',
action='append',
Expand Down Expand Up @@ -2935,11 +3000,11 @@ def get_execute_kwargs(self, args) -> dict:
else:
file_infos[SRC_LAST_MODIFIED_MILLIS] = str(int(mtime * 1000))

file_infos = self._file_info_with_header_args(args, file_infos)

return {
"bucket":
self.api.get_bucket_by_name(args.bucketName),
"cache_control":
args.cache_control,
"content_type":
args.contentType,
"custom_upload_timestamp":
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
argcomplete>=2,<4
arrow>=1.0.2,<2.0.0
b2sdk>=1.25.0,<2
b2sdk @ git+https://github.com/Backblaze/b2-sdk-python@c0507d1b54e376ad5206ab253b028963d4cc5bdc
docutils>=0.18.1
idna~=3.4; platform_system == 'Java'
importlib-metadata~=3.3; python_version < '3.8'
Expand Down
61 changes: 61 additions & 0 deletions test/integration/test_b2_command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -2690,3 +2690,64 @@ def test_cat(b2_tool, bucket_name, sample_filepath, tmp_path, uploaded_sample_fi
).replace("\r", "") == sample_filepath.read_text()
assert b2_tool.should_succeed(['cat', f"b2id://{uploaded_sample_file['fileId']}"
],).replace("\r", "") == sample_filepath.read_text()


def test_header_arguments(b2_tool, bucket_name, sample_filepath, tmp_path):
# yapf: disable
args = [
'--cache-control', 'max-age=3600',
'--content-disposition', 'attachment',
'--content-encoding', 'gzip',
'--content-language', 'en',
'--expires', 'Thu, 01 Dec 2050 16:00:00 GMT',
]
# yapf: enable
expected_file_info = {
'b2-cache-control': 'max-age=3600',
'b2-content-disposition': 'attachment',
'b2-content-encoding': 'gzip',
'b2-content-language': 'en',
'b2-expires': 'Thu, 01 Dec 2050 16:00:00 GMT',
}

def assert_expected(file_info, expected=expected_file_info):
for key, val in expected.items():
assert file_info[key] == val

status, stdout, stderr = b2_tool.execute(
[
'upload-file',
'--quiet',
'--noProgress',
bucket_name,
str(sample_filepath),
'sample_file',
*args,
'--info', 'b2-content-disposition=will-be-overwritten',
]
)
assert status == 0
file_version = json.loads(stdout)
assert_expected(file_version['fileInfo'])

# Since we used both --info and --content-disposition to set b2-content-disposition,
# a warning should be emitted
assert 'will be overwritten' in stderr and 'b2-content-disposition = attachment' in stderr


copied_version = b2_tool.should_succeed_json(
[
'copy-file-by-id', '--quiet', *args, '--contentType', 'text/plain',
file_version['fileId'], bucket_name, 'copied_file'
]
)
assert_expected(copied_version['fileInfo'])

download_output = b2_tool.should_succeed(
['download-file-by-id', file_version['fileId'], tmp_path / 'downloaded_file']
)
assert re.search(r'CacheControl: *max-age=3600', download_output)
assert re.search(r'ContentDisposition: *attachment', download_output)
assert re.search(r'ContentEncoding: *gzip', download_output)
assert re.search(r'ContentLanguage: *en', download_output)
assert re.search(r'Expires: *Thu, 01 Dec 2050 16:00:00 GMT', download_output)
17 changes: 13 additions & 4 deletions test/unit/console_tool/test_upload_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import b2


def test_upload_file__file_info_src_last_modified_millis(b2_cli, bucket, tmpdir):
def test_upload_file__file_info_src_last_modified_millis_and_headers(b2_cli, bucket, tmpdir):
"""Test upload_file supports manually specifying file info src_last_modified_millis"""
filename = 'file1.txt'
content = 'hello world'
Expand All @@ -23,15 +23,24 @@ def test_upload_file__file_info_src_last_modified_millis(b2_cli, bucket, tmpdir)
expected_json = {
"action": "upload",
"contentSha1": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",
"fileInfo": {
"src_last_modified_millis": "1"
},
"fileInfo":
{
"b2-cache-control": "max-age=3600",
"b2-expires": "Thu, 01 Dec 2050 16:00:00 GMT",
"b2-content-language": "en",
"b2-content-disposition": "attachment",
"b2-content-encoding": "gzip",
"src_last_modified_millis": "1"
},
"fileName": filename,
"size": len(content),
}
b2_cli.run(
[
'upload-file', '--noProgress', '--info=src_last_modified_millis=1', 'my-bucket',
'--cache-control', 'max-age=3600', '--expires', 'Thu, 01 Dec 2050 16:00:00 GMT',
'--content-language', 'en', '--content-disposition', 'attachment', '--content-encoding',
'gzip',
str(local_file1), 'file1.txt'
],
expected_json_in_stdout=expected_json,
Expand Down
Loading