summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.zuul.yaml18
-rw-r--r--bindep.txt2
-rw-r--r--cinderclient/api_versions.py2
-rw-r--r--cinderclient/client.py8
-rw-r--r--cinderclient/shell_utils.py130
-rw-r--r--cinderclient/tests/unit/test_shell.py35
-rw-r--r--cinderclient/tests/unit/test_utils.py18
-rw-r--r--cinderclient/tests/unit/v3/fakes.py2
-rw-r--r--cinderclient/tests/unit/v3/fakes_base.py2
-rw-r--r--cinderclient/tests/unit/v3/test_shell.py103
-rw-r--r--cinderclient/tests/unit/v3/test_volumes.py12
-rw-r--r--cinderclient/utils.py97
-rw-r--r--cinderclient/v3/contrib/list_extensions.py4
-rw-r--r--cinderclient/v3/shell.py121
-rw-r--r--cinderclient/v3/shell_base.py98
-rw-r--r--cinderclient/v3/volume_snapshots.py14
-rw-r--r--cinderclient/v3/volumes.py22
-rw-r--r--releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml8
-rw-r--r--releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml6
-rw-r--r--releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml10
-rw-r--r--releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml5
-rw-r--r--releasenotes/source/index.rst2
-rw-r--r--releasenotes/source/yoga.rst6
-rw-r--r--releasenotes/source/zed.rst6
-rw-r--r--requirements.txt1
-rw-r--r--setup.cfg5
-rw-r--r--test-requirements.txt1
-rw-r--r--tox.ini8
28 files changed, 483 insertions, 263 deletions
diff --git a/.zuul.yaml b/.zuul.yaml
index c9e5b50..64d85ea 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -21,13 +21,13 @@
- ^cinderclient/tests/unit/.*$
- job:
- name: python-cinderclient-functional-py36
+ name: python-cinderclient-functional-py38
parent: python-cinderclient-functional-base
- # need to specify a platform that has python 3.6 available
- nodeset: devstack-single-node-centos-8-stream
+ # need to specify a platform that has python 3.8 available
+ nodeset: openstack-single-node-focal
vars:
- python_version: 3.6
- tox_envlist: functional-py36
+ python_version: 3.8
+ tox_envlist: functional-py38
- job:
name: python-cinderclient-functional-py39
@@ -38,20 +38,22 @@
tox_envlist: functional-py39
- project:
+ vars:
+ ensure_tox_version: '<4'
templates:
- check-requirements
- lib-forward-testing-python3
- openstack-cover-jobs
- - openstack-python3-yoga-jobs
+ - openstack-python3-antelope-jobs
- publish-openstack-docs-pti
- release-notes-jobs-python3
check:
jobs:
- - python-cinderclient-functional-py36
+ - python-cinderclient-functional-py38
- python-cinderclient-functional-py39
- openstack-tox-pylint:
voting: false
gate:
jobs:
- - python-cinderclient-functional-py36
+ - python-cinderclient-functional-py38
- python-cinderclient-functional-py39
diff --git a/bindep.txt b/bindep.txt
index 2dbd41a..52426cf 100644
--- a/bindep.txt
+++ b/bindep.txt
@@ -6,8 +6,6 @@ libffi-dev [platform:dpkg]
libffi-devel [platform:rpm]
libssl-dev [platform:ubuntu-xenial]
locales [platform:debian]
-python-dev [platform:dpkg]
-python-devel [platform:rpm !platform:centos-8]
python3-all-dev [platform:ubuntu !platform:ubuntu-precise]
python3-dev [platform:dpkg]
python3-devel [platform:rpm]
diff --git a/cinderclient/api_versions.py b/cinderclient/api_versions.py
index 48e00b7..5f6ad65 100644
--- a/cinderclient/api_versions.py
+++ b/cinderclient/api_versions.py
@@ -26,7 +26,7 @@ LOG = logging.getLogger(__name__)
# key is unsupported version, value is appropriate supported alternative
REPLACEMENT_VERSIONS = {"1": "3", "2": "3"}
-MAX_VERSION = "3.66"
+MAX_VERSION = "3.70"
MIN_VERSION = "3.0"
_SUBSTITUTIONS = {}
diff --git a/cinderclient/client.py b/cinderclient/client.py
index 6beb381..c99a2e7 100644
--- a/cinderclient/client.py
+++ b/cinderclient/client.py
@@ -20,6 +20,7 @@ import glob
import hashlib
import importlib.util
import itertools
+import json
import logging
import os
import pkgutil
@@ -47,11 +48,6 @@ except ImportError:
from time import sleep
try:
- import json
-except ImportError:
- import simplejson as json
-
-try:
osprofiler_web = importutils.try_import("osprofiler.web")
except Exception:
pass
@@ -797,6 +793,8 @@ def discover_extensions(version):
def _discover_via_python_path():
for (module_loader, name, ispkg) in pkgutil.iter_modules():
if name.endswith('cinderclient_ext'):
+ if not hasattr(module_loader, 'load_module'):
+ module_loader = module_loader.find_module(name)
module = module_loader.load_module(name)
yield name, module
diff --git a/cinderclient/shell_utils.py b/cinderclient/shell_utils.py
index cd8f162..65e8400 100644
--- a/cinderclient/shell_utils.py
+++ b/cinderclient/shell_utils.py
@@ -15,6 +15,8 @@
import sys
import time
+import prettytable
+
from cinderclient import exceptions
from cinderclient import utils
@@ -24,13 +26,109 @@ _quota_resources = ['volumes', 'snapshots', 'gigabytes',
_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit', 'Allocated']
+def _print(pt, order):
+ print(pt.get_string(sortby=order))
+
+
+def _pretty_format_dict(data_dict):
+ formatted_data = []
+
+ for k in sorted(data_dict):
+ formatted_data.append("%s : %s" % (k, data_dict[k]))
+
+ return "\n".join(formatted_data)
+
+
+def print_list(objs, fields, exclude_unavailable=False, formatters=None,
+ sortby_index=0):
+ '''Prints a list of objects.
+
+ @param objs: Objects to print
+ @param fields: Fields on each object to be printed
+ @param exclude_unavailable: Boolean to decide if unavailable fields are
+ removed
+ @param formatters: Custom field formatters
+ @param sortby_index: Results sorted against the key in the fields list at
+ this index; if None then the object order is not
+ altered
+ '''
+ formatters = formatters or {}
+ mixed_case_fields = ['serverId']
+ removed_fields = []
+ rows = []
+
+ for o in objs:
+ row = []
+ for field in fields:
+ if field in removed_fields:
+ continue
+ if field in formatters:
+ row.append(formatters[field](o))
+ else:
+ if field in mixed_case_fields:
+ field_name = field.replace(' ', '_')
+ else:
+ field_name = field.lower().replace(' ', '_')
+ if isinstance(o, dict) and field in o:
+ data = o[field]
+ else:
+ if not hasattr(o, field_name) and exclude_unavailable:
+ removed_fields.append(field)
+ continue
+ else:
+ data = getattr(o, field_name, '')
+ if data is None:
+ data = '-'
+ if isinstance(data, str) and "\r" in data:
+ data = data.replace("\r", " ")
+ row.append(data)
+ rows.append(row)
+
+ for f in removed_fields:
+ fields.remove(f)
+
+ pt = prettytable.PrettyTable((f for f in fields), caching=False)
+ pt.align = 'l'
+ for row in rows:
+ count = 0
+ # Converts unicode values in dictionary to string
+ for part in row:
+ count = count + 1
+ if isinstance(part, dict):
+ row[count - 1] = part
+ pt.add_row(row)
+
+ if sortby_index is None:
+ order_by = None
+ else:
+ order_by = fields[sortby_index]
+ _print(pt, order_by)
+
+
+def print_dict(d, property="Property", formatters=None):
+ pt = prettytable.PrettyTable([property, 'Value'], caching=False)
+ pt.align = 'l'
+ formatters = formatters or {}
+
+ for r in d.items():
+ r = list(r)
+
+ if r[0] in formatters:
+ if isinstance(r[1], dict):
+ r[1] = _pretty_format_dict(r[1])
+ if isinstance(r[1], str) and "\r" in r[1]:
+ r[1] = r[1].replace("\r", " ")
+ pt.add_row(r)
+ _print(pt, property)
+
+
def print_volume_image(image_resp_tuple):
# image_resp_tuple = tuple (response, body)
image = image_resp_tuple[1]
vt = image['os-volume_upload_image'].get('volume_type')
if vt is not None:
image['os-volume_upload_image']['volume_type'] = vt.get('name')
- utils.print_dict(image['os-volume_upload_image'])
+ print_dict(image['os-volume_upload_image'])
def poll_for_status(poll_fn, obj_id, action, final_ok_states,
@@ -120,7 +218,7 @@ def find_message(cs, message):
def print_volume_snapshot(snapshot):
- utils.print_dict(snapshot._info)
+ print_dict(snapshot._info)
def translate_keys(collection, convert):
@@ -188,16 +286,16 @@ def extract_metadata(args, type='user_metadata'):
def print_volume_type_list(vtypes):
- utils.print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public'])
+ print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public'])
def print_group_type_list(gtypes):
- utils.print_list(gtypes, ['ID', 'Name', 'Description'])
+ print_list(gtypes, ['ID', 'Name', 'Description'])
def print_resource_filter_list(filters):
formatter = {'Filters': lambda resource: ', '.join(resource.filters)}
- utils.print_list(filters, ['Resource', 'Filters'], formatters=formatter)
+ print_list(filters, ['Resource', 'Filters'], formatters=formatter)
def quota_show(quotas):
@@ -211,7 +309,7 @@ def quota_show(quotas):
if not good_name:
continue
quota_dict[resource] = getattr(quotas, resource, None)
- utils.print_dict(quota_dict)
+ print_dict(quota_dict)
def quota_usage_show(quotas):
@@ -228,7 +326,7 @@ def quota_usage_show(quotas):
quota_info['Type'] = resource
quota_info = dict((k.capitalize(), v) for k, v in quota_info.items())
quota_list.append(quota_info)
- utils.print_list(quota_list, _quota_infos)
+ print_list(quota_list, _quota_infos)
def quota_update(manager, identifier, args):
@@ -266,26 +364,26 @@ def print_volume_encryption_type_list(encryption_types):
:param encryption_types: a list of :class: VolumeEncryptionType instances
"""
- utils.print_list(encryption_types, ['Volume Type ID', 'Provider',
- 'Cipher', 'Key Size',
- 'Control Location'])
+ print_list(encryption_types, ['Volume Type ID', 'Provider',
+ 'Cipher', 'Key Size',
+ 'Control Location'])
def print_qos_specs(qos_specs):
# formatters defines field to be converted from unicode to string
- utils.print_dict(qos_specs._info, formatters=['specs'])
+ print_dict(qos_specs._info, formatters=['specs'])
def print_qos_specs_list(q_specs):
- utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs'])
+ print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs'])
def print_qos_specs_and_associations_list(q_specs):
- utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs'])
+ print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs'])
def print_associations_list(associations):
- utils.print_list(associations, ['Association_Type', 'Name', 'ID'])
+ print_list(associations, ['Association_Type', 'Name', 'ID'])
def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states,
@@ -305,7 +403,7 @@ def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states,
if status in final_ok_states:
break
elif status == "error":
- utils.print_dict(info)
+ print_dict(info)
if global_request_id:
search_opts = {
'request_id': global_request_id
@@ -317,5 +415,5 @@ def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states,
fault_msg = "Unknown error. Operation failed."
raise exceptions.ResourceInErrorState(obj, fault_msg)
elif time_elapsed == timeout_period:
- utils.print_dict(info)
+ print_dict(info)
raise exceptions.TimeoutException(obj, action)
diff --git a/cinderclient/tests/unit/test_shell.py b/cinderclient/tests/unit/test_shell.py
index 682d509..c5d64af 100644
--- a/cinderclient/tests/unit/test_shell.py
+++ b/cinderclient/tests/unit/test_shell.py
@@ -120,9 +120,9 @@ class ShellTest(utils.TestCase):
# Some expected help output, including microversioned commands
required = [
r'.*?^usage: ',
- r'.*?(?m)^\s+create\s+Creates a volume.',
- r'.*?(?m)^\s+summary\s+Get volumes summary.',
- r'.*?(?m)^Run "cinder help SUBCOMMAND" for help on a subcommand.',
+ r'.*?^\s+create\s+Creates a volume.',
+ r'.*?^\s+summary\s+Get volumes summary.',
+ r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.',
]
help_text = self.shell('help')
for r in required:
@@ -132,7 +132,7 @@ class ShellTest(utils.TestCase):
def test_help_on_subcommand(self):
required = [
r'.*?^usage: cinder list',
- r'.*?(?m)^Lists all volumes.',
+ r'.*?^Lists all volumes.',
]
help_text = self.shell('help list')
for r in required:
@@ -142,7 +142,7 @@ class ShellTest(utils.TestCase):
def test_help_on_subcommand_mv(self):
required = [
r'.*?^usage: cinder summary',
- r'.*?(?m)^Get volumes summary.',
+ r'.*?^Get volumes summary.',
]
help_text = self.shell('help summary')
for r in required:
@@ -152,9 +152,9 @@ class ShellTest(utils.TestCase):
def test_help_arg_no_subcommand(self):
required = [
r'.*?^usage: ',
- r'.*?(?m)^\s+create\s+Creates a volume.',
- r'.*?(?m)^\s+summary\s+Get volumes summary.',
- r'.*?(?m)^Run "cinder help SUBCOMMAND" for help on a subcommand.',
+ r'.*?^\s+create\s+Creates a volume.',
+ r'.*?^\s+summary\s+Get volumes summary.',
+ r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.',
]
help_text = self.shell('--os-volume-api-version 3.40')
for r in required:
@@ -376,7 +376,7 @@ class TestLoadVersionedActions(utils.TestCase):
self.mock_completion()
- def test_load_versioned_actions(self):
+ def test_load_versioned_actions_v3_0(self):
parser = cinderclient.shell.CinderClientArgumentParser()
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = cinderclient.shell.OpenStackCinderShell()
@@ -388,6 +388,10 @@ class TestLoadVersionedActions(utils.TestCase):
"fake_action 3.0 to 3.1",
shell.subcommands['fake-action'].get_default('func')())
+ def test_load_versioned_actions_v3_2(self):
+ parser = cinderclient.shell.CinderClientArgumentParser()
+ subparsers = parser.add_subparsers(metavar='<subcommand>')
+ shell = cinderclient.shell.OpenStackCinderShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("3.2"), False, [])
@@ -521,7 +525,7 @@ class TestLoadVersionedActions(utils.TestCase):
@mock.patch.object(cinderclient.shell.CinderClientArgumentParser,
'add_argument')
- def test_load_actions_with_versioned_args(self, mock_add_arg):
+ def test_load_actions_with_versioned_args_v36(self, mock_add_arg):
parser = cinderclient.shell.CinderClientArgumentParser(add_help=False)
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = cinderclient.shell.OpenStackCinderShell()
@@ -533,8 +537,13 @@ class TestLoadVersionedActions(utils.TestCase):
self.assertNotIn(mock.call('--foo', help="second foo"),
mock_add_arg.call_args_list)
- mock_add_arg.reset_mock()
-
+ @mock.patch.object(cinderclient.shell.CinderClientArgumentParser,
+ 'add_argument')
+ def test_load_actions_with_versioned_args_v39(self, mock_add_arg):
+ parser = cinderclient.shell.CinderClientArgumentParser(add_help=False)
+ subparsers = parser.add_subparsers(metavar='<subcommand>')
+ shell = cinderclient.shell.OpenStackCinderShell()
+ shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("3.9"), False, [])
self.assertNotIn(mock.call('--foo', help="first foo"),
@@ -545,7 +554,7 @@ class TestLoadVersionedActions(utils.TestCase):
class ShellUtilsTest(utils.TestCase):
- @mock.patch.object(cinderclient.utils, 'print_dict')
+ @mock.patch.object(cinderclient.shell_utils, 'print_dict')
def test_print_volume_image(self, mock_print_dict):
response = {'os-volume_upload_image': {'name': 'myimg1'}}
image_resp_tuple = (202, response)
diff --git a/cinderclient/tests/unit/test_utils.py b/cinderclient/tests/unit/test_utils.py
index cce4498..69b0d04 100644
--- a/cinderclient/tests/unit/test_utils.py
+++ b/cinderclient/tests/unit/test_utils.py
@@ -220,7 +220,7 @@ class PrintListTestCase(test_utils.TestCase):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=3, b=4), Row(a=1, b=2)]
with CaptureStdout() as cso:
- utils.print_list(to_print, ['a', 'b'])
+ shell_utils.print_list(to_print, ['a', 'b'])
# Output should be sorted by the first key (a)
self.assertEqual("""\
+---+---+
@@ -235,7 +235,7 @@ class PrintListTestCase(test_utils.TestCase):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=3, b=None), Row(a=1, b=2)]
with CaptureStdout() as cso:
- utils.print_list(to_print, ['a', 'b'])
+ shell_utils.print_list(to_print, ['a', 'b'])
# Output should be sorted by the first key (a)
self.assertEqual("""\
+---+---+
@@ -250,7 +250,7 @@ class PrintListTestCase(test_utils.TestCase):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=4, b=3), Row(a=2, b=1)]
with CaptureStdout() as cso:
- utils.print_list(to_print, ['a', 'b'], sortby_index=1)
+ shell_utils.print_list(to_print, ['a', 'b'], sortby_index=1)
# Output should be sorted by the second key (b)
self.assertEqual("""\
+---+---+
@@ -265,7 +265,7 @@ class PrintListTestCase(test_utils.TestCase):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=3, b=4), Row(a=1, b=2)]
with CaptureStdout() as cso:
- utils.print_list(to_print, ['a', 'b'], sortby_index=None)
+ shell_utils.print_list(to_print, ['a', 'b'], sortby_index=None)
# Output should be in the order given
self.assertEqual("""\
+---+---+
@@ -283,7 +283,7 @@ class PrintListTestCase(test_utils.TestCase):
for row in [Row(a=1, b=2), Row(a=3, b=4)]:
yield row
with CaptureStdout() as cso:
- utils.print_list(gen_rows(), ['a', 'b'])
+ shell_utils.print_list(gen_rows(), ['a', 'b'])
self.assertEqual("""\
+---+---+
| a | b |
@@ -297,7 +297,7 @@ class PrintListTestCase(test_utils.TestCase):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=3, b='a\r'), Row(a=1, b='c\rd')]
with CaptureStdout() as cso:
- utils.print_list(to_print, ['a', 'b'])
+ shell_utils.print_list(to_print, ['a', 'b'])
# Output should be sorted by the first key (a)
self.assertEqual("""\
+---+-----+
@@ -314,13 +314,13 @@ class PrintDictTestCase(test_utils.TestCase):
def test__pretty_format_dict(self):
content = {'key1': 'value1', 'key2': 'value2'}
expected = "key1 : value1\nkey2 : value2"
- result = utils._pretty_format_dict(content)
+ result = shell_utils._pretty_format_dict(content)
self.assertEqual(expected, result)
def test_print_dict_with_return(self):
d = {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'test\rcarriage\n\rreturn'}
with CaptureStdout() as cso:
- utils.print_dict(d)
+ shell_utils.print_dict(d)
self.assertEqual("""\
+----------+---------------+
| Property | Value |
@@ -337,7 +337,7 @@ class PrintDictTestCase(test_utils.TestCase):
content = {'a': 'A', 'b': 'B', 'f_key':
{'key1': 'value1', 'key2': 'value2'}}
with CaptureStdout() as cso:
- utils.print_dict(content, formatters='f_key')
+ shell_utils.print_dict(content, formatters='f_key')
self.assertEqual("""\
+----------+---------------+
| Property | Value |
diff --git a/cinderclient/tests/unit/v3/fakes.py b/cinderclient/tests/unit/v3/fakes.py
index d39dd4b..f21c2ab 100644
--- a/cinderclient/tests/unit/v3/fakes.py
+++ b/cinderclient/tests/unit/v3/fakes.py
@@ -453,6 +453,8 @@ class FakeHTTPClient(fakes_base.FakeHTTPClient):
'failover_replication', 'list_replication_targets',
'reset_status'):
assert action in body
+ elif action == 'os-reimage':
+ assert 'image_id' in body[action]
else:
raise AssertionError("Unexpected action: %s" % action)
return (resp, {}, {})
diff --git a/cinderclient/tests/unit/v3/fakes_base.py b/cinderclient/tests/unit/v3/fakes_base.py
index ec75ff0..b5f2728 100644
--- a/cinderclient/tests/unit/v3/fakes_base.py
+++ b/cinderclient/tests/unit/v3/fakes_base.py
@@ -550,6 +550,8 @@ class FakeHTTPClient(base_client.HTTPClient):
_body = body
elif action == 'revert':
assert 'snapshot_id' in body[action]
+ elif action == 'os-reimage':
+ assert 'image_id' in body[action]
else:
raise AssertionError("Unexpected action: %s" % action)
return (resp, {}, _body)
diff --git a/cinderclient/tests/unit/v3/test_shell.py b/cinderclient/tests/unit/v3/test_shell.py
index 8f00525..58caddc 100644
--- a/cinderclient/tests/unit/v3/test_shell.py
+++ b/cinderclient/tests/unit/v3/test_shell.py
@@ -99,6 +99,14 @@ class ShellTest(utils.TestCase):
api_versions.APIVersion('3.99'))):
self.shell.main(cmd.split())
+ def run_command_with_server_api_max(self, api_max, cmd):
+ # version negotiation will use the supplied api_max, which must be
+ # a string value, as the server's max supported version
+ with mock.patch('cinderclient.api_versions._get_server_version_range',
+ return_value=(api_versions.APIVersion('3.0'),
+ api_versions.APIVersion(api_max))):
+ self.shell.main(cmd.split())
+
def assert_called(self, method, url, body=None,
partial_body=None, **kwargs):
return self.shell.cs.assert_called(method, url, body,
@@ -344,7 +352,7 @@ class ShellTest(utils.TestCase):
self.run_command(command)
self.assert_called('GET', '/volumes/detail?group_id=fake_id')
- @mock.patch("cinderclient.utils.print_list")
+ @mock.patch("cinderclient.shell_utils.print_list")
def test_list_duplicate_fields(self, mock_print):
self.run_command('list --field Status,id,Size,status')
self.assert_called('GET', '/volumes/detail')
@@ -520,7 +528,7 @@ class ShellTest(utils.TestCase):
self.run_command(command)
self.assert_called('GET', '/attachments%s' % expected)
- @mock.patch('cinderclient.utils.print_list')
+ @mock.patch('cinderclient.shell_utils.print_list')
@mock.patch.object(cinderclient.v3.attachments.VolumeAttachmentManager,
'list')
def test_attachment_list_setattr(self, mock_list, mock_print):
@@ -918,6 +926,41 @@ class ShellTest(utils.TestCase):
f'snapshot-create --force {force_value} 123456')
self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65)
+ @mock.patch('cinderclient.shell.CinderClientArgumentParser.exit')
+ def test_snapshot_create_pre_3_66_with_naked_force(
+ self, mock_exit):
+ mock_exit.side_effect = Exception("mock exit")
+ try:
+ self.run_command('--os-volume-api-version 3.65 '
+ 'snapshot-create --force 123456')
+ except Exception as e:
+ # ignore the exception (it's raised to simulate an exit),
+ # but make sure it's the exception we expect
+ self.assertEqual('mock exit', str(e))
+
+ exit_code = mock_exit.call_args.args[0]
+ self.assertEqual(2, exit_code)
+
+ @mock.patch('cinderclient.utils.find_resource')
+ def test_snapshot_create_pre_3_66_with_force_None(
+ self, mock_find_vol):
+ """We will let the API detect the problematic value."""
+ mock_find_vol.return_value = volumes.Volume(
+ self, {'id': '123456'}, loaded=True)
+ snap_body_3_65 = {
+ 'snapshot': {
+ 'volume_id': '123456',
+ # note: this is a string, NOT None!
+ 'force': 'None',
+ 'name': None,
+ 'description': None,
+ 'metadata': {}
+ }
+ }
+ self.run_command('--os-volume-api-version 3.65 '
+ 'snapshot-create --force None 123456')
+ self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65)
+
SNAP_BODY_3_66 = {
'snapshot': {
'volume_id': '123456',
@@ -953,6 +996,17 @@ class ShellTest(utils.TestCase):
self.assertIn('not allowed after microversion 3.65', str(uae))
@mock.patch('cinderclient.utils.find_resource')
+ def test_snapshot_create_3_66_with_force_None(
+ self, mock_find_vol):
+ mock_find_vol.return_value = volumes.Volume(
+ self, {'id': '123456'}, loaded=True)
+ uae = self.assertRaises(exceptions.UnsupportedAttribute,
+ self.run_command,
+ '--os-volume-api-version 3.66 '
+ 'snapshot-create --force None 123456')
+ self.assertIn('not allowed after microversion 3.65', str(uae))
+
+ @mock.patch('cinderclient.utils.find_resource')
def test_snapshot_create_3_66(self, mock_find_vol):
mock_find_vol.return_value = volumes.Volume(
self, {'id': '123456'}, loaded=True)
@@ -961,6 +1015,28 @@ class ShellTest(utils.TestCase):
self.assert_called_anytime('POST', '/snapshots',
body=self.SNAP_BODY_3_66)
+ @mock.patch('cinderclient.utils.find_resource')
+ def test_snapshot_create_3_66_not_supported(self, mock_find_vol):
+ mock_find_vol.return_value = volumes.Volume(
+ self, {'id': '123456'}, loaded=True)
+ self.run_command_with_server_api_max(
+ '3.64',
+ '--os-volume-api-version 3.66 snapshot-create 123456')
+ # call should be made, but will use the pre-3.66 request body
+ # because the client in use has been downgraded to 3.64
+ pre_3_66_request_body = {
+ 'snapshot': {
+ 'volume_id': '123456',
+ # default value is False
+ 'force': False,
+ 'name': None,
+ 'description': None,
+ 'metadata': {}
+ }
+ }
+ self.assert_called_anytime('POST', '/snapshots',
+ body=pre_3_66_request_body)
+
def test_snapshot_manageable_list(self):
self.run_command('--os-volume-api-version 3.8 '
'snapshot-manageable-list fakehost')
@@ -1209,7 +1285,7 @@ class ShellTest(utils.TestCase):
get_levels_mock.assert_not_called()
@mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels')
- @mock.patch('cinderclient.utils.print_list')
+ @mock.patch('cinderclient.shell_utils.print_list')
def test_service_get_log_no_params(self, print_mock, get_levels_mock):
self.run_command('--os-volume-api-version 3.32 service-get-log')
get_levels_mock.assert_called_once_with('', '', '')
@@ -1220,7 +1296,7 @@ class ShellTest(utils.TestCase):
@ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler',
'cinder-backup')
@mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels')
- @mock.patch('cinderclient.utils.print_list')
+ @mock.patch('cinderclient.shell_utils.print_list')
def test_service_get_log(self, binary, print_mock, get_levels_mock):
server = 'host1'
prefix = 'sqlalchemy'
@@ -1378,7 +1454,7 @@ class ShellTest(utils.TestCase):
'availability_zone': 'AZ2'}}
self.assert_called('POST', '/backups', body=expected)
- @mock.patch("cinderclient.utils.print_list")
+ @mock.patch("cinderclient.shell_utils.print_list")
def test_snapshot_list(self, mock_print_list):
"""Ensure we always present all existing fields when listing snaps."""
self.run_command('--os-volume-api-version 3.65 snapshot-list')
@@ -1847,7 +1923,7 @@ class ShellTest(utils.TestCase):
},
)
@ddt.unpack
- @mock.patch('cinderclient.utils.print_dict')
+ @mock.patch('cinderclient.shell_utils.print_dict')
@mock.patch('cinderclient.tests.unit.v3.fakes_base._stub_restore')
def test_do_backup_restore(self,
mock_stub_restore,
@@ -1895,3 +1971,18 @@ class ShellTest(utils.TestCase):
'volume_id': '1234',
'volume_name': volume_name,
})
+
+ def test_reimage(self):
+ self.run_command('--os-volume-api-version 3.68 reimage 1234 1')
+ expected = {'os-reimage': {'image_id': '1',
+ 'reimage_reserved': False}}
+ self.assert_called('POST', '/volumes/1234/action', body=expected)
+
+ @ddt.data('False', 'True')
+ def test_reimage_reserved(self, reimage_reserved):
+ self.run_command(
+ '--os-volume-api-version 3.68 reimage --reimage-reserved %s 1234 1'
+ % reimage_reserved)
+ expected = {'os-reimage': {'image_id': '1',
+ 'reimage_reserved': reimage_reserved}}
+ self.assert_called('POST', '/volumes/1234/action', body=expected)
diff --git a/cinderclient/tests/unit/v3/test_volumes.py b/cinderclient/tests/unit/v3/test_volumes.py
index c733a09..1c2f8a2 100644
--- a/cinderclient/tests/unit/v3/test_volumes.py
+++ b/cinderclient/tests/unit/v3/test_volumes.py
@@ -201,3 +201,15 @@ class VolumesTest(utils.TestCase):
'force_host_copy': False,
'lock_volume': False}})
self._assert_request_id(vol)
+
+ @ddt.data(False, True)
+ def test_reimage(self, reimage_reserved):
+ cs = fakes.FakeClient(api_versions.APIVersion('3.68'))
+ v = cs.volumes.get('1234')
+ self._assert_request_id(v)
+ vol = cs.volumes.reimage(v, '1', reimage_reserved)
+ cs.assert_called('POST', '/volumes/1234/action',
+ {'os-reimage': {'image_id': '1',
+ 'reimage_reserved':
+ reimage_reserved}})
+ self._assert_request_id(vol)
diff --git a/cinderclient/utils.py b/cinderclient/utils.py
index 99acc03..565c61a 100644
--- a/cinderclient/utils.py
+++ b/cinderclient/utils.py
@@ -18,7 +18,6 @@ import os
from urllib import parse
import uuid
-import prettytable
import stevedore
from cinderclient import exceptions
@@ -106,76 +105,6 @@ def isunauthenticated(f):
return getattr(f, 'unauthenticated', False)
-def _print(pt, order):
- print(pt.get_string(sortby=order))
-
-
-def print_list(objs, fields, exclude_unavailable=False, formatters=None,
- sortby_index=0):
- '''Prints a list of objects.
-
- @param objs: Objects to print
- @param fields: Fields on each object to be printed
- @param exclude_unavailable: Boolean to decide if unavailable fields are
- removed
- @param formatters: Custom field formatters
- @param sortby_index: Results sorted against the key in the fields list at
- this index; if None then the object order is not
- altered
- '''
- formatters = formatters or {}
- mixed_case_fields = ['serverId']
- removed_fields = []
- rows = []
-
- for o in objs:
- row = []
- for field in fields:
- if field in removed_fields:
- continue
- if field in formatters:
- row.append(formatters[field](o))
- else:
- if field in mixed_case_fields:
- field_name = field.replace(' ', '_')
- else:
- field_name = field.lower().replace(' ', '_')
- if isinstance(o, dict) and field in o:
- data = o[field]
- else:
- if not hasattr(o, field_name) and exclude_unavailable:
- removed_fields.append(field)
- continue
- else:
- data = getattr(o, field_name, '')
- if data is None:
- data = '-'
- if isinstance(data, str) and "\r" in data:
- data = data.replace("\r", " ")
- row.append(data)
- rows.append(row)
-
- for f in removed_fields:
- fields.remove(f)
-
- pt = prettytable.PrettyTable((f for f in fields), caching=False)
- pt.align = 'l'
- for row in rows:
- count = 0
- # Converts unicode values in dictionary to string
- for part in row:
- count = count + 1
- if isinstance(part, dict):
- row[count - 1] = part
- pt.add_row(row)
-
- if sortby_index is None:
- order_by = None
- else:
- order_by = fields[sortby_index]
- _print(pt, order_by)
-
-
def build_query_param(params, sort=False):
"""parse list to url query parameters"""
@@ -206,32 +135,6 @@ def build_query_param(params, sort=False):
return query_string
-def _pretty_format_dict(data_dict):
- formatted_data = []
-
- for k in sorted(data_dict):
- formatted_data.append("%s : %s" % (k, data_dict[k]))
-
- return "\n".join(formatted_data)
-
-
-def print_dict(d, property="Property", formatters=None):
- pt = prettytable.PrettyTable([property, 'Value'], caching=False)
- pt.align = 'l'
- formatters = formatters or {}
-
- for r in d.items():
- r = list(r)
-
- if r[0] in formatters:
- if isinstance(r[1], dict):
- r[1] = _pretty_format_dict(r[1])
- if isinstance(r[1], str) and "\r" in r[1]:
- r[1] = r[1].replace("\r", " ")
- pt.add_row(r)
- _print(pt, property)
-
-
def find_resource(manager, name_or_id, **kwargs):
"""Helper for the _find_* methods."""
is_group = kwargs.pop('is_group', False)
diff --git a/cinderclient/v3/contrib/list_extensions.py b/cinderclient/v3/contrib/list_extensions.py
index 937d34b..548cbec 100644
--- a/cinderclient/v3/contrib/list_extensions.py
+++ b/cinderclient/v3/contrib/list_extensions.py
@@ -14,7 +14,7 @@
# under the License.
from cinderclient import base
-from cinderclient import utils
+from cinderclient import shell_utils
class ListExtResource(base.Resource):
@@ -41,4 +41,4 @@ def do_list_extensions(client, _args):
"""Lists all available os-api extensions."""
extensions = client.list_extensions.show_all()
fields = ["Name", "Summary", "Alias", "Updated"]
- utils.print_list(extensions, fields)
+ shell_utils.print_list(extensions, fields)
diff --git a/cinderclient/v3/shell.py b/cinderclient/v3/shell.py
index 27f2c7f..2542530 100644
--- a/cinderclient/v3/shell.py
+++ b/cinderclient/v3/shell.py
@@ -197,7 +197,7 @@ def do_backup_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(backups, columns, sortby_index=sortby_index)
+ shell_utils.print_list(backups, columns, sortby_index=sortby_index)
if show_count:
print("Backup in total: %s" % total_count)
@@ -282,7 +282,7 @@ def do_backup_restore(cs, args):
info.update(restore._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--detail',
@@ -316,7 +316,7 @@ def do_get_pools(cs, args):
backend['name'] = info['name']
if args.detail:
backend.update(info['capabilities'])
- utils.print_dict(backend)
+ shell_utils.print_dict(backend)
AppendFilters.filters = []
@@ -518,7 +518,7 @@ def do_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(volumes, key_list, exclude_unavailable=True,
+ shell_utils.print_list(volumes, key_list, exclude_unavailable=True,
sortby_index=sortby_index)
if show_count:
print("Volume in total: %s" % total_count)
@@ -747,7 +747,7 @@ def do_create(cs, args):
volume = cs.volumes.get(volume.id)
info.update(volume._info)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
with cs.volumes.completion_cache('uuid',
cinderclient.v3.volumes.Volume,
@@ -812,7 +812,7 @@ def do_summary(cs, args):
if cs.api_version >= api_versions.APIVersion("3.36"):
formatters.append('metadata')
- utils.print_dict(info['volume-summary'], formatters=formatters)
+ shell_utils.print_dict(info['volume-summary'], formatters=formatters)
@api_versions.wraps('3.11')
@@ -853,7 +853,7 @@ def do_group_type_show(cs, args):
info.update(gtype._info)
info.pop('links', None)
- utils.print_dict(info, formatters=['group_specs'])
+ shell_utils.print_dict(info, formatters=['group_specs'])
@api_versions.wraps('3.11')
@@ -881,7 +881,7 @@ def do_group_type_update(cs, args):
def do_group_specs_list(cs, args):
"""Lists current group types and specs."""
gtypes = cs.group_types.list()
- utils.print_list(gtypes, ['ID', 'Name', 'group_specs'])
+ shell_utils.print_list(gtypes, ['ID', 'Name', 'group_specs'])
@api_versions.wraps('3.11')
@@ -1170,7 +1170,7 @@ def do_cluster_list(cs, args):
if args.detailed:
columns.extend(('Num Hosts', 'Num Down Hosts', 'Last Heartbeat',
'Disabled Reason', 'Created At', 'Updated at'))
- utils.print_list(clusters, columns)
+ shell_utils.print_list(clusters, columns)
@api_versions.wraps('3.7')
@@ -1181,7 +1181,7 @@ def do_cluster_list(cs, args):
def do_cluster_show(cs, args):
"""Show detailed information on a clustered service."""
cluster = cs.clusters.show(args.name, args.binary)
- utils.print_dict(cluster.to_dict())
+ shell_utils.print_dict(cluster.to_dict())
@api_versions.wraps('3.7')
@@ -1192,7 +1192,7 @@ def do_cluster_show(cs, args):
def do_cluster_enable(cs, args):
"""Enables clustered services."""
cluster = cs.clusters.update(args.name, args.binary, disabled=False)
- utils.print_dict(cluster.to_dict())
+ shell_utils.print_dict(cluster.to_dict())
@api_versions.wraps('3.7')
@@ -1206,7 +1206,7 @@ def do_cluster_disable(cs, args):
"""Disables clustered services."""
cluster = cs.clusters.update(args.name, args.binary, disabled=True,
disabled_reason=args.reason)
- utils.print_dict(cluster.to_dict())
+ shell_utils.print_dict(cluster.to_dict())
@api_versions.wraps('3.24')
@@ -1251,12 +1251,12 @@ def do_work_cleanup(cs, args):
if cleaning:
print('Following services will be cleaned:')
- utils.print_list(cleaning, columns)
+ shell_utils.print_list(cleaning, columns)
if unavailable:
print('There are no alternative nodes to do cleanup for the following '
'services:')
- utils.print_list(unavailable, columns)
+ shell_utils.print_list(unavailable, columns)
if not (cleaning or unavailable):
print('No cleanable services matched cleanup criteria.')
@@ -1337,7 +1337,7 @@ def do_manage(cs, args):
volume = cs.volumes.get(volume.id)
info.update(volume._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps('3.8')
@@ -1394,7 +1394,7 @@ def do_manageable_list(cs, args):
columns = ['reference', 'size', 'safe_to_manage']
if detailed:
columns.extend(['reason_not_safe', 'cinder_id', 'extra_info'])
- utils.print_list(volumes, columns, sortby_index=None)
+ shell_utils.print_list(volumes, columns, sortby_index=None)
@api_versions.wraps('3.13')
@@ -1427,7 +1427,7 @@ def do_group_list(cs, args):
groups = cs.groups.list(search_opts=search_opts)
columns = ['ID', 'Status', 'Name']
- utils.print_list(groups, columns)
+ shell_utils.print_list(groups, columns)
with cs.groups.completion_cache(
'uuid',
@@ -1469,7 +1469,7 @@ def do_group_show(cs, args):
info.update(group._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps('3.13')
@@ -1505,7 +1505,7 @@ def do_group_create(cs, args):
info.update(group._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
with cs.groups.completion_cache('uuid',
cinderclient.v3.groups.Group,
@@ -1556,7 +1556,7 @@ def do_group_create_from_src(cs, args):
args.description)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps('3.13')
@@ -1696,7 +1696,8 @@ def do_group_list_replication_targets(cs, args):
cs, args.group).list_replication_targets()
rep_targets = replication_targets.get('replication_targets')
if rep_targets and len(rep_targets) > 0:
- utils.print_list(rep_targets, [key for key in rep_targets[0].keys()])
+ shell_utils.print_list(rep_targets,
+ [key for key in rep_targets[0].keys()])
@api_versions.wraps('3.14')
@@ -1743,7 +1744,7 @@ def do_group_snapshot_list(cs, args):
group_snapshots = cs.group_snapshots.list(search_opts=search_opts)
columns = ['ID', 'Status', 'Name']
- utils.print_list(group_snapshots, columns)
+ shell_utils.print_list(group_snapshots, columns)
AppendFilters.filters = []
@@ -1758,7 +1759,7 @@ def do_group_snapshot_show(cs, args):
info.update(group_snapshot._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps('3.14')
@@ -1786,7 +1787,7 @@ def do_group_snapshot_create(cs, args):
info.update(group_snapshot._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps('3.14')
@@ -1841,7 +1842,7 @@ def do_service_list(cs, args):
columns.append("Disabled Reason")
if cs.api_version.matches('3.49'):
columns.extend(["Backend State"])
- utils.print_list(result, columns)
+ shell_utils.print_list(result, columns)
@api_versions.wraps('3.8')
@@ -1900,7 +1901,7 @@ def do_snapshot_manageable_list(cs, args):
columns = ['reference', 'size', 'safe_to_manage', 'source_reference']
if detailed:
columns.extend(['reason_not_safe', 'cinder_id', 'extra_info'])
- utils.print_list(snapshots, columns, sortby_index=None)
+ shell_utils.print_list(snapshots, columns, sortby_index=None)
@api_versions.wraps("3.0")
@@ -1908,7 +1909,7 @@ def do_api_version(cs, args):
"""Display the server API version information."""
columns = ['ID', 'Status', 'Version', 'Min_version']
response = cs.services.server_api_version()
- utils.print_list(response, columns)
+ shell_utils.print_list(response, columns)
@api_versions.wraps("3.40")
@@ -2010,7 +2011,7 @@ def do_message_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(messages, columns, sortby_index=sortby_index)
+ shell_utils.print_list(messages, columns, sortby_index=sortby_index)
AppendFilters.filters = []
@@ -2024,7 +2025,7 @@ def do_message_show(cs, args):
message = shell_utils.find_message(cs, args.message)
info.update(message._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@api_versions.wraps("3.3")
@@ -2179,11 +2180,11 @@ def do_snapshot_list(cs, args):
# It's the server's responsibility to return the appropriate fields for the
# requested microversion, we present all known fields and skip those that
# are missing.
- utils.print_list(snapshots,
- ['ID', 'Volume ID', 'Status', 'Name', 'Size',
- 'Consumes Quota', 'User ID'],
- exclude_unavailable=True,
- sortby_index=sortby_index)
+ shell_utils.print_list(snapshots,
+ ['ID', 'Volume ID', 'Status', 'Name', 'Size',
+ 'Consumes Quota', 'User ID'],
+ exclude_unavailable=True,
+ sortby_index=sortby_index)
if show_count:
print("Snapshot in total: %s" % total_count)
@@ -2213,6 +2214,7 @@ def do_snapshot_list(cs, args):
'than forcing it to be available. From microversion 3.66, '
'all snapshots are "forced" and this option is invalid. '
'Default=False.')
+# FIXME: is this second declaration of --force really necessary?
@utils.arg('--force',
metavar='<True>',
nargs='?',
@@ -2253,6 +2255,7 @@ def do_snapshot_create(cs, args):
snapshot_metadata = shell_utils.extract_metadata(args)
volume = utils.find_volume(cs, args.volume)
+
snapshot = cs.volume_snapshots.create(volume.id,
args.force,
args.name,
@@ -2409,7 +2412,7 @@ def do_attachment_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(attachments, columns, sortby_index=sortby_index)
+ shell_utils.print_list(attachments, columns, sortby_index=sortby_index)
AppendFilters.filters = []
@@ -2422,13 +2425,13 @@ def do_attachment_show(cs, args):
attachment = cs.attachments.show(args.attachment)
attachment_dict = attachment.to_dict()
connection_dict = attachment_dict.pop('connection_info', {})
- utils.print_dict(attachment_dict)
+ shell_utils.print_dict(attachment_dict)
# TODO(jdg): Need to add checks here like admin/policy for displaying the
# connection_info, this is still experimental so we'll leave it enabled for
# now
if connection_dict:
- utils.print_dict(connection_dict)
+ shell_utils.print_dict(connection_dict)
@api_versions.wraps('3.27')
@@ -2501,9 +2504,9 @@ def do_attachment_create(cs, args):
mode)
connector_dict = attachment.pop('connection_info', None)
- utils.print_dict(attachment)
+ shell_utils.print_dict(attachment)
if connector_dict:
- utils.print_dict(connector_dict)
+ shell_utils.print_dict(connector_dict)
@api_versions.wraps('3.27')
@@ -2555,9 +2558,9 @@ def do_attachment_update(cs, args):
connector)
attachment_dict = attachment.to_dict()
connector_dict = attachment_dict.pop('connection_info', None)
- utils.print_dict(attachment_dict)
+ shell_utils.print_dict(attachment_dict)
if connector_dict:
- utils.print_dict(connector_dict)
+ shell_utils.print_dict(connector_dict)
@api_versions.wraps('3.27')
@@ -2596,7 +2599,7 @@ def do_version_list(cs, args):
{'v': api_versions.MAX_VERSION})
print("\nServer supported API versions:")
- utils.print_list(result, columns)
+ shell_utils.print_list(result, columns)
@api_versions.wraps('3.32')
@@ -2639,7 +2642,7 @@ def do_service_get_log(cs, args):
log_levels = cs.services.get_log_levels(args.binary, args.server,
args.prefix)
columns = ('Binary', 'Host', 'Prefix', 'Level')
- utils.print_list(log_levels, columns)
+ shell_utils.print_list(log_levels, columns)
@utils.arg('volume', metavar='<volume>',
@@ -2716,7 +2719,7 @@ def do_backup_create(cs, args):
if 'links' in info:
info.pop('links')
- utils.print_dict(info)
+ shell_utils.print_dict(info)
with cs.backups.completion_cache(
'uuid',
@@ -2757,7 +2760,7 @@ def do_transfer_create(cs, args):
info.update(transfer._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--all-tenants',
@@ -2805,7 +2808,7 @@ def do_transfer_list(cs, args):
transfers = cs.transfers.list(search_opts=search_opts, sort=sort)
columns = ['ID', 'Volume ID', 'Name']
- utils.print_list(transfers, columns)
+ shell_utils.print_list(transfers, columns)
AppendFilters.filters = []
@@ -2822,7 +2825,7 @@ def do_default_type_set(cs, args):
project = args.project
default_type = cs.default_types.create(volume_type, project)
- utils.print_dict(default_type._info)
+ shell_utils.print_dict(default_type._info)
@api_versions.wraps('3.62')
@@ -2837,9 +2840,9 @@ def do_default_type_list(cs, args):
default_types = cs.default_types.list(project_id)
columns = ['Volume Type ID', 'Project ID']
if project_id:
- utils.print_dict(default_types._info)
+ shell_utils.print_dict(default_types._info)
else:
- utils.print_list(default_types, columns)
+ shell_utils.print_list(default_types, columns)
@api_versions.wraps('3.62')
@@ -2858,3 +2861,23 @@ def do_default_type_unset(cs, args):
except Exception as e:
print("Unset for default volume type for project %s failed: %s"
% (project_id, e))
+
+
+@api_versions.wraps('3.68')
+@utils.arg('volume',
+ metavar='<volume>',
+ help='Name or ID of volume to reimage')
+@utils.arg('image_id',
+ metavar='<image-id>',
+ help='The image id of the image that will be used to reimage '
+ 'the volume.')
+@utils.arg('--reimage-reserved',
+ metavar='<True|False>',
+ default=False,
+ help='Enables or disables reimage for a volume that is in '
+ 'reserved state otherwise only volumes in "available" '
+ ' or "error" status may be re-imaged. Default=False.')
+def do_reimage(cs, args):
+ """Rebuilds a volume, overwriting all content with the specified image"""
+ volume = utils.find_volume(cs, args.volume)
+ volume.reimage(args.image_id, args.reimage_reserved)
diff --git a/cinderclient/v3/shell_base.py b/cinderclient/v3/shell_base.py
index 0dee47e..56d9df4 100644
--- a/cinderclient/v3/shell_base.py
+++ b/cinderclient/v3/shell_base.py
@@ -163,7 +163,7 @@ def do_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(volumes, key_list, exclude_unavailable=True,
+ shell_utils.print_list(volumes, key_list, exclude_unavailable=True,
sortby_index=sortby_index)
@@ -181,9 +181,9 @@ def do_show(cs, args):
info.pop('links', None)
info = _translate_attachments(info)
- utils.print_dict(info,
- formatters=['metadata', 'volume_image_metadata',
- 'attachment_ids', 'attached_servers'])
+ shell_utils.print_dict(info,
+ formatters=['metadata', 'volume_image_metadata',
+ 'attachment_ids', 'attached_servers'])
class CheckSizeArgForCreate(argparse.Action):
@@ -325,7 +325,7 @@ def do_create(cs, args):
info.pop('links', None)
info = _translate_attachments(info)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--cascade',
@@ -581,9 +581,9 @@ def do_snapshot_list(cs, args):
else:
sortby_index = 0
- utils.print_list(snapshots,
- ['ID', 'Volume ID', 'Status', 'Name', 'Size'],
- sortby_index=sortby_index)
+ shell_utils.print_list(snapshots,
+ ['ID', 'Volume ID', 'Status', 'Name', 'Size'],
+ sortby_index=sortby_index)
@utils.arg('snapshot',
@@ -714,7 +714,7 @@ def do_type_show(cs, args):
info.update(vtype._info)
info.pop('links', None)
- utils.print_dict(info, formatters=['extra_specs'])
+ shell_utils.print_dict(info, formatters=['extra_specs'])
@utils.arg('id',
@@ -746,7 +746,7 @@ def do_type_update(cs, args):
def do_extra_specs_list(cs, args):
"""Lists current volume types and extra specs."""
vtypes = cs.volume_types.list()
- utils.print_list(vtypes, ['ID', 'Name', 'extra_specs'])
+ shell_utils.print_list(vtypes, ['ID', 'Name', 'extra_specs'])
@utils.arg('name',
@@ -821,7 +821,7 @@ def do_type_access_list(cs, args):
access_list = cs.volume_type_access.list(volume_type)
columns = ['Volume_type_ID', 'Project_ID']
- utils.print_list(access_list, columns)
+ shell_utils.print_list(access_list, columns)
@utils.arg('--volume-type', metavar='<volume_type>', required=True,
@@ -972,7 +972,7 @@ def do_absolute_limits(cs, args):
"""Lists absolute limits for a user."""
limits = cs.limits.get(args.tenant).absolute
columns = ['Name', 'Value']
- utils.print_list(limits, columns)
+ shell_utils.print_list(limits, columns)
@utils.arg('tenant',
@@ -984,7 +984,7 @@ def do_rate_limits(cs, args):
"""Lists rate limits for a user."""
limits = cs.limits.get(args.tenant).rate
columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available']
- utils.print_list(limits, columns)
+ shell_utils.print_list(limits, columns)
@utils.arg('volume',
@@ -1133,7 +1133,7 @@ def do_backup_create(cs, args):
if 'links' in info:
info.pop('links')
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('backup', metavar='<backup>', help='Name or ID of backup.')
@@ -1144,7 +1144,7 @@ def do_backup_show(cs, args):
info.update(backup._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--all-tenants',
@@ -1211,7 +1211,7 @@ def do_backup_list(cs, args):
sortby_index = None
else:
sortby_index = 0
- utils.print_list(backups, columns, sortby_index=sortby_index)
+ shell_utils.print_list(backups, columns, sortby_index=sortby_index)
@utils.arg('--force',
@@ -1273,7 +1273,7 @@ def do_backup_restore(cs, args):
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('backup', metavar='<backup>',
@@ -1281,7 +1281,7 @@ def do_backup_restore(cs, args):
def do_backup_export(cs, args):
"""Export backup metadata record."""
info = cs.backups.export_record(args.backup)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('backup_service', metavar='<backup_service>',
@@ -1293,7 +1293,7 @@ def do_backup_import(cs, args):
info = cs.backups.import_record(args.backup_service, args.backup_url)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('backup', metavar='<backup>', nargs='+',
@@ -1345,7 +1345,7 @@ def do_transfer_create(cs, args):
info.update(transfer._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('transfer', metavar='<transfer>',
@@ -1367,7 +1367,7 @@ def do_transfer_accept(cs, args):
info.update(transfer._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--all-tenants',
@@ -1391,7 +1391,7 @@ def do_transfer_list(cs, args):
}
transfers = cs.transfers.list(search_opts=search_opts)
columns = ['ID', 'Volume ID', 'Name']
- utils.print_list(transfers, columns)
+ shell_utils.print_list(transfers, columns)
@utils.arg('transfer', metavar='<transfer>',
@@ -1403,7 +1403,7 @@ def do_transfer_show(cs, args):
info.update(transfer._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('volume', metavar='<volume>',
@@ -1441,7 +1441,7 @@ def do_service_list(cs, args):
# so as not to add the column when the extended ext is not enabled.
if result and hasattr(result[0], 'disabled_reason'):
columns.append("Disabled Reason")
- utils.print_list(result, columns)
+ shell_utils.print_list(result, columns)
@utils.arg('host', metavar='<hostname>', help='Host name.')
@@ -1450,7 +1450,7 @@ def do_service_enable(cs, args):
"""Enables the service."""
result = cs.services.enable(args.host, args.binary)
columns = ["Host", "Binary", "Status"]
- utils.print_list([result], columns)
+ shell_utils.print_list([result], columns)
@utils.arg('host', metavar='<hostname>', help='Host name.')
@@ -1466,7 +1466,7 @@ def do_service_disable(cs, args):
args.reason)
else:
result = cs.services.disable(args.host, args.binary)
- utils.print_list([result], columns)
+ shell_utils.print_list([result], columns)
def treeizeAvailabilityZone(zone):
@@ -1525,13 +1525,13 @@ def do_availability_zone_list(cs, _args):
for zone in availability_zones:
result += treeizeAvailabilityZone(zone)
shell_utils.translate_availability_zone_keys(result)
- utils.print_list(result, ['Name', 'Status'])
+ shell_utils.print_list(result, ['Name', 'Status'])
def do_encryption_type_list(cs, args):
"""Shows encryption type details for volume types. Admin only."""
result = cs.volume_encryption_types.list()
- utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher',
+ shell_utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher',
'Key Size', 'Control Location'])
@@ -1796,7 +1796,7 @@ def do_snapshot_metadata(cs, args):
if args.action == 'set':
metadata = snapshot.set_metadata(metadata)
- utils.print_dict(metadata._info)
+ shell_utils.print_dict(metadata._info)
elif args.action == 'unset':
snapshot.delete_metadata(list(metadata.keys()))
@@ -1806,7 +1806,7 @@ def do_snapshot_metadata(cs, args):
def do_snapshot_metadata_show(cs, args):
"""Shows snapshot metadata."""
snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot)
- utils.print_dict(snapshot._info['metadata'], 'Metadata-property')
+ shell_utils.print_dict(snapshot._info['metadata'], 'Metadata-property')
@utils.arg('volume', metavar='<volume>',
@@ -1814,7 +1814,7 @@ def do_snapshot_metadata_show(cs, args):
def do_metadata_show(cs, args):
"""Shows volume metadata."""
volume = utils.find_volume(cs, args.volume)
- utils.print_dict(volume._info['metadata'], 'Metadata-property')
+ shell_utils.print_dict(volume._info['metadata'], 'Metadata-property')
@utils.arg('volume', metavar='<volume>',
@@ -1823,7 +1823,7 @@ def do_image_metadata_show(cs, args):
"""Shows volume image metadata."""
volume = utils.find_volume(cs, args.volume)
resp, body = volume.show_image_metadata(volume)
- utils.print_dict(body['metadata'], 'Metadata-property')
+ shell_utils.print_dict(body['metadata'], 'Metadata-property')
@utils.arg('volume',
@@ -1839,7 +1839,7 @@ def do_metadata_update_all(cs, args):
volume = utils.find_volume(cs, args.volume)
metadata = shell_utils.extract_metadata(args)
metadata = volume.update_all_metadata(metadata)
- utils.print_dict(metadata['metadata'], 'Metadata-property')
+ shell_utils.print_dict(metadata['metadata'], 'Metadata-property')
@utils.arg('snapshot',
@@ -1855,7 +1855,7 @@ def do_snapshot_metadata_update_all(cs, args):
snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot)
metadata = shell_utils.extract_metadata(args)
metadata = snapshot.update_all_metadata(metadata)
- utils.print_dict(metadata)
+ shell_utils.print_dict(metadata)
@utils.arg('volume', metavar='<volume>', help='ID of volume to update.')
@@ -1954,7 +1954,7 @@ def do_manage(cs, args):
volume = cs.volumes.get(volume.id)
info.update(volume._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('volume', metavar='<volume>',
@@ -1978,7 +1978,7 @@ def do_consisgroup_list(cs, args):
consistencygroups = cs.consistencygroups.list()
columns = ['ID', 'Status', 'Name']
- utils.print_list(consistencygroups, columns)
+ shell_utils.print_list(consistencygroups, columns)
@utils.arg('consistencygroup',
@@ -1992,7 +1992,7 @@ def do_consisgroup_show(cs, args):
info.update(consistencygroup._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('volumetypes',
@@ -2023,7 +2023,7 @@ def do_consisgroup_create(cs, args):
info.update(consistencygroup._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('--cgsnapshot',
@@ -2061,7 +2061,7 @@ def do_consisgroup_create_from_src(cs, args):
args.description)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('consistencygroup',
@@ -2166,7 +2166,7 @@ def do_cgsnapshot_list(cs, args):
cgsnapshots = cs.cgsnapshots.list(search_opts=search_opts)
columns = ['ID', 'Status', 'Name']
- utils.print_list(cgsnapshots, columns)
+ shell_utils.print_list(cgsnapshots, columns)
@utils.arg('cgsnapshot',
@@ -2179,7 +2179,7 @@ def do_cgsnapshot_show(cs, args):
info.update(cgsnapshot._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('consistencygroup',
@@ -2207,7 +2207,7 @@ def do_cgsnapshot_create(cs, args):
info.update(cgsnapshot._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('cgsnapshot',
@@ -2241,7 +2241,7 @@ def do_get_pools(cs, args):
backend['name'] = info['name']
if args.detail:
backend.update(info['capabilities'])
- utils.print_dict(backend)
+ shell_utils.print_dict(backend)
@utils.arg('host',
@@ -2256,9 +2256,9 @@ def do_get_capabilities(cs, args):
infos.update(capabilities._info)
prop = infos.pop('properties', None)
- utils.print_dict(infos, "Volume stats")
- utils.print_dict(prop, "Backend properties",
- formatters=sorted(prop.keys()))
+ shell_utils.print_dict(infos, "Volume stats")
+ shell_utils.print_dict(prop, "Backend properties",
+ formatters=sorted(prop.keys()))
@utils.arg('volume',
@@ -2308,7 +2308,7 @@ def do_snapshot_manage(cs, args):
snapshot = cs.volume_snapshots.get(snapshot.id)
info.update(snapshot._info)
info.pop('links', None)
- utils.print_dict(info)
+ shell_utils.print_dict(info)
@utils.arg('snapshot', metavar='<snapshot>',
@@ -2378,7 +2378,7 @@ def do_manageable_list(cs, args):
columns = ['reference', 'size', 'safe_to_manage']
if detailed:
columns.extend(['reason_not_safe', 'cinder_id', 'extra_info'])
- utils.print_list(volumes, columns, sortby_index=None)
+ shell_utils.print_list(volumes, columns, sortby_index=None)
@utils.arg('host',
@@ -2422,4 +2422,4 @@ def do_snapshot_manageable_list(cs, args):
columns = ['reference', 'size', 'safe_to_manage', 'source_reference']
if detailed:
columns.extend(['reason_not_safe', 'cinder_id', 'extra_info'])
- utils.print_list(snapshots, columns, sortby_index=None)
+ shell_utils.print_list(snapshots, columns, sortby_index=None)
diff --git a/cinderclient/v3/volume_snapshots.py b/cinderclient/v3/volume_snapshots.py
index 9a94422..cb1c3ba 100644
--- a/cinderclient/v3/volume_snapshots.py
+++ b/cinderclient/v3/volume_snapshots.py
@@ -108,6 +108,20 @@ class SnapshotManager(base.ManagerWithFind):
else:
snapshot_metadata = metadata
+ # Bug #1995883: it's possible for the shell to use the user-
+ # specified 3.66 do_snapshot_create function, but if the server
+ # only supports < 3.66, the client will have been downgraded and
+ # will use this function. In that case, the 'force' parameter will
+ # be None, which means that the user didn't specify a value for it,
+ # so we set it to the pre-3.66 default value of False.
+ #
+ # NOTE: we know this isn't a problem for current client consumers
+ # because a null value for 'force' has never been allowed by the
+ # Block Storage API v3, so there's no reason for anyone to directly
+ # call this method passing force=None.
+ if force is None:
+ force = False
+
body = {'snapshot': {'volume_id': volume_id,
'force': force,
'name': name,
diff --git a/cinderclient/v3/volumes.py b/cinderclient/v3/volumes.py
index 42527f7..0479dc3 100644
--- a/cinderclient/v3/volumes.py
+++ b/cinderclient/v3/volumes.py
@@ -67,6 +67,10 @@ class Volume(volumes_base.Volume):
metadata=metadata, bootable=bootable,
cluster=cluster)
+ def reimage(self, image_id, reimage_reserved=False):
+ """Rebuilds the volume with the new specified image"""
+ self.manager.reimage(self, image_id, reimage_reserved)
+
class VolumeManager(volumes_base.VolumeManager):
resource_class = Volume
@@ -282,3 +286,21 @@ class VolumeManager(volumes_base.VolumeManager):
search_opts=options)
return self._get(url, None)
+
+ @api_versions.wraps('3.68')
+ def reimage(self, volume, image_id, reimage_reserved=False):
+ """Reimage a volume
+
+ .. warning:: This is a destructive action and the contents of the
+ volume will be lost.
+
+ :param volume: Volume to reimage.
+ :param reimage_reserved: Boolean to enable or disable reimage
+ of a volume that is in 'reserved' state otherwise only
+ volumes in 'available' status may be re-imaged.
+ :param image_id: The image id.
+ """
+ return self._action('os-reimage',
+ volume,
+ {'image_id': image_id,
+ 'reimage_reserved': reimage_reserved})
diff --git a/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml
new file mode 100644
index 0000000..87964d1
--- /dev/null
+++ b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml
@@ -0,0 +1,8 @@
+---
+fixes:
+ - |
+ `Bug #1995883
+ <https://bugs.launchpad.net/python-cinderclient/+bug/1995883>`_:
+ Fixed bad format request body generated for the snapshot-create
+ action when the client supports mv 3.66 or greater but the Block
+ Storage API being contacted supports < 3.66.
diff --git a/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml
new file mode 100644
index 0000000..5915647
--- /dev/null
+++ b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml
@@ -0,0 +1,6 @@
+---
+upgrade:
+ - |
+ Python 3.6 & 3.7 support has been dropped. The minimum version of Python now
+ supported is Python 3.8.
+
diff --git a/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml
new file mode 100644
index 0000000..a95fb1f
--- /dev/null
+++ b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml
@@ -0,0 +1,10 @@
+---
+features:
+ - |
+ A new ``cinder reimage`` command and related python API binding has been
+ added which allows a user to replace the current content of a specified
+ volume with the data of a specified image supplied by the Image service
+ (Glance). (Note that this is a destructive action, that is, all data
+ currently contained in the volume is destroyed when the volume is
+ re-imaged.) This feature requires Block Storage API microversion 3.68
+ or greater.
diff --git a/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml
new file mode 100644
index 0000000..70e2e1c
--- /dev/null
+++ b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml
@@ -0,0 +1,5 @@
+---
+prelude: |
+ The Yoga release of the python-cinderclient supports Block Storage
+ API version 3 through microversion 3.68. (The maximum microversion
+ of the Block Storage API in the Yoga release is 3.68.)
diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst
index f9f9bfd..340b17f 100644
--- a/releasenotes/source/index.rst
+++ b/releasenotes/source/index.rst
@@ -6,6 +6,8 @@
:maxdepth: 1
unreleased
+ zed
+ yoga
xena
wallaby
victoria
diff --git a/releasenotes/source/yoga.rst b/releasenotes/source/yoga.rst
new file mode 100644
index 0000000..7cd5e90
--- /dev/null
+++ b/releasenotes/source/yoga.rst
@@ -0,0 +1,6 @@
+=========================
+Yoga Series Release Notes
+=========================
+
+.. release-notes::
+ :branch: stable/yoga
diff --git a/releasenotes/source/zed.rst b/releasenotes/source/zed.rst
new file mode 100644
index 0000000..9608c05
--- /dev/null
+++ b/releasenotes/source/zed.rst
@@ -0,0 +1,6 @@
+========================
+Zed Series Release Notes
+========================
+
+.. release-notes::
+ :branch: stable/zed
diff --git a/requirements.txt b/requirements.txt
index 6f8e90b..4c81976 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,7 +4,6 @@
pbr>=5.5.0 # Apache-2.0
PrettyTable>=0.7.2 # BSD
keystoneauth1>=4.3.1 # Apache-2.0
-simplejson>=3.5.1 # MIT
oslo.i18n>=5.0.1 # Apache-2.0
oslo.utils>=4.8.0 # Apache-2.0
requests>=2.25.1 # Apache-2.0
diff --git a/setup.cfg b/setup.cfg
index 7506b72..7b3c798 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,7 +6,7 @@ description_file =
author = OpenStack
author_email = openstack-discuss@lists.openstack.org
home_page = https://docs.openstack.org/python-cinderclient/latest/
-python_requires = >=3.6
+python_requires = >=3.8
classifier =
Development Status :: 5 - Production/Stable
Environment :: Console
@@ -16,9 +16,8 @@ classifier =
License :: OSI Approved :: Apache Software License
Operating System :: POSIX :: Linux
Programming Language :: Python
+ Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3
- Programming Language :: Python :: 3.6
- Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
diff --git a/test-requirements.txt b/test-requirements.txt
index e0d7c93..0886bd1 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -10,7 +10,6 @@ coverage>=5.5 # Apache-2.0
ddt>=1.4.1 # MIT
fixtures>=3.0.0 # Apache-2.0/BSD
requests-mock>=1.2.0 # Apache-2.0
-tempest>=26.0.0 # Apache-2.0
testtools>=2.4.0 # MIT
stestr>=3.1.0 # Apache-2.0
oslo.serialization>=4.1.0 # Apache-2.0
diff --git a/tox.ini b/tox.ini
index dc8777c..1cd674d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,6 +2,7 @@
distribute = False
envlist = py3,pep8
minversion = 3.18.0
+requires = tox<4
skipsdist = True
# this allows tox to infer the base python from the environment name
# and override any basepython configured in this file
@@ -77,6 +78,9 @@ deps =
commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html
[testenv:functional]
+deps =
+ {[testenv]deps}
+ tempest>=26.0.0
commands = stestr run {posargs}
setenv =
{[testenv]setenv}
@@ -95,12 +99,14 @@ setenv =
# TLS (https) server certificate.
passenv = OS_*
-[testenv:functional-py36]
+[testenv:functional-py38]
+deps = {[testenv:functional]deps}
setenv = {[testenv:functional]setenv}
passenv = {[testenv:functional]passenv}
commands = {[testenv:functional]commands}
[testenv:functional-py39]
+deps = {[testenv:functional]deps}
setenv = {[testenv:functional]setenv}
passenv = {[testenv:functional]passenv}
commands = {[testenv:functional]commands}