Skip to content

Commit

Permalink
add --lifecycleRule option with improved validation
Browse files Browse the repository at this point in the history
  • Loading branch information
mjurbanski-reef committed Jul 25, 2023
1 parent 13c3fba commit 1fff1cd
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
* Declare official support of Python 3.12
* Add `--lifecycleRule` to `create-bucket` and `update-bucket` and deprecate `--lifecycleRules` argument
* Add extra dependencies for better UX, installable with `pip install b2[full]`

### Infrastructure
* Remove unsupported PyPy 3.7 from tests matrix and add PyPy 3.10 instead
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.template
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ LABEL build-date-iso8601="${build_date}"
WORKDIR ${homedir}

COPY ${tar_path}/${tar_name} .
RUN ["pip", "install", "${tar_name}"]
RUN ["pip", "install", "${tar_name}[full]"]
ENV PATH=${homedir}/.local/bin:$$PATH


Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
include requirements.txt
include requirements-full.txt
include requirements-license.txt
include LICENSE
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ Stand-alone binaries are available for Linux and Windows; this is the most strai
You can also install it in your Python environment ([virtualenv](https://pypi.org/project/virtualenv/) is recommended) from PyPI with:

```bash
pip install b2
pip install b2[full]
```

The extra dependencies improve debugging experience and, potentially, performance of `b2` CLI, but are not strictly required.
You can install the `b2` without them:

```bash
pip install b2
```

### Installing from source
Expand Down
57 changes: 57 additions & 0 deletions b2/_cli/obj_loads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
######################################################################
#
# File: b2/_cli/obj_loads.py
#
# Copyright 2023 Backblaze Inc. All Rights Reserved.
#
# License https://www.backblaze.com/using_b2_code.html
#
######################################################################
from __future__ import annotations

import argparse
import io
import json
from typing import TypeVar

from b2sdk.v2 import get_b2sdk_doc_urls

try:
import pydantic
from pydantic import TypeAdapter, ValidationError
except ImportError:
pydantic = None


def convert_error_to_human_readable(validation_exc: ValidationError) -> str:
buf = io.StringIO()
for error in validation_exc.errors():
loc = '.'.join(str(loc) for loc in error['loc'])
buf.write(f' In field {loc!r} input was `{error["input"]!r}`, error: {error["msg"]}\n')
return buf.getvalue()


def describe_type(type_) -> str:
urls = get_b2sdk_doc_urls(type_)
if urls:
url_links = ', '.join(f'{name} <{url}>' for name, url in urls.items())
return f'{type_.__name__} ({url_links})'
return type_.__name__


T = TypeVar('T')


def validated_loads(data: str, expected_type: type[T] | None = None) -> T:
if expected_type is not None and pydantic is not None:
ta = TypeAdapter(expected_type)
try:
val = ta.validate_json(data)
except ValidationError as e:
errors = convert_error_to_human_readable(e)
raise argparse.ArgumentTypeError(
f'Invalid value inputted, expected {describe_type(expected_type)}, got {data!r}, more detail below:\n{errors}'
) from e
else:
val = json.loads(data)
return val
52 changes: 40 additions & 12 deletions b2/console_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import b2sdk
import requests
import rst2ansi
from b2sdk.raw_api import LifecycleRule
from b2sdk.v2 import (
ALL_CAPABILITIES,
B2_ACCOUNT_INFO_DEFAULT_FILE,
Expand Down Expand Up @@ -116,6 +117,7 @@
B2_USER_AGENT_APPEND_ENV_VAR,
CREATE_BUCKET_TYPES,
)
from b2._cli.obj_loads import validated_loads
from b2._cli.shell import detect_shell
from b2.arg_parser import (
ArgumentParser,
Expand Down Expand Up @@ -514,6 +516,32 @@ def _get_upload_mode_from_args(args):
return UploadMode.FULL


class LifecycleRulesMixin(Described):
"""
Use `--lifecycleRule` to set lifecycle rule for the bucket.
Multiple rules can be specified by repeating the option.
`--lifecycleRules` option is deprecated and cannot be used together with --lifecycleRule.
"""

@classmethod
def _setup_parser(cls, parser):
lifecycle_group = parser.add_mutually_exclusive_group()
lifecycle_group.add_argument(
'--lifecycleRule',
action='append',
default=[],
type=functools.partial(validated_loads, expected_type=LifecycleRule),
dest='lifecycleRules',
help="Lifecycle rule in JSON format. Can be supplied multiple times.",
)
lifecycle_group.add_argument(
'--lifecycleRules',
type=functools.partial(validated_loads, expected_type=List[LifecycleRule]),
help="(deprecated; use --lifecycleRule instead) List of lifecycle rules in JSON format.",
)


class Command(Described):
# Set to True for commands that receive sensitive information in arguments
FORBID_LOGGING_ARGUMENTS = False
Expand Down Expand Up @@ -1023,14 +1051,15 @@ def _determine_source_metadata(


@B2.register_subcommand
class CreateBucket(DefaultSseMixin, Command):
class CreateBucket(DefaultSseMixin, LifecycleRulesMixin, Command):
"""
Creates a new bucket. Prints the ID of the bucket created.
Optionally stores bucket info, CORS rules and lifecycle rules with the bucket.
These can be given as JSON on the command line.
{DEFAULTSSEMIXIN}
{LIFECYCLERULESMIXIN}
Requires capability:
Expand All @@ -1042,16 +1071,15 @@ class CreateBucket(DefaultSseMixin, Command):

@classmethod
def _setup_parser(cls, parser):
parser.add_argument('--bucketInfo', type=json.loads)
parser.add_argument('--corsRules', type=json.loads)
parser.add_argument('--lifecycleRules', type=json.loads)
parser.add_argument('--bucketInfo', type=validated_loads)
parser.add_argument('--corsRules', type=validated_loads)
parser.add_argument(
'--fileLockEnabled',
action='store_true',
help=
"If given, the bucket will have the file lock mechanism enabled. This parameter cannot be changed after bucket creation."
)
parser.add_argument('--replication', type=json.loads)
parser.add_argument('--replication', type=validated_loads)
parser.add_argument('bucketName')
parser.add_argument('bucketType', choices=CREATE_BUCKET_TYPES)

Expand Down Expand Up @@ -2496,7 +2524,7 @@ def get_synchronizer_from_args(


@B2.register_subcommand
class UpdateBucket(DefaultSseMixin, Command):
class UpdateBucket(DefaultSseMixin, LifecycleRulesMixin, Command):
"""
Updates the ``bucketType`` of an existing bucket. Prints the ID
of the bucket updated.
Expand All @@ -2505,6 +2533,7 @@ class UpdateBucket(DefaultSseMixin, Command):
These can be given as JSON on the command line.
{DEFAULTSSEMIXIN}
{LIFECYCLERULESMIXIN}
To set a default retention for files in the bucket ``--defaultRetentionMode`` and
``--defaultRetentionPeriod`` have to be specified. The latter one is of the form "X days|years".
Expand Down Expand Up @@ -2538,9 +2567,8 @@ class UpdateBucket(DefaultSseMixin, Command):

@classmethod
def _setup_parser(cls, parser):
parser.add_argument('--bucketInfo', type=json.loads)
parser.add_argument('--corsRules', type=json.loads)
parser.add_argument('--lifecycleRules', type=json.loads)
parser.add_argument('--bucketInfo', type=validated_loads)
parser.add_argument('--corsRules', type=validated_loads)
parser.add_argument(
'--defaultRetentionMode',
choices=(
Expand All @@ -2555,7 +2583,7 @@ def _setup_parser(cls, parser):
type=parse_default_retention_period,
metavar='period',
)
parser.add_argument('--replication', type=json.loads)
parser.add_argument('--replication', type=validated_loads)
parser.add_argument(
'--fileLockEnabled',
action='store_true',
Expand Down Expand Up @@ -3291,7 +3319,7 @@ def _get_licenses_dicts(cls) -> List[Dict]:
]
)
licenses_output = piplicenses.create_output_string(args)
licenses = json.loads(licenses_output)
licenses = validated_loads(licenses_output)
return licenses

def _fetch_license_from_url(self, url: str) -> str:
Expand Down Expand Up @@ -3376,7 +3404,7 @@ def __init__(self, b2_api: Optional[B2Api], stdout, stderr):
def run_command(self, argv):
signal.signal(signal.SIGINT, keyboard_interrupt_handler)
parser = B2.get_parser()
argcomplete.autocomplete(parser)
argcomplete.autocomplete(parser, default_completer=None)
args = parser.parse_args(argv[1:])
self._setup_logging(args, argv)

Expand Down
1 change: 1 addition & 0 deletions requirements-full.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pydantic>=2.0.1
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def read_requirements(extra=None):
# for example:
# $ pip install -e .[dev,test]
extras_require={
'full': read_requirements('full'),
'doc': read_requirements('doc'),
'license': read_requirements('license'),
},
Expand Down
6 changes: 3 additions & 3 deletions test/integration/test_b2_command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,13 @@ def test_basic(b2_tool, bucket_name):


def test_bucket(b2_tool, bucket_name):
rules = """[{
rule = """{
"daysFromHidingToDeleting": 1,
"daysFromUploadingToHiding": null,
"fileNamePrefix": ""
}]"""
}"""
output = b2_tool.should_succeed_json(
['update-bucket', '--lifecycleRules', rules, bucket_name, 'allPublic', *get_bucketinfo()],
['update-bucket', '--lifecycleRule', rule, bucket_name, 'allPublic', *get_bucketinfo()],
)
assert output["lifecycleRules"] == [
{
Expand Down
57 changes: 56 additions & 1 deletion test/unit/test_console_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ def _run_command(
expected_stderr = self._normalize_expected_output(expected_stderr, format_vars)
stdout, stderr = self._get_stdouterr()
console_tool = ConsoleTool(self.b2_api, stdout, stderr)
actual_status = console_tool.run_command(['b2'] + argv)
try:
actual_status = console_tool.run_command(['b2'] + argv)
except SystemExit as e:
actual_status = e.code

actual_stdout = self._trim_trailing_spaces(stdout.getvalue())
actual_stderr = self._trim_trailing_spaces(stderr.getvalue())
Expand Down Expand Up @@ -448,6 +451,58 @@ def test_authorize_key_without_list_buckets(self):
1,
)

def test_create_bucket__with_lifecycle_rule(self):
self._authorize_account()

rule = json.dumps(
{
"daysFromHidingToDeleting": 1,
"daysFromUploadingToHiding": None,
"fileNamePrefix": ""
}
)

self._run_command(
['create-bucket', 'my-bucket', 'allPrivate', '--lifecycleRule', rule], 'bucket_0\n', '',
0
)

def test_create_bucket__with_lifecycle_rules(self):
self._authorize_account()

rules = json.dumps(
[
{
"daysFromHidingToDeleting": 1,
"daysFromUploadingToHiding": None,
"fileNamePrefix": ""
}
]
)

self._run_command(
['create-bucket', 'my-bucket', 'allPrivate', '--lifecycleRules', rules], 'bucket_0\n',
'', 0
)

def test_create_bucket__mutually_exclusive_lifecycle_rules_options(self):
self._authorize_account()

rule = json.dumps(
{
"daysFromHidingToDeleting": 1,
"daysFromUploadingToHiding": None,
"fileNamePrefix": ""
}
)

self._run_command(
[
'create-bucket', 'my-bucket', 'allPrivate', '--lifecycleRule', rule,
'--lifecycleRules', f"[{rule}]"
], '', '', 2
)

def test_create_bucket_key_and_authorize_with_it(self):
# Start with authorizing with the master key
self._authorize_account()
Expand Down

0 comments on commit 1fff1cd

Please sign in to comment.