Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.10", "3.11"]
architecture: ["x64"]

steps:
Expand Down
9 changes: 8 additions & 1 deletion coriolisclient/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import traceback

import six
from six.moves.urllib import parse as urlparse

from keystoneauth1 import exceptions as keystoneauth_exceptions
from oslo_utils import strutils
Expand Down Expand Up @@ -166,7 +167,7 @@ def __init__(self, client):

@wrap_unauthorized_exception
def _list(self, url, response_key=None, obj_class=None, json=None,
values_key='values'):
values_key='values', query: dict | list | None = None):
"""List the collection.
:param url: a partial URL, e.g., '/servers'
:param response_key: the key to be looked up in response dictionary,
Expand All @@ -176,7 +177,13 @@ def _list(self, url, response_key=None, obj_class=None, json=None,
(self.resource_class will be used by default)
:param json: data that will be encoded as JSON and passed in POST
request (GET will be sent by default)
:param query: an optional dict or list of (key, value) tuples
containing query filters
"""

if query:
url += "?" + urlparse.urlencode(query)

if json:
body = self.client.post(url, json=json).json()
else:
Expand Down
19 changes: 18 additions & 1 deletion coriolisclient/cli/deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,25 @@ class ListDeployment(lister.Lister):

def get_parser(self, prog_name):
parser = super(ListDeployment, self).get_parser(prog_name)
parser.add_argument(
'--marker',
help='The id of the last retrieved deployment.')
parser.add_argument(
'--limit', type=int,
help='Maximum number of deployments to retrieve.')
parser.add_argument(
'--sort',
help='Comma-separated list of sort keys and directions in the '
'form of <key>[:<asc|desc>]. The direction defaults to '
'descending if not specified.')
return parser

def take_action(self, args):
obj_list = self.app.client_manager.coriolis.deployments.list()
sort_keys, sort_dirs = cli_utils.parse_sort_arg(args.sort)
obj_list = self.app.client_manager.coriolis.deployments.list(
marker=args.marker,
limit=args.limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs,
)
return DeploymentFormatter().list_objects(obj_list)
19 changes: 18 additions & 1 deletion coriolisclient/cli/transfer_executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from cliff import show

from coriolisclient.cli import formatter
from coriolisclient.cli import utils as cli_utils


class TransferExecutionFormatter(formatter.EntityFormatter):
Expand Down Expand Up @@ -180,9 +181,25 @@ class ListTransferExecution(lister.Lister):
def get_parser(self, prog_name):
parser = super(ListTransferExecution, self).get_parser(prog_name)
parser.add_argument('transfer', help='The transfer\'s id')
parser.add_argument(
'--marker',
help='The id of the last retrieved execution.')
parser.add_argument(
'--limit', type=int,
help='Maximum number of executions to retrieve.')
parser.add_argument(
'--sort',
help='Comma-separated list of sort keys and directions in the '
'form of <key>[:<asc|desc>]. The direction defaults to '
'descending if not specified.')
return parser

def take_action(self, args):
sort_keys, sort_dirs = cli_utils.parse_sort_arg(args.sort)
obj_list = self.app.client_manager.coriolis.transfer_executions.list(
args.transfer)
args.transfer,
marker=args.marker,
limit=args.limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs)
return TransferExecutionFormatter().list_objects(obj_list)
19 changes: 18 additions & 1 deletion coriolisclient/cli/transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,27 @@ class ListTransfer(lister.Lister):

def get_parser(self, prog_name):
parser = super(ListTransfer, self).get_parser(prog_name)
parser.add_argument(
'--marker',
help='The id of the last retrieved transfer.')
parser.add_argument(
'--limit', type=int,
help='Maximum number of transfers to retrieve.')
parser.add_argument(
'--sort',
help='Comma-separated list of sort keys and directions in the '
'form of <key>[:<asc|desc>]. The direction defaults to '
'descending if not specified.')
return parser

def take_action(self, args):
obj_list = self.app.client_manager.coriolis.transfers.list()
sort_keys, sort_dirs = cli_utils.parse_sort_arg(args.sort)
obj_list = self.app.client_manager.coriolis.transfers.list(
marker=args.marker,
limit=args.limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs,
)
return TransferFormatter().list_objects(obj_list)


Expand Down
26 changes: 26 additions & 0 deletions coriolisclient/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import uuid

from coriolisclient import constants
from coriolisclient import exceptions


def add_storage_mappings_arguments_to_parser(parser):
Expand Down Expand Up @@ -264,3 +265,28 @@ def _split_pool_mapping_arg(arg):
'same OS type and which are compatible with OSMorphing '
'the guest OS of each afferent instance. The mappings must '
'be of the form "INSTANCE_IDENTIFIER=MINION_POOL_ID".')


def parse_sort_arg(sort: str | None) -> tuple[list, list]:
"""Parse sort CLI argument.

:param sort: Comma-separated list of sort keys and directions in the form
of <key>[:<asc|desc>]. The direction defaults to descending if
not specified.
:returns: (sort_keys, sort_dirs)
"""
sort_keys = []
sort_dirs = []
if not sort:
return sort_keys, sort_dirs

for sort_entry in sort.split(','):
sort_key, _sep, sort_dir = sort_entry.partition(':')
if not sort_dir:
sort_dir = 'desc'
elif sort_dir not in ('asc', 'desc'):
raise exceptions.CoriolisException(
'Unknown sort direction: %s' % sort_dir)
sort_keys.append(sort_key)
sort_dirs.append(sort_dir)
return sort_keys, sort_dirs
15 changes: 13 additions & 2 deletions coriolisclient/tests/cli/test_deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,23 @@ def test_get_parser(self):
parser = self.cli.get_parser('coriolis')
self.assertIsInstance(parser, argparse.ArgumentParser)

def test_take_action(self):
@mock.patch("coriolisclient.cli.utils.parse_sort_arg")
def test_take_action(self, mock_parse_sort_arg):
mock_parse_sort_arg.return_value = (
mock.sentinel.sort_keys, mock.sentinel.sort_dirs)

mock_args = mock.MagicMock()
mock_fun = self.mock_app.client_manager.coriolis.deployments.list
mock_fun.return_value = [
v1_deployments.Deployment(mock.MagicMock(), DEPLOYMENT_LIST_DATA)]

columns, data = self.cli.take_action(mock.ANY)
columns, data = self.cli.take_action(mock_args)

self.assertEqual(deployments.DeploymentFormatter().columns, columns)
self.assertEqual([DEPLOYMENT_LIST_FORMATTED_DATA], list(data))
mock_fun.assert_called_once_with(
marker=mock_args.marker,
limit=mock_args.limit,
sort_keys=mock.sentinel.sort_keys,
sort_dirs=mock.sentinel.sort_dirs,
)
13 changes: 12 additions & 1 deletion coriolisclient/tests/cli/test_transfer_executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,15 @@ def test_get_parser(

@mock.patch.object(transfer_executions.TransferExecutionFormatter,
'list_objects')
@mock.patch("coriolisclient.cli.utils.parse_sort_arg")
def test_take_action(
self,
mock_parse_sort_arg,
mock_list_objects
):
mock_parse_sort_arg.return_value = (
mock.sentinel.sort_keys, mock.sentinel.sort_dirs)

args = mock.Mock()
args.transfer = mock.sentinel.transfer
mock_transfer_list = mock.Mock()
Expand All @@ -435,6 +440,12 @@ def test_take_action(
mock_list_objects.return_value,
result
)
mock_transfer_list.assert_called_once_with(mock.sentinel.transfer)
mock_transfer_list.assert_called_once_with(
mock.sentinel.transfer,
marker=args.marker,
limit=args.limit,
sort_keys=mock.sentinel.sort_keys,
sort_dirs=mock.sentinel.sort_dirs,
)
mock_list_objects.assert_called_once_with(
mock_transfer_list.return_value)
21 changes: 17 additions & 4 deletions coriolisclient/tests/cli/test_transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,18 +470,31 @@ def test_get_parser(self, mock_get_parser):
mock_get_parser.assert_called_once_with(mock.sentinel.prog_name)

@mock.patch.object(transfers.TransferFormatter, 'list_objects')
def test_take_action(self, mock_list_objects):
@mock.patch("coriolisclient.cli.utils.parse_sort_arg")
def test_take_action(self, mock_parse_sort_arg, mock_list_objects):
mock_parse_sort_arg.return_value = (
mock.sentinel.sort_keys, mock.sentinel.sort_dirs)

args = mock.Mock()
mock_transfer = mock.Mock()
self.mock_app.client_manager.coriolis.transfers.list = mock_transfer
args.sort = None
mock_transfer_list = mock.Mock()
self.mock_app.client_manager.coriolis.transfers.list = (
mock_transfer_list)

result = self.transfer.take_action(args)

self.assertEqual(
mock_list_objects.return_value,
result
)
mock_list_objects.assert_called_once_with(mock_transfer.return_value)
mock_list_objects.assert_called_once_with(
mock_transfer_list.return_value)
mock_transfer_list.assert_called_once_with(
marker=args.marker,
limit=args.limit,
sort_keys=mock.sentinel.sort_keys,
sort_dirs=mock.sentinel.sort_dirs,
)


class UpdateTransferTestCase(test_base.CoriolisBaseTestCase):
Expand Down
10 changes: 10 additions & 0 deletions coriolisclient/tests/cli/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,13 @@ def test_add_minion_pool_args_to_parser(self):
[{'instance_id': 'mock_instance_id', 'pool_id': 'mock_pool_id'}],
args.instance_osmorphing_minion_pool_mappings
)

@ddt.data(
(None, ([], [])),
("key0:asc,key1:desc,key2",
(["key0", "key1", "key2"], ["asc", "desc", "desc"]))
)
@ddt.unpack
def test_parse_sort_arg(self, sort_arg, exp_ret):
ret = utils.parse_sort_arg(sort_arg)
self.assertEqual(exp_ret, ret)
43 changes: 42 additions & 1 deletion coriolisclient/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def setUp(self):
self.manager = base.BaseManager(mock_client)

def test_list(self):
self.manager.client.get(mock.sentinel.url).json.return_value = {
self.manager.client.get.return_value.json.return_value = {
"mock_response_key": {
"data": [mock.sentinel.data1, mock.sentinel.data2]}
}
Expand All @@ -294,6 +294,7 @@ def test_list(self):
values_key="data"
)

self.manager.client.get.assert_called_once_with(mock.sentinel.url)
self.assertEqual(
[obj_class.return_value] * 2,
result
Expand All @@ -303,6 +304,46 @@ def test_list(self):
mock.call(self.manager, mock.sentinel.data2, loaded=True)
])

def test_list_with_dict_query(self):
self.manager.client.get.return_value.json.return_value = {
"mock_response_key": {"data": []}
}
testutils.get_wrapped_function(self.manager._list)(
self.manager,
url="test-url",
response_key="mock_response_key",
obj_class=mock.Mock(),
json=None,
values_key="data",
query={
"some_filter": "some_value",
"some_other_filter": "some_other_value"
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: query should be a list, not a dict, as seen in the test below, and in the calls in the tests above. It has to be a list because the same key can be specified multiple times.

Copy link
Copy Markdown
Member Author

@petrutlucian94 petrutlucian94 May 6, 2026

Choose a reason for hiding this comment

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

urlparse.urlencode accepts both, which is why _list does the same:

def _list(self, url, response_key=None, obj_class=None, json=None,
              values_key='values', query: dict | list | None = None):

test_list_with_dict_query covers dict query params, test_list_with_tuple_list_query handles lists of tuples.

)
self.manager.client.get.assert_called_once_with(
"test-url?some_filter=some_value&"
"some_other_filter=some_other_value")

def test_list_with_tuple_list_query(self):
self.manager.client.get.return_value.json.return_value = {
"mock_response_key": {"data": []}
}
testutils.get_wrapped_function(self.manager._list)(
self.manager,
url="test-url",
response_key="mock_response_key",
obj_class=mock.Mock(),
json=None,
values_key="data",
query=[
("some_filter", "some_value"),
("some_other_filter", "some_other_value"),
]
)
self.manager.client.get.assert_called_once_with(
"test-url?some_filter=some_value&"
"some_other_filter=some_other_value")

def test_list_json(self):
(self.manager.client.post(mock.sentinel.url, json=True).json.
return_value) = [mock.sentinel.data]
Expand Down
23 changes: 22 additions & 1 deletion coriolisclient/tests/v1/test_deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,28 @@ def test_list(self):
result = self.deployments.list(detail=True)
self.assertEqual(mock_list.return_value, result)
mock_list.assert_called_once_with(
'/deployments/detail', 'deployments')
'/deployments/detail', 'deployments', query=[])

def test_list_with_pagination(self):
with mock.patch.object(self.deployments, '_list') as mock_list:
result = self.deployments.list(
detail=True,
marker=mock.sentinel.marker,
limit=mock.sentinel.limit,
sort_keys=[mock.sentinel.sort_key0, mock.sentinel.sort_key1],
sort_dirs=[mock.sentinel.sort_dir0, mock.sentinel.sort_dir1],
)
exp_query = [
("marker", mock.sentinel.marker),
("limit", mock.sentinel.limit),
("sort_key", mock.sentinel.sort_key0),
("sort_key", mock.sentinel.sort_key1),
("sort_dir", mock.sentinel.sort_dir0),
("sort_dir", mock.sentinel.sort_dir1),
]
self.assertEqual(mock_list.return_value, result)
mock_list.assert_called_once_with(
'/deployments/detail', 'deployments', query=exp_query)

def test_get(self):
deployment = mock.Mock(uuid=DEPLOYMENT_ID)
Expand Down
Loading
Loading