From 287d6c5688e4409302b1b0beeb526862f841abbb Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 14 Feb 2024 10:33:10 -0500 Subject: [PATCH 01/23] fix: Support specifying arrays nested in complex lists as JSON (#577) --- linodecli/arg_helpers.py | 16 +++++++++--- linodecli/baked/request.py | 5 ++++ .../api_request_test_foobar_post.yaml | 5 ++++ tests/integration/linodes/test_interfaces.py | 3 +++ tests/unit/test_arg_helpers.py | 25 +++++++++++++------ tests/unit/test_operation.py | 4 +++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 0b3f50f4f..b438e2db7 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -398,11 +398,21 @@ def action_help(cli, command, action): if op.method in {"post", "put"} and arg.required else "" ) - nullable_fmt = " (nullable)" if arg.nullable else "" - print( - f" --{arg.path}: {is_required}{arg.description}{nullable_fmt}" + + extensions = [] + + if arg.format == "json": + extensions.append("JSON") + + if arg.nullable: + extensions.append("nullable") + + suffix = ( + f" ({', '.join(extensions)})" if len(extensions) > 0 else "" ) + print(f" --{arg.path}: {is_required}{arg.description}{suffix}") + def bake_command(cli, spec_loc): """ diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 818a8263f..8cf7e420d 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -50,6 +50,11 @@ def __init__( schema.extensions.get("linode-cli-format") or schema.format or None ) + # If this is a deeply nested array we should treat it as JSON. + # This allows users to specify fields like --interfaces.ip_ranges. + if schema.type == "array" and list_parent is not None: + self.format = "json" + #: The type accepted for this argument. This will ultimately determine what #: we accept in the ArgumentParser self.datatype = ( diff --git a/tests/fixtures/api_request_test_foobar_post.yaml b/tests/fixtures/api_request_test_foobar_post.yaml index f7dd8d934..5ce433eb7 100644 --- a/tests/fixtures/api_request_test_foobar_post.yaml +++ b/tests/fixtures/api_request_test_foobar_post.yaml @@ -114,6 +114,11 @@ components: nested_int: type: number description: A deeply nested integer. + field_array: + type: array + description: An arbitrary deeply nested array. + items: + type: string field_string: type: string description: An arbitrary field. diff --git a/tests/integration/linodes/test_interfaces.py b/tests/integration/linodes/test_interfaces.py index e963110d0..587e835f4 100644 --- a/tests/integration/linodes/test_interfaces.py +++ b/tests/integration/linodes/test_interfaces.py @@ -47,6 +47,8 @@ def linode_with_vpc_interface(): "any", "--interfaces.ipv4.vpc", "10.0.0.5", + "--interfaces.ip_ranges", + json.dumps(["10.0.0.6/32"]), "--interfaces.purpose", "public", "--json", @@ -89,6 +91,7 @@ def test_with_vpc_interface(linode_with_vpc_interface): assert vpc_interface["vpc_id"] == vpc_json["id"] assert vpc_interface["ipv4"]["vpc"] == "10.0.0.5" assert vpc_interface["ipv4"]["nat_1_1"] == linode_json["ipv4"][0] + assert vpc_interface["ip_ranges"][0] == "10.0.0.6/32" assert not public_interface["primary"] assert public_interface["purpose"] == "public" diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index 99b65fdc3..b59a0cc11 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -184,13 +184,22 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli): {"lang": "CLI", "source": "linode-cli command action\n --bar=foo"}, ] - mocked_args = mocker.MagicMock() - mocked_args.read_only = False - mocked_args.required = True - mocked_args.path = "path" - mocked_args.description = "test description" - - mocked_ops.args = [mocked_args] + mocked_ops.args = [ + mocker.MagicMock( + read_only=False, + required=True, + path="path", + description="test description", + ), + mocker.MagicMock( + read_only=False, + required=False, + path="path2", + description="test description 2", + format="json", + nullable=True, + ), + ] mock_cli.find_operation = mocker.Mock(return_value=mocked_ops) @@ -209,7 +218,9 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli): ) in captured.out assert "Arguments" in captured.out assert "test description" in captured.out + assert "test description 2" in captured.out assert "(required)" in captured.out + assert "(JSON, nullable)" in captured.out assert "filter results" not in captured.out def test_action_help_get_method(self, capsys, mocker, mock_cli): diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 2f8d518c6..87abeb501 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -1,4 +1,5 @@ import argparse +import json from linodecli.baked import operation from linodecli.baked.operation import ExplicitEmptyListValue, ExplicitNullValue @@ -171,6 +172,8 @@ def test_parse_args_object_list(self, create_operation): "test2", "--object_list.field_dict.nested_int", "789", + "--object_list.field_array", + json.dumps(["foo", "bar"]), # Second object "--object_list.field_int", "456", @@ -184,6 +187,7 @@ def test_parse_args_object_list(self, create_operation): "field_string": "test1", "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, + "field_array": ["foo", "bar"], "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, From 65bc15f998bca74039f071573f124d1b11545ab6 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 14 Feb 2024 12:56:41 -0500 Subject: [PATCH 02/23] new: Address breaking change in MDS plugin `sshkeys` command (#579) --- linodecli/plugins/metadata.py | 13 +++++++++---- tests/unit/test_plugin_metadata.py | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/linodecli/plugins/metadata.py b/linodecli/plugins/metadata.py index 7587da309..cfa1147ce 100644 --- a/linodecli/plugins/metadata.py +++ b/linodecli/plugins/metadata.py @@ -57,11 +57,16 @@ def print_ssh_keys_table(data): """ table = Table(show_lines=True) - table.add_column("ssh keys") + table.add_column("user") + table.add_column("ssh key") - if data.users.root is not None: - for key in data.users.root: - table.add_row(key) + for name, keys in data.users.items(): + # Keys will be None if no keys are configured for the user + if keys is None: + continue + + for key in keys: + table.add_row(name, key) rprint(table) diff --git a/tests/unit/test_plugin_metadata.py b/tests/unit/test_plugin_metadata.py index 599002019..72e3c6df7 100644 --- a/tests/unit/test_plugin_metadata.py +++ b/tests/unit/test_plugin_metadata.py @@ -116,7 +116,9 @@ def test_ssh_key_table(capsys: CaptureFixture): print_ssh_keys_table(SSH_KEYS) captured_text = capsys.readouterr() - assert "ssh keys" in captured_text.out + assert "user" in captured_text.out + assert "ssh key" in captured_text.out + assert "root" in captured_text.out assert "ssh-key-1" in captured_text.out assert "ssh-key-2" in captured_text.out @@ -125,4 +127,5 @@ def test_empty_ssh_key_table(capsys: CaptureFixture): print_ssh_keys_table(SSH_KEYS_EMPTY) captured_text = capsys.readouterr() - assert "ssh keys" in captured_text.out + assert "user" in captured_text.out + assert "ssh key" in captured_text.out From df37368a075b17fc8357f4a0939379bf58a15b38 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 15 Feb 2024 14:35:40 -0500 Subject: [PATCH 03/23] Add `SKIP_BAKE` Argument to `bake` Target in Makefile (#580) --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index b2a115a59..12148fedc 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,12 @@ install: check-prerequisites requirements build .PHONY: bake bake: clean +ifeq ($(SKIP_BAKE), 1) + @echo Skipping bake stage +else python3 -m linodecli bake ${SPEC} --skip-config cp data-3 linodecli/ +endif .PHONY: build build: clean bake From cef8240bcd2629e7ea781454f72c0711325ee72a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 16 Feb 2024 15:27:00 -0500 Subject: [PATCH 04/23] new: Support specifying complex list arguments as JSON (#578) --- linodecli/api_request.py | 4 ++ linodecli/arg_helpers.py | 5 +- linodecli/baked/operation.py | 59 ++++++++++++++++-- linodecli/baked/request.py | 47 +++++++++++--- tests/integration/helpers.py | 31 +--------- tests/integration/linodes/test_interfaces.py | 61 ++++++++++++++++++- .../integration/networking/test_networking.py | 6 +- tests/unit/test_arg_helpers.py | 2 +- tests/unit/test_operation.py | 48 +++++++++++++++ 9 files changed, 213 insertions(+), 50 deletions(-) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index c61c47632..9c584218a 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -257,11 +257,15 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]: # expand paths for k, v in vars(parsed_args).items(): + if v is None: + continue + cur = expanded_json for part in k.split(".")[:-1]: if part not in cur: cur[part] = {} cur = cur[part] + cur[k.split(".")[-1]] = v return json.dumps(_traverse_request_body(expanded_json)) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index b438e2db7..6add0c7cc 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -341,7 +341,7 @@ def help_with_ops(ops, config): ) -def action_help(cli, command, action): +def action_help(cli, command, action): # pylint: disable=too-many-branches """ Prints help relevant to the command and action """ @@ -407,6 +407,9 @@ def action_help(cli, command, action): if arg.nullable: extensions.append("nullable") + if arg.is_parent: + extensions.append("conflicts with children") + suffix = ( f" ({', '.join(extensions)})" if len(extensions) > 0 else "" ) diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index b8141adc0..a104710be 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -8,9 +8,10 @@ import platform import re import sys +from collections import defaultdict from getpass import getpass from os import environ, path -from typing import List, Tuple +from typing import Any, List, Tuple from openapi3.paths import Operation @@ -427,14 +428,14 @@ def _add_args_post_put(self, parser) -> List[Tuple[str, str]]: action=ArrayAction, type=arg_type_handler, ) - elif arg.list_item: + elif arg.is_child: parser.add_argument( "--" + arg.path, metavar=arg.name, action=ListArgumentAction, type=arg_type_handler, ) - list_items.append((arg.path, arg.list_parent)) + list_items.append((arg.path, arg.parent)) else: if arg.datatype == "string" and arg.format == "password": # special case - password input @@ -463,10 +464,51 @@ def _add_args_post_put(self, parser) -> List[Tuple[str, str]]: return list_items + def _validate_parent_child_conflicts(self, parsed: argparse.Namespace): + """ + This method validates that no child arguments (e.g. --interfaces.purpose) are + specified alongside their parent (e.g. --interfaces). + """ + conflicts = defaultdict(list) + + for arg in self.args: + parent = arg.parent + arg_value = getattr(parsed, arg.path, None) + + if parent is None or arg_value is None: + continue + + # Special case to ignore child arguments that are not specified + # but are implicitly populated by ListArgumentAction. + if isinstance(arg_value, list) and arg_value.count(None) == len( + arg_value + ): + continue + + # If the parent isn't defined, we can + # skip this one + if getattr(parsed, parent) is None: + continue + + # We found a conflict + conflicts[parent].append(arg) + + # No conflicts found + if len(conflicts) < 1: + return + + for parent, args in conflicts.items(): + arg_format = ", ".join([f"--{v.path}" for v in args]) + print( + f"Argument(s) {arg_format} cannot be specified when --{parent} is specified.", + file=sys.stderr, + ) + + sys.exit(2) + @staticmethod def _handle_list_items( - list_items, - parsed, + list_items: List[Tuple[str, str]], parsed: Any ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements lists = {} @@ -563,4 +605,9 @@ def parse_args(self, args): elif self.method in ("post", "put"): list_items = self._add_args_post_put(parser) - return self._handle_list_items(list_items, parser.parse_args(args)) + parsed = parser.parse_args(args) + + if self.method in ("post", "put"): + self._validate_parent_child_conflicts(parsed) + + return self._handle_list_items(list_items, parsed) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 8cf7e420d..450ba25b6 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -9,7 +9,13 @@ class OpenAPIRequestArg: """ def __init__( - self, name, schema, required, prefix=None, list_parent=None + self, + name, + schema, + required, + prefix=None, + is_parent=False, + parent=None, ): # pylint: disable=too-many-arguments """ Parses a single Schema node into a argument the CLI can use when making @@ -23,6 +29,10 @@ def __init__( :param prefix: The prefix for this arg's path, used in the actual argument to the CLI to ensure unique arg names :type prefix: str + :param is_parent: Whether this argument is a parent to child fields. + :type is_parent: bool + :param parent: If applicable, the path to the parent list for this argument. + :type parent: Optional[str] """ #: The name of this argument, mostly used for display and docs self.name = name @@ -52,7 +62,7 @@ def __init__( # If this is a deeply nested array we should treat it as JSON. # This allows users to specify fields like --interfaces.ip_ranges. - if schema.type == "array" and list_parent is not None: + if is_parent or (schema.type == "array" and parent is not None): self.format = "json" #: The type accepted for this argument. This will ultimately determine what @@ -64,13 +74,16 @@ def __init__( #: The type of item accepted in this list; if None, this is not a list self.item_type = None - #: Whether the argument is a field in a nested list. - self.list_item = list_parent is not None + #: Whether the argument is a parent to child fields. + self.is_parent = is_parent + + #: Whether the argument is a nested field. + self.is_child = parent is not None #: The name of the list this argument falls under. #: This allows nested dictionaries to be specified in lists of objects. #: e.g. --interfaces.ipv4.nat_1_1 - self.list_parent = list_parent + self.parent = parent #: The path of the path element in the schema. self.prefix = prefix @@ -90,7 +103,7 @@ def __init__( ) -def _parse_request_model(schema, prefix=None, list_parent=None): +def _parse_request_model(schema, prefix=None, parent=None): """ Parses a schema into a list of OpenAPIRequest objects :param schema: The schema to parse as a request model @@ -112,8 +125,11 @@ def _parse_request_model(schema, prefix=None, list_parent=None): if v.type == "object" and not v.readOnly and v.properties: # nested objects receive a prefix and are otherwise parsed normally pref = prefix + "." + k if prefix else k + args += _parse_request_model( - v, prefix=pref, list_parent=list_parent + v, + prefix=pref, + parent=parent, ) elif ( v.type == "array" @@ -124,9 +140,20 @@ def _parse_request_model(schema, prefix=None, list_parent=None): # handle lists of objects as a special case, where each property # of the object in the list is its own argument pref = prefix + "." + k if prefix else k - args += _parse_request_model( - v.items, prefix=pref, list_parent=pref + + # Support specifying this list as JSON + args.append( + OpenAPIRequestArg( + k, + v.items, + False, + prefix=prefix, + is_parent=True, + parent=parent, + ) ) + + args += _parse_request_model(v.items, prefix=pref, parent=pref) else: # required fields are defined in the schema above the property, so # we have to check here if required fields are defined/if this key @@ -136,7 +163,7 @@ def _parse_request_model(schema, prefix=None, list_parent=None): required = k in schema.required args.append( OpenAPIRequestArg( - k, v, required, prefix=prefix, list_parent=list_parent + k, v, required, prefix=prefix, parent=parent ) ) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 95edd2613..05b207417 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -8,9 +8,6 @@ INVALID_HOST = "https://wrongapi.linode.com" SUCCESS_STATUS_CODE = 0 FAILED_STATUS_CODE = 256 -INVALID_HOST = "https://wrongapi.linode.com" -SUCCESS_STATUS_CODE = 0 - COMMAND_JSON_OUTPUT = ["--suppress-warnings", "--no-defaults", "--json"] @@ -32,19 +29,13 @@ def wait_for_condition(interval: int, timeout: int, condition: Callable): def exec_test_command(args: List[str]): - process = subprocess.run( - args, - stdout=subprocess.PIPE, - ) + process = subprocess.run(args, stdout=subprocess.PIPE) assert process.returncode == 0 return process def exec_failing_test_command(args: List[str], expected_code: int = 1): - process = subprocess.run( - args, - stderr=subprocess.PIPE, - ) + process = subprocess.run(args, stderr=subprocess.PIPE) assert process.returncode == expected_code return process @@ -143,23 +134,5 @@ def remove_all(target: str): exec_test_command(["linode-cli", target, "delete", id]) -def exec_test_command(args: List[str]): - process = subprocess.run( - args, - stdout=subprocess.PIPE, - ) - assert process.returncode == 0 - return process - - -def exec_failing_test_command(args: List[str]): - process = subprocess.run( - args, - stderr=subprocess.PIPE, - ) - assert process.returncode == 1 - return process - - def count_lines(text: str): return len(list(filter(len, text.split("\n")))) diff --git a/tests/integration/linodes/test_interfaces.py b/tests/integration/linodes/test_interfaces.py index 587e835f4..3e961e716 100644 --- a/tests/integration/linodes/test_interfaces.py +++ b/tests/integration/linodes/test_interfaces.py @@ -1,5 +1,6 @@ import json import time +from typing import Any, Dict import pytest @@ -65,9 +66,57 @@ def linode_with_vpc_interface(): delete_target_id(target="vpcs", id=vpc_id) -def test_with_vpc_interface(linode_with_vpc_interface): - linode_json, vpc_json = linode_with_vpc_interface +@pytest.fixture +def linode_with_vpc_interface_as_json(): + vpc_json = create_vpc_w_subnet() + + vpc_region = vpc_json["region"] + vpc_id = str(vpc_json["id"]) + subnet_id = int(vpc_json["subnets"][0]["id"]) + + linode_json = json.loads( + exec_test_command( + BASE_CMD + + [ + "create", + "--type", + "g6-nanode-1", + "--region", + vpc_region, + "--image", + DEFAULT_TEST_IMAGE, + "--root_pass", + DEFAULT_RANDOM_PASS, + "--interfaces", + json.dumps( + [ + { + "purpose": "vpc", + "primary": True, + "subnet_id": subnet_id, + "ipv4": {"nat_1_1": "any", "vpc": "10.0.0.5"}, + "ip_ranges": ["10.0.0.6/32"], + }, + {"purpose": "public"}, + ] + ), + "--json", + "--suppress-warnings", + ] + ) + .stdout.decode() + .rstrip() + )[0] + + yield linode_json, vpc_json + delete_target_id(target="linodes", id=str(linode_json["id"])) + delete_target_id(target="vpcs", id=vpc_id) + + +def assert_interface_configuration( + linode_json: Dict[str, Any], vpc_json: Dict[str, Any] +): config_json = json.loads( exec_test_command( BASE_CMD @@ -95,3 +144,11 @@ def test_with_vpc_interface(linode_with_vpc_interface): assert not public_interface["primary"] assert public_interface["purpose"] == "public" + + +def test_with_vpc_interface(linode_with_vpc_interface): + assert_interface_configuration(*linode_with_vpc_interface) + + +def test_with_vpc_interface_as_json(linode_with_vpc_interface_as_json): + assert_interface_configuration(*linode_with_vpc_interface_as_json) diff --git a/tests/integration/networking/test_networking.py b/tests/integration/networking/test_networking.py index 267e03c29..53fdc3d35 100644 --- a/tests/integration/networking/test_networking.py +++ b/tests/integration/networking/test_networking.py @@ -2,6 +2,7 @@ import re import pytest +from _pytest.monkeypatch import MonkeyPatch from tests.integration.helpers import delete_target_id, exec_test_command from tests.integration.linodes.helpers_linodes import ( @@ -121,8 +122,11 @@ def test_allocate_additional_private_ipv4_address(test_linode_id): ) -def test_share_ipv4_address(test_linode_id_shared_ipv4): +def test_share_ipv4_address( + test_linode_id_shared_ipv4, monkeypatch: MonkeyPatch +): target_linode, parent_linode = test_linode_id_shared_ipv4 + monkeypatch.setenv("LINODE_CLI_API_VERSION", "v4beta") # Allocate an IPv4 address on the parent Linode ip_address = json.loads( diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index b59a0cc11..e934158f0 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -220,7 +220,7 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli): assert "test description" in captured.out assert "test description 2" in captured.out assert "(required)" in captured.out - assert "(JSON, nullable)" in captured.out + assert "(JSON, nullable, conflicts with children)" in captured.out assert "filter results" not in captured.out def test_action_help_get_method(self, capsys, mocker, mock_cli): diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 87abeb501..36c583259 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -1,4 +1,6 @@ import argparse +import contextlib +import io import json from linodecli.baked import operation @@ -193,6 +195,52 @@ def test_parse_args_object_list(self, create_operation): {"field_int": 456, "field_dict": {"nested_string": "test3"}}, ] + def test_parse_args_object_list_json(self, create_operation): + expected = [ + { + "field_string": "test1", + "field_int": 123, + "field_dict": {"nested_string": "test2", "nested_int": 789}, + "field_array": ["foo", "bar"], + }, + {"field_int": 456, "field_dict": {"nested_string": "test3"}}, + ] + + result = create_operation.parse_args( + ["--object_list", json.dumps(expected)] + ) + + assert result.object_list == expected + + def test_parse_args_conflicting_parent_child(self, create_operation): + stderr_buf = io.StringIO() + + try: + with contextlib.redirect_stderr(stderr_buf): + create_operation.parse_args( + [ + "--object_list", + "[]", + "--object_list.field_string", + "test", + "--object_list.field_int", + "123", + "--object_list.field_dict.nested_string", + "cool", + ] + ) + except SystemExit as sys_exit: + assert sys_exit.code == 2 + else: + raise RuntimeError("Expected system exit, got none") + + stderr_result = stderr_buf.getvalue() + assert ( + "Argument(s) --object_list.field_dict.nested_string, --object_list.field_string, " + "--object_list.field_int cannot be specified when --object_list is specified." + in stderr_result + ) + def test_array_arg_action_basic(self): """ Tests a basic array argument condition.. From 8b2e3c13da096a0adc65c8692e90c60799abcf44 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 4 Mar 2024 11:54:14 -0500 Subject: [PATCH 05/23] new: Add API spec version to User-Agent; add User-Agent to metadata plugin requests (#583) --- linodecli/api_request.py | 6 +----- linodecli/cli.py | 11 +++++++++++ linodecli/plugins/metadata.py | 4 ++-- tests/unit/conftest.py | 2 +- tests/unit/test_api_request.py | 1 + tests/unit/test_cli.py | 5 +++++ 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index 9c584218a..abb851c75 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -6,7 +6,6 @@ import json import sys import time -from sys import version_info from typing import Any, Iterable, List, Optional import requests @@ -69,10 +68,7 @@ def do_request( headers = { "Authorization": f"Bearer {ctx.config.get_token()}", "Content-Type": "application/json", - "User-Agent": ( - f"linode-cli:{ctx.version} " - f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}" - ), + "User-Agent": ctx.user_agent, } parsed_args = operation.parse_args(args) diff --git a/linodecli/cli.py b/linodecli/cli.py index b89ee09bb..09a69e2dc 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -193,3 +193,14 @@ def find_operation(self, command, action): # Fail if no matching alias was found raise ValueError(f"No action {action} for command {command}") + + @property + def user_agent(self) -> str: + """ + Returns the User-Agent to use when making API requests. + """ + return ( + f"linode-cli/{self.version} " + f"linode-api-docs/{self.spec_version} " + f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}" + ) diff --git a/linodecli/plugins/metadata.py b/linodecli/plugins/metadata.py index cfa1147ce..2d9597da5 100644 --- a/linodecli/plugins/metadata.py +++ b/linodecli/plugins/metadata.py @@ -194,7 +194,7 @@ def get_metadata_parser(): return parser -def call(args, _): +def call(args, context): """ The entrypoint for this plugin """ @@ -209,7 +209,7 @@ def call(args, _): # make a client, but only if we weren't printing help and endpoint is valid if "--help" not in args: try: - client = MetadataClient() + client = MetadataClient(user_agent=context.client.user_agent) except ConnectTimeout as exc: raise ConnectionError( "Can't access Metadata service. Please verify that you are inside a Linode." diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e6a3e8129..cc1216c23 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -61,7 +61,7 @@ def _get_parsed_spec(filename): @pytest.fixture def mock_cli( - version="DEVELOPMENT", + version="0.0.0", url="http://localhost", defaults=True, ): diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 6e4cade6d..24cfc434f 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -337,6 +337,7 @@ def validate_http_request(url, headers=None, data=None, **kwargs): ] } ) + assert headers["User-Agent"] == mock_cli.user_agent assert "Authorization" in headers assert data is None diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index eddfbc4db..b7f41a79c 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -73,6 +73,11 @@ def test_find_operation( mock_cli.find_operation("foo", "cool") mock_cli.find_operation("cool", "cool") + def test_user_agent(self, mock_cli: CLI): + assert re.compile( + r"linode-cli/[0-9]+\.[0-9]+\.[0-9]+ linode-api-docs/[0-9]+\.[0-9]+\.[0-9]+ python/[0-9]+\.[0-9]+\.[0-9]+" + ).match(mock_cli.user_agent) + def test_get_all_pages( mock_cli: CLI, list_operation: OpenAPIOperation, monkeypatch: MonkeyPatch From 5f5bf720b1623d8d09394a3a8a1973ff4f94ac9d Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 10:14:22 -0400 Subject: [PATCH 06/23] doc: Add development guide to wiki (#586) --- Makefile | 2 +- linodecli/configuration/auth.py | 4 +- wiki/Home.md | 11 +- wiki/OpenAPI Extensions.md | 16 --- wiki/_Sidebar.md | 10 ++ wiki/development/Development - Index.md | 12 ++ wiki/development/Development - Overview.md | 104 ++++++++++++++++++ wiki/development/Development - Setup.md | 86 +++++++++++++++ wiki/development/Development - Skeleton.md | 30 +++++ .../Development - Testing.md} | 12 +- 10 files changed, 255 insertions(+), 32 deletions(-) delete mode 100644 wiki/OpenAPI Extensions.md create mode 100644 wiki/_Sidebar.md create mode 100644 wiki/development/Development - Index.md create mode 100644 wiki/development/Development - Overview.md create mode 100644 wiki/development/Development - Setup.md create mode 100644 wiki/development/Development - Skeleton.md rename wiki/{Testing.md => development/Development - Testing.md} (70%) diff --git a/Makefile b/Makefile index 12148fedc..545e1e6d5 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ build: clean bake .PHONY: requirements requirements: - pip3 install -r requirements.txt -r requirements-dev.txt + pip3 install --upgrade -r requirements.txt -r requirements-dev.txt .PHONY: lint lint: build diff --git a/linodecli/configuration/auth.py b/linodecli/configuration/auth.py index 7f62df7a1..960354ea6 100644 --- a/linodecli/configuration/auth.py +++ b/linodecli/configuration/auth.py @@ -178,8 +178,8 @@ def _get_token_web(base_url): def _handle_oauth_callback(): """ - Sends the user to a URL to perform an OAuth login for the CLI, then redirets - them to a locally-hosted page that captures teh token + Sends the user to a URL to perform an OAuth login for the CLI, then redirects + them to a locally-hosted page that captures the token """ # load up landing page HTML landing_page_path = Path(__file__).parent.parent / "oauth-landing-page.html" diff --git a/wiki/Home.md b/wiki/Home.md index a25a03cdb..eb36dd623 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,9 +1,4 @@ -Welcome to the linode-cli wiki! +Welcome to the linode-cli wiki! -- [Installation](./Installation) -- [Configuration](./Configuration) -- [OpenAPI Extensions](./OpenAPI%20Extensions) -- [Output](./Output) -- [Plugins](./Plugins) -- [Testing](./Testing) -- [Usage](./Usage) +For installation instructions and usage guides, please +refer to the sidebar of this page. \ No newline at end of file diff --git a/wiki/OpenAPI Extensions.md b/wiki/OpenAPI Extensions.md deleted file mode 100644 index ff44aab23..000000000 --- a/wiki/OpenAPI Extensions.md +++ /dev/null @@ -1,16 +0,0 @@ -# Specification Extensions - -In order to be more useful, the following [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions) have been added to Linode's OpenAPI spec: - -| Attribute | Location | Purpose | -| --- | --- | --- | -| x-linode-cli-action | method | The action name for operations under this path. If not present, operationId is used. | -| x-linode-cli-color | property | If present, defines key-value pairs of property value: color. Colors must be one of "red", "green", "yellow", "white", and "black". Must include a default. | -| x-linode-cli-command | path | The command name for operations under this path. If not present, "default" is used. | -| x-linode-cli-display | property | If truthy, displays this as a column in output. If a number, determines the ordering (left to right). | -| x-linode-cli-format | property | Overrides the "format" given in this property for the CLI only. Valid values are `file` and `json`. | -| x-linode-cli-skip | path | If present and truthy, this method will not be available in the CLI. | -| x-linode-cli-allowed-defaults| requestBody | Tells the CLI what configured defaults apply to this request. Valid defaults are "region", "image", "authorized_users", "engine", and "type". | -| x-linode-cli-nested-list | content-type| Tells the CLI to flatten a single object into multiple table rows based on the keys included in this value. Values should be comma-delimited JSON paths, and must all be present on response objects. When used, a new key `_split` is added to each flattened object whose value is the last segment of the JSON path used to generate the flattened object from the source. | -| x-linode-cli-use-schema | content-type| Overrides the normal schema for the object and uses this instead. Especially useful when paired with ``x-linode-cli-nested-list``, allowing a schema to describe the flattened object instead of the original object. | -| x-linode-cli-subtables | content-type| Indicates that certain response attributes should be printed in a separate "sub"-table. This allows certain endpoints with nested structures in the response to be displayed correctly. | diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 000000000..a22828883 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,10 @@ +- [Installation](./Installation) +- [Configuration](./Configuration) +- [Usage](./Usage) +- [Output](./Output) +- [Plugins](./Plugins) +- [Development](./Development%20-%20Index) + - [Overview](./Development%20-%20Overview) + - [Skeleton](./Development%20-%20Skeleton) + - [Setup](./Development%20-%20Setup) + - [Testing](./Development%20-%20Testing) \ No newline at end of file diff --git a/wiki/development/Development - Index.md b/wiki/development/Development - Index.md new file mode 100644 index 000000000..291141a1d --- /dev/null +++ b/wiki/development/Development - Index.md @@ -0,0 +1,12 @@ +This guide will help you get started developing against and contributing to the Linode CLI. + +## Index + +1. [Overview](./Development%20-%20Overview) +2. [Skeleton](./Development%20-%20Skeleton) +3. [Setup](./Development%20-%20Setup) +4. [Testing](./Development%20-%20Testing) + +## Contributing + +Once you're ready to contribute a change to the project, please refer to our [Contributing Guide](https://github.com/linode/linode-cli/blob/dev/CONTRIBUTING.md). \ No newline at end of file diff --git a/wiki/development/Development - Overview.md b/wiki/development/Development - Overview.md new file mode 100644 index 000000000..969aa2a9a --- /dev/null +++ b/wiki/development/Development - Overview.md @@ -0,0 +1,104 @@ +The following section outlines the core functions of the Linode CLI. + +## OpenAPI Specification Parsing + +Most Linode CLI commands (excluding [plugin commands](https://github.com/linode/linode-cli/tree/dev/linodecli/plugins)) +are generated dynamically at build-time from the [Linode OpenAPI Specification](https://github.com/linode/linode-api-docs), +which is also used to generate the [official Linode API documentation](https://www.linode.com/docs/api/). + +Each OpenAPI spec endpoint method is parsed into an `OpenAPIOperation` object. +This object includes all necessary request and response arguments to create a command, +stored as `OpenAPIRequestArg` and `OpenAPIResponseAttr` objects respectively. +At runtime, the Linode CLI changes each `OpenAPIRequestArg` to an argparse argument and +each `OpenAPIResponseAttr` to an outputtable column. It can also manage complex structures like +nested objects and lists, resulting in commands and outputs that may not +exactly match the OpenAPI specification. + +## OpenAPI Specification Extensions + +In order to better support the Linode CLI, the following [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions) have been added to Linode's OpenAPI spec: + +| Attribute | Location | Purpose | +| --- | --- | --- | +| x-linode-cli-action | method | The action name for operations under this path. If not present, operationId is used. | +| x-linode-cli-color | property | If present, defines key-value pairs of property value: color. Colors must be one of "red", "green", "yellow", "white", and "black". Must include a default. | +| x-linode-cli-command | path | The command name for operations under this path. If not present, "default" is used. | +| x-linode-cli-display | property | If truthy, displays this as a column in output. If a number, determines the ordering (left to right). | +| x-linode-cli-format | property | Overrides the "format" given in this property for the CLI only. Valid values are `file` and `json`. | +| x-linode-cli-skip | path | If present and truthy, this method will not be available in the CLI. | +| x-linode-cli-allowed-defaults| requestBody | Tells the CLI what configured defaults apply to this request. Valid defaults are "region", "image", "authorized_users", "engine", and "type". | +| x-linode-cli-nested-list | content-type| Tells the CLI to flatten a single object into multiple table rows based on the keys included in this value. Values should be comma-delimited JSON paths, and must all be present on response objects. When used, a new key `_split` is added to each flattened object whose value is the last segment of the JSON path used to generate the flattened object from the source. | +| x-linode-cli-use-schema | content-type| Overrides the normal schema for the object and uses this instead. Especially useful when paired with ``x-linode-cli-nested-list``, allowing a schema to describe the flattened object instead of the original object. | +| x-linode-cli-subtables | content-type| Indicates that certain response attributes should be printed in a separate "sub"-table. This allows certain endpoints with nested structures in the response to be displayed correctly. | + +## Baking + +The "baking" process is run with `make bake`, `make install`, and `make build` targets, +wrapping the `linode-cli bake` command. + +Objects representing each command are serialized into the `data-3` file via the [pickle](https://docs.python.org/3/library/pickle.html) +package, and are included in release artifacts as a [data file](https://setuptools.pypa.io/en/latest/userguide/datafiles.html). +This enables quick command loading at runtime and eliminates the need for runtime parsing logic. + +## Configuration + +The Linode CLI can be configured using the `linode-cli configure` command, which allows users to +configure the following: + +- A Linode API token + - This can optionally be done using OAuth, see [OAuth Authentication](#oauth-authentication) +- Default values for commonly used fields (e.g. region, image) +- Overrides for the target API URL (hostname, version, scheme, etc.) + +This command serves as an interactive prompt and outputs a configuration file to `~/.config/linode-cli`. +This file is in a simple INI format and can be easily modified manually by users. + +Additionally, multiple users can be created for the CLI which can be designated when running commands using the `--as-user` argument +or using the `default-user` config variable. + +When running a command, the config file is loaded into a `CLIConfig` object stored under the `CLI.config` field. +This object allows various parts of the CLI to access the current user, the configured token, and any other CLI config values by name. + +The logic for the interactive prompt and the logic for storing the CLI configuration can be found in the +`configuration` package. + +## OAuth Authentication + +In addition to allowing users to configure a token manually, they can automatically generate a CLI token under their account using +an OAuth workflow. This workflow uses the [Linode OAuth API](https://www.linode.com/docs/api/#oauth) to generate a temporary token, +which is then used to generate a long-term token stored in the CLI config file. + +The OAuth client ID is hardcoded and references a client under an officially managed Linode account. + +The rough steps of this OAuth workflow are as follows: + +1. The CLI checks whether a browser can be opened. If not, manually prompt the user for a token and skip. +2. Open a local HTTP server on an arbitrary port that exposes `oauth-landing-page.html`. This will also extract the token from the callback. +3. Open the user's browser to the OAuth URL with the hardcoded client ID and the callback URL pointing to the local webserver. +4. Once the user authorizes the OAuth application, they will be redirected to the local webserver where the temporary token will be extracted. +5. With the extracted token, a new token is generated with the default callback and a name similar to `Linode CLI @ localhost`. + +All the logic for OAuth token generation is stored in the `configuration/auth.py` file. + +## Outputs + +The Linode CLI uses the [Rich Python package](https://rich.readthedocs.io/en/latest/) to render tables, colorize text, +and handle other complex terminal output operations. + +## Output Overrides + +For special cases where the desired output may not be possible using OpenAPI spec extensions alone, developers +can implement special override functions that are given the output JSON and print a custom output to stdout. + +These overrides are specified using the `@output_override` decorator and can be found in the `overrides.py` file. + +## Command Completions + +The Linode CLI allows users to dynamically generate shell completions for the Bash and Fish shells. +This works by rendering hardcoded templates for each baked/generated command. + +See `completion.py` for more details. + +## Next Steps + +To continue to the next step of this guide, continue to the [Skeleton page](./Development%20-%20Skeleton). diff --git a/wiki/development/Development - Setup.md b/wiki/development/Development - Setup.md new file mode 100644 index 000000000..b789d82bb --- /dev/null +++ b/wiki/development/Development - Setup.md @@ -0,0 +1,86 @@ +The following guide outlines to the process for setting up the Linode CLI for development. + +## Cloning the Repository + +The Linode CLI repository can be cloned locally using the following command: + +```bash +git clone git@github.com:linode/linode-cli.git +``` + +If you do not have an SSH key configured, you can alternatively use the following command: + +```bash +git clone https://github.com/linode/linode-cli.git +``` + +## Configuring a VirtualEnv (recommended) + +A virtual env allows you to create virtual Python environment which can prevent potential +Python dependency conflicts. + +To create a VirtualEnv, run the following: + +```bash +python3 -m venv .venv +``` + +To enter the VirtualEnv, run the following command (NOTE: This needs to be run every time you open your shell): + +```bash +source .venv/bin/activate +``` + +## Installing Project Dependencies + +All Linode CLI Python requirements can be installed by running the following command: + +```bash +make requirements +``` + +## Building and Installing the Project + +The Linode CLI can be built and installed using the `make install` target: + +```bash +make install +``` + +Alternatively you can build but not install the CLI using the `make build` target: + +```bash +make build +``` + +Optionally you can validate that you have installed a local version of the CLI using the `linode-cli --version` command: + +```bash +linode-cli --version + +# Output: +# linode-cli 0.0.0 +# Built off spec version 4.173.0 +# +# The 0.0.0 implies this is a locally built version of the CLI +``` + +## Building Using a Custom OpenAPI Specification + +In some cases, you may want to build the CLI using a custom or modified OpenAPI specification. + +This can be achieved using the `SPEC` Makefile argument, for example: + +```bash +# Download the OpenAPI spec +curl -o openapi.yaml https://raw.githubusercontent.com/linode/linode-api-docs/development/openapi.yaml + +# Many arbitrary changes to the spec + +# Build & install the CLI using the modified spec +make SPEC=$PWD/openapi.yaml install +``` + +## Next Steps + +To continue to the next step of this guide, continue to the [Testing page](./Development%20-%20Testing). \ No newline at end of file diff --git a/wiki/development/Development - Skeleton.md b/wiki/development/Development - Skeleton.md new file mode 100644 index 000000000..b47d4df7c --- /dev/null +++ b/wiki/development/Development - Skeleton.md @@ -0,0 +1,30 @@ +The following section outlines the purpose of each file in the CLI. + +* `linode-cli` + * `baked` + * `__init__.py` - Contains imports for certain classes in this package + * `colors.py` - Contains logic for colorizing strings in CLI outputs (deprecated) + * `operation.py` - Contains the logic to parse an `OpenAPIOperation` from the OpenAPI spec and generate/execute a corresponding argparse parser + * `request.py` - Contains the `OpenAPIRequest` and `OpenAPIRequestArg` classes + * `response.py` - Contains `OpenAPIResponse` and `OpenAPIResponseAttr` classes + * `configuration` + * `__init__.py` - Contains the `CLIConfig` class and the logic for the interactive configuration prompt + * `auth.py` - Contains all the logic for the token generation OAuth workflow + * `helpers.py` - Contains various config-related helpers + * `plugins` + * `__init__.py` - Contains the shared wrapper that allows plugins to access CLI functionality + * `__init__.py` - Contains the main entrypoint for the CLI; routes top-level commands to their corresponding functions + * `__main__.py` - Calls the project entrypoint in `__init__.py` + * `api_request.py` - Contains logic for building API request bodies, making API requests, and handling API responses/errors + * `arg_helpers.py` - Contains miscellaneous logic for registering common argparse arguments and loading the OpenAPI spec + * `cli.py` - Contains the `CLI` class, which routes all the logic baking, loading, executing, and outputting generated CLI commands + * `completion.py` - Contains all the logic for generating shell completion files (`linode-cli completion`) + * `helpers.py` - Contains various miscellaneous helpers, especially relating to string manipulation, etc. + * `oauth-landing-page.html` - The page to show users in their browser when the OAuth workflow is complete. + * `output.py` - Contains all the logic for handling generated command outputs, including formatting tables, filtering JSON, etc. + * `overrides.py` - Contains hardcoded output override functions for select CLI commands. + + +## Next Steps + +To continue to the next step of this guide, continue to the [Setup page](./Development%20-%20Setup). \ No newline at end of file diff --git a/wiki/Testing.md b/wiki/development/Development - Testing.md similarity index 70% rename from wiki/Testing.md rename to wiki/development/Development - Testing.md index 578b8e43e..c97c04b46 100644 --- a/wiki/Testing.md +++ b/wiki/development/Development - Testing.md @@ -1,10 +1,12 @@ -# Testing +This page gives an overview of how to run the various test suites for the Linode CLI. -**WARNING!** Running the CLI tests will remove all linodes and data associated -with the account. It is only recommended to run these tests if you are an advanced -user. +Before running any tests, built and installed the Linode CLI with your changes using `make install`. -## Running the Tests +## Running Unit Tests + +Unit tests can be run using the `make testunit` Makefile target. + +## Running Integration Tests Running the tests locally is simple. The only requirements are that you export Linode API token as `LINODE_CLI_TOKEN`:: ```bash From fc013dabfacf7991ccb79d1de543f50b21c8837d Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 10:15:21 -0400 Subject: [PATCH 07/23] new: Improve generated command help page formatting using Rich (#585) --- linodecli/__init__.py | 7 +- linodecli/arg_helpers.py | 174 ------------------- linodecli/baked/request.py | 27 ++- linodecli/help_pages.py | 307 +++++++++++++++++++++++++++++++++ linodecli/helpers.py | 24 --- tests/unit/test_arg_helpers.py | 100 ----------- tests/unit/test_help_pages.py | 161 +++++++++++++++++ tests/unit/test_helpers.py | 15 +- 8 files changed, 496 insertions(+), 319 deletions(-) create mode 100644 linodecli/help_pages.py create mode 100644 tests/unit/test_help_pages.py diff --git a/linodecli/__init__.py b/linodecli/__init__.py index a5af35c6b..7116e3410 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -15,9 +15,7 @@ from linodecli import plugins from .arg_helpers import ( - action_help, bake_command, - help_with_ops, register_args, register_plugin, remove_plugin, @@ -25,6 +23,7 @@ from .cli import CLI from .completion import bake_completions, get_completions from .configuration import ENV_TOKEN_NAME +from .help_pages import print_help_action, print_help_default from .helpers import handle_url_overrides from .output import OutputMode @@ -155,7 +154,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements # handle a help for the CLI if parsed.command is None or (parsed.command is None and parsed.help): parser.print_help() - help_with_ops(cli.ops, cli.config) + print_help_default(cli.ops, cli.config) sys.exit(0) # configure @@ -257,6 +256,6 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if parsed.command is not None and parsed.action is not None: if parsed.help: - action_help(cli, parsed.command, parsed.action) + print_help_action(cli, parsed.command, parsed.action) sys.exit(0) cli.handle_command(parsed.command, parsed.action, args) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 6add0c7cc..a605b5b0f 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -5,14 +5,10 @@ import os import sys -import textwrap from importlib import import_module import requests import yaml -from rich import box -from rich import print as rprint -from rich.table import Table from linodecli import plugins @@ -247,176 +243,6 @@ def remove_plugin(plugin_name, config): return f"Plugin {plugin_name} removed", 0 -# pylint: disable=too-many-locals -def help_with_ops(ops, config): - """ - Prints help output with options from the API spec - """ - - # Environment variables overrides - print("\nEnvironment variables:") - env_variables = { - "LINODE_CLI_TOKEN": "A Linode Personal Access Token for the CLI to make requests with. " - "If specified, the configuration step will be skipped.", - "LINODE_CLI_CA": "The path to a custom Certificate Authority file to verify " - "API requests against.", - "LINODE_CLI_API_HOST": "Overrides the target host for API requests. " - "(e.g. 'api.linode.com')", - "LINODE_CLI_API_VERSION": "Overrides the target Linode API version for API requests. " - "(e.g. 'v4beta')", - "LINODE_CLI_API_SCHEME": "Overrides the target scheme used for API requests. " - "(e.g. 'https')", - } - - table = Table(show_header=True, header_style="", box=box.SQUARE) - table.add_column("Name") - table.add_column("Description") - - for k, v in env_variables.items(): - table.add_row(k, v) - - rprint(table) - - # commands to manage CLI users (don't call out to API) - print("\nCLI user management commands:") - um_commands = [["configure", "set-user", "show-users"], ["remove-user"]] - table = Table(show_header=False) - for cmd in um_commands: - table.add_row(*cmd) - rprint(table) - - # commands to manage plugins (don't call out to API) - print("\nCLI Plugin management commands:") - pm_commands = [["register-plugin", "remove-plugin"]] - table = Table(show_header=False) - for cmd in pm_commands: - table.add_row(*cmd) - rprint(table) - - # other CLI commands - print("\nOther CLI commands:") - other_commands = [["completion"]] - table = Table(show_header=False) - for cmd in other_commands: - table.add_row(*cmd) - rprint(table) - - # commands generated from the spec (call the API directly) - print("\nAvailable commands:") - - content = list(sorted(ops.keys())) - proc = [] - for i in range(0, len(content), 3): - proc.append(content[i : i + 3]) - if content[i + 3 :]: - proc.append(content[i + 3 :]) - - table = Table(show_header=False) - for cmd in proc: - table.add_row(*cmd) - rprint(table) - - # plugins registered to the CLI (do arbitrary things) - if plugins.available(config): - # only show this if there are any available plugins - print("Available plugins:") - - plugin_content = list(plugins.available(config)) - plugin_proc = [] - - for i in range(0, len(plugin_content), 3): - plugin_proc.append(plugin_content[i : i + 3]) - if plugin_content[i + 3 :]: - plugin_proc.append(plugin_content[i + 3 :]) - - plugin_table = Table(show_header=False) - for plugin in plugin_proc: - plugin_table.add_row(*plugin) - rprint(plugin_table) - - print("\nTo reconfigure, call `linode-cli configure`") - print( - "For comprehensive documentation," - "visit https://www.linode.com/docs/api/" - ) - - -def action_help(cli, command, action): # pylint: disable=too-many-branches - """ - Prints help relevant to the command and action - """ - try: - op = cli.find_operation(command, action) - except ValueError: - return - print(f"linode-cli {command} {action}", end="") - - for param in op.params: - pname = param.name.upper() - print(f" [{pname}]", end="") - - print() - print(op.summary) - - if op.docs_url: - rprint(f"API Documentation: [link={op.docs_url}]{op.docs_url}[/link]") - - if len(op.samples) > 0: - print() - print(f"Example Usage{'s' if len(op.samples) > 1 else ''}: ") - - rprint( - *[ - # Indent all samples for readability; strip and trailing newlines - textwrap.indent(v.get("source").rstrip(), " ") - for v in op.samples - ], - sep="\n\n", - ) - - print() - if op.method == "get" and op.action == "list": - filterable_attrs = [ - attr for attr in op.response_model.attrs if attr.filterable - ] - - if filterable_attrs: - print("You may filter results with:") - for attr in filterable_attrs: - print(f" --{attr.name}") - print( - "Additionally, you may order results using --order-by and --order." - ) - return - if op.args: - print("Arguments:") - for arg in sorted(op.args, key=lambda s: not s.required): - if arg.read_only: - continue - is_required = ( - "(required) " - if op.method in {"post", "put"} and arg.required - else "" - ) - - extensions = [] - - if arg.format == "json": - extensions.append("JSON") - - if arg.nullable: - extensions.append("nullable") - - if arg.is_parent: - extensions.append("conflicts with children") - - suffix = ( - f" ({', '.join(extensions)})" if len(extensions) > 0 else "" - ) - - print(f" --{arg.path}: {is_required}{arg.description}{suffix}") - - def bake_command(cli, spec_loc): """ Handle a bake command from args diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 450ba25b6..5886b7317 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -16,6 +16,7 @@ def __init__( prefix=None, is_parent=False, parent=None, + depth=0, ): # pylint: disable=too-many-arguments """ Parses a single Schema node into a argument the CLI can use when making @@ -33,6 +34,8 @@ def __init__( :type is_parent: bool :param parent: If applicable, the path to the parent list for this argument. :type parent: Optional[str] + :param depth: The depth of this argument, or how many parent arguments this argument has. + :type depth: int """ #: The name of this argument, mostly used for display and docs self.name = name @@ -85,6 +88,10 @@ def __init__( #: e.g. --interfaces.ipv4.nat_1_1 self.parent = parent + #: The depth of this argument, or how many parent arguments this argument has. + #: This is useful when formatting help pages. + self.depth = depth + #: The path of the path element in the schema. self.prefix = prefix @@ -103,7 +110,7 @@ def __init__( ) -def _parse_request_model(schema, prefix=None, parent=None): +def _parse_request_model(schema, prefix=None, parent=None, depth=0): """ Parses a schema into a list of OpenAPIRequest objects :param schema: The schema to parse as a request model @@ -130,6 +137,9 @@ def _parse_request_model(schema, prefix=None, parent=None): v, prefix=pref, parent=parent, + # NOTE: We do not increment the depth because dicts do not have + # parent arguments. + depth=depth, ) elif ( v.type == "array" @@ -150,10 +160,16 @@ def _parse_request_model(schema, prefix=None, parent=None): prefix=prefix, is_parent=True, parent=parent, + depth=depth, ) ) - args += _parse_request_model(v.items, prefix=pref, parent=pref) + args += _parse_request_model( + v.items, + prefix=pref, + parent=pref, + depth=depth + 1, + ) else: # required fields are defined in the schema above the property, so # we have to check here if required fields are defined/if this key @@ -163,7 +179,12 @@ def _parse_request_model(schema, prefix=None, parent=None): required = k in schema.required args.append( OpenAPIRequestArg( - k, v, required, prefix=prefix, parent=parent + k, + v, + required, + prefix=prefix, + parent=parent, + depth=depth, ) ) diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py new file mode 100644 index 000000000..ecf27ae87 --- /dev/null +++ b/linodecli/help_pages.py @@ -0,0 +1,307 @@ +""" +This module contains various helper functions related to outputting +help pages. +""" + +import re +import textwrap +from collections import defaultdict +from typing import List, Optional + +from rich import box +from rich import print as rprint +from rich.console import Console +from rich.padding import Padding +from rich.table import Table + +from linodecli import plugins +from linodecli.baked import OpenAPIOperation +from linodecli.baked.request import OpenAPIRequestArg + +HELP_ENV_VARS = { + "LINODE_CLI_TOKEN": "A Linode Personal Access Token for the CLI to make requests with. " + "If specified, the configuration step will be skipped.", + "LINODE_CLI_CA": "The path to a custom Certificate Authority file to verify " + "API requests against.", + "LINODE_CLI_API_HOST": "Overrides the target host for API requests. " + "(e.g. 'api.linode.com')", + "LINODE_CLI_API_VERSION": "Overrides the target Linode API version for API requests. " + "(e.g. 'v4beta')", + "LINODE_CLI_API_SCHEME": "Overrides the target scheme used for API requests. " + "(e.g. 'https')", +} + + +# pylint: disable=too-many-locals +def print_help_default(ops, config): + """ + Prints help output with options from the API spec + """ + + # Environment variables overrides + print("\nEnvironment variables:") + + table = Table(show_header=True, header_style="", box=box.SQUARE) + table.add_column("Name") + table.add_column("Description") + + for k, v in HELP_ENV_VARS.items(): + table.add_row(k, v) + + rprint(table) + + # commands to manage CLI users (don't call out to API) + print("\nCLI user management commands:") + um_commands = [["configure", "set-user", "show-users"], ["remove-user"]] + table = Table(show_header=False) + for cmd in um_commands: + table.add_row(*cmd) + rprint(table) + + # commands to manage plugins (don't call out to API) + print("\nCLI Plugin management commands:") + pm_commands = [["register-plugin", "remove-plugin"]] + table = Table(show_header=False) + for cmd in pm_commands: + table.add_row(*cmd) + rprint(table) + + # other CLI commands + print("\nOther CLI commands:") + other_commands = [["completion"]] + table = Table(show_header=False) + for cmd in other_commands: + table.add_row(*cmd) + rprint(table) + + # commands generated from the spec (call the API directly) + print("\nAvailable commands:") + + content = list(sorted(ops.keys())) + proc = [] + for i in range(0, len(content), 3): + proc.append(content[i : i + 3]) + if content[i + 3 :]: + proc.append(content[i + 3 :]) + + table = Table(show_header=False) + for cmd in proc: + table.add_row(*cmd) + rprint(table) + + # plugins registered to the CLI (do arbitrary things) + if plugins.available(config): + # only show this if there are any available plugins + print("Available plugins:") + + plugin_content = list(plugins.available(config)) + plugin_proc = [] + + for i in range(0, len(plugin_content), 3): + plugin_proc.append(plugin_content[i : i + 3]) + if plugin_content[i + 3 :]: + plugin_proc.append(plugin_content[i + 3 :]) + + plugin_table = Table(show_header=False) + for plugin in plugin_proc: + plugin_table.add_row(*plugin) + rprint(plugin_table) + + print("\nTo reconfigure, call `linode-cli configure`") + print( + "For comprehensive documentation, " + "visit https://www.linode.com/docs/api/" + ) + + +def print_help_action( + cli: "CLI", command: Optional[str], action: Optional[str] +): + """ + Prints help relevant to the command and action + """ + try: + op = cli.find_operation(command, action) + except ValueError: + return + + console = Console(highlight=False) + + console.print(f"[bold]linode-cli {command} {action}[/]", end="") + + for param in op.params: + pname = param.name.upper() + console.print(f" [{pname}]", end="") + + console.print() + console.print(f"[bold]{op.summary}[/]") + + if op.docs_url: + console.print( + f"[bold]API Documentation: [link={op.docs_url}]{op.docs_url}[/link][/]" + ) + + if len(op.samples) > 0: + console.print() + console.print( + f"[bold]Example Usage{'s' if len(op.samples) > 1 else ''}: [/]" + ) + + console.print( + *[ + # Indent all samples for readability; strip and trailing newlines + textwrap.indent(v.get("source").rstrip(), " ") + for v in op.samples + ], + sep="\n\n", + highlight=True, + ) + + console.print() + + if op.method == "get" and op.action == "list": + _help_action_print_filter_args(console, op) + return + + if op.args: + _help_action_print_body_args(console, op) + + +def _help_action_print_filter_args(console: Console, op: OpenAPIOperation): + """ + Pretty-prints all the filter (GET) arguments for this operation. + """ + + filterable_attrs = [ + attr for attr in op.response_model.attrs if attr.filterable + ] + + if filterable_attrs: + console.print("[bold]You may filter results with:[/]") + for attr in filterable_attrs: + console.print(f" [bold magenta]--{attr.name}[/]") + + console.print( + "\nAdditionally, you may order results using --order-by and --order." + ) + + +def _help_action_print_body_args( + console: Console, + op: OpenAPIOperation, +): + """ + Pretty-prints all the body (POST/PUT) arguments for this operation. + """ + console.print("[bold]Arguments:[/]") + + for group in _help_group_arguments(op.args): + for arg in group: + metadata = [] + + if op.method in {"post", "put"} and arg.required: + metadata.append("required") + + if arg.format == "json": + metadata.append("JSON") + + if arg.nullable: + metadata.append("nullable") + + if arg.is_parent: + metadata.append("conflicts with children") + + prefix = f" ({', '.join(metadata)})" if len(metadata) > 0 else "" + + description = _markdown_links_to_rich( + arg.description.replace("\n", " ").replace("\r", " ") + ) + + arg_str = ( + f"[bold magenta]--{arg.path}[/][bold]{prefix}[/]: {description}" + ) + + console.print(Padding.indent(arg_str.rstrip(), (arg.depth * 2) + 2)) + + console.print() + + +def _help_group_arguments( + args: List[OpenAPIRequestArg], +) -> List[List[OpenAPIRequestArg]]: + """ + Returns help page groupings for a list of POST/PUT arguments. + """ + args_sorted = sorted(args, key=lambda a: a.path) + + groups_tmp = defaultdict(list) + + # Initial grouping by root parent + for arg in args_sorted: + if arg.read_only: + continue + + groups_tmp[arg.path.split(".", 1)[0]].append(arg) + + group_required = [] + groups = [] + ungrouped = [] + + for group in groups_tmp.values(): + # If the group has more than one element, + # leave it as is in the result + if len(group) > 1: + groups.append( + # Required arguments should come first in groups + sorted(group, key=lambda v: not v.required), + ) + continue + + target_arg = group[0] + + # If the group's argument is required, + # add it to the required group + if target_arg.required: + group_required.append(target_arg) + continue + + # Add ungrouped arguments (single value groups) to the + # "ungrouped" group. + ungrouped.append(target_arg) + + result = [] + + if len(group_required) > 0: + result.append(group_required) + + if len(ungrouped) > 0: + result.append(ungrouped) + + result += groups + + return result + + +def _markdown_links_to_rich(text): + """ + Returns the given text with Markdown links converted to Rich-compatible links. + """ + + result = text + + # Find all Markdown links + r = re.compile(r"\[(?P.*?)]\((?P.*?)\)") + + for match in r.finditer(text): + url = match.group("link") + + # Expand the URL if necessary + if url.startswith("/"): + url = f"https://linode.com{url}" + + # Replace with more readable text + result = result.replace( + match.group(), f"{match.group('text')} ([link={url}]{url}[/link])" + ) + + return result diff --git a/linodecli/helpers.py b/linodecli/helpers.py index bb2a5288f..9099cb22e 100644 --- a/linodecli/helpers.py +++ b/linodecli/helpers.py @@ -4,7 +4,6 @@ import glob import os -import re from argparse import ArgumentParser from pathlib import Path from urllib.parse import urlparse @@ -40,29 +39,6 @@ def handle_url_overrides( ).geturl() -def filter_markdown_links(text): - """ - Returns the given text with Markdown links converted to human-readable links. - """ - - result = text - - # Find all Markdown links - r = re.compile(r"\[(?P.*?)]\((?P.*?)\)") - - for match in r.finditer(text): - url = match.group("link") - - # Expand the URL if necessary - if url.startswith("/"): - url = f"https://linode.com{url}" - - # Replace with more readable text - result = result.replace(match.group(), f"{match.group('text')} ({url})") - - return result - - def pagination_args_shared(parser: ArgumentParser): """ Add pagination related arguments to the given diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index e934158f0..9b4fb55f1 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -151,106 +151,6 @@ def test_remove_plugin_not_available(self, mocked_config): assert "not a registered plugin" in msg assert code == 14 - # arg_helpers.help_with_ops(ops, config) - def test_help_with_ops(self, capsys, mocked_config): - mock_ops = {"testkey1": "testvalue1"} - arg_helpers.help_with_ops(mock_ops, mocked_config) - captured = capsys.readouterr() - assert "testkey1" in captured.out - - def test_help_with_ops_with_plugins(self, capsys, mocker, mocked_config): - mock_ops = {"testkey1": "testvalue1"} - mocker.patch( - "linodecli.arg_helpers.plugins.available", - return_value=["testing.plugin"], - ) - arg_helpers.help_with_ops(mock_ops, mocked_config) - captured = capsys.readouterr() - assert "testing.plugin" in captured.out - - # arg_helpers.action_help(cli, command, action) - def test_action_help_value_error(self, capsys, mock_cli): - arg_helpers.action_help(mock_cli, None, None) - captured = capsys.readouterr() - assert not captured.out - - def test_action_help_post_method(self, capsys, mocker, mock_cli): - mocked_ops = mocker.MagicMock() - mocked_ops.summary = "test summary" - mocked_ops.docs_url = "https://website.com/endpoint" - mocked_ops.method = "post" - mocked_ops.samples = [ - {"lang": "CLI", "source": "linode-cli command action\n --foo=bar"}, - {"lang": "CLI", "source": "linode-cli command action\n --bar=foo"}, - ] - - mocked_ops.args = [ - mocker.MagicMock( - read_only=False, - required=True, - path="path", - description="test description", - ), - mocker.MagicMock( - read_only=False, - required=False, - path="path2", - description="test description 2", - format="json", - nullable=True, - ), - ] - - mock_cli.find_operation = mocker.Mock(return_value=mocked_ops) - - arg_helpers.action_help(mock_cli, "command", "action") - captured = capsys.readouterr() - - assert "test summary" in captured.out - assert "API Documentation" in captured.out - assert "https://website.com/endpoint" in captured.out - assert ( - "Example Usages: \n" - " linode-cli command action\n" - " --foo=bar\n\n" - " linode-cli command action\n" - " --bar=foo\n\n" - ) in captured.out - assert "Arguments" in captured.out - assert "test description" in captured.out - assert "test description 2" in captured.out - assert "(required)" in captured.out - assert "(JSON, nullable, conflicts with children)" in captured.out - assert "filter results" not in captured.out - - def test_action_help_get_method(self, capsys, mocker, mock_cli): - mocked_ops = mocker.MagicMock() - mocked_ops.summary = "test summary" - mocked_ops.docs_url = "https://website.com/endpoint" - mocked_ops.method = "get" - mocked_ops.action = "list" - mocked_ops.args = None - mocked_ops.samples = [ - {"lang": "CLI", "source": "linode-cli command action"} - ] - - mock_attr = mocker.MagicMock() - mock_attr.filterable = True - mock_attr.name = "filtername" - mocked_ops.response_model.attrs = [mock_attr] - - mock_cli.find_operation = mocker.Mock(return_value=mocked_ops) - - arg_helpers.action_help(mock_cli, "command", "action") - captured = capsys.readouterr() - assert "test summary" in captured.out - assert "API Documentation" in captured.out - assert "https://website.com/endpoint" in captured.out - assert "Example Usage: \n linode-cli command action" in captured.out - assert "Arguments" not in captured.out - assert "filter results" in captured.out - assert "filtername" in captured.out - def test_bake_command_bad_website(self, capsys, mock_cli): with pytest.raises(SystemExit) as ex: arg_helpers.bake_command(mock_cli, "https://website.com") diff --git a/tests/unit/test_help_pages.py b/tests/unit/test_help_pages.py new file mode 100644 index 000000000..c315618e8 --- /dev/null +++ b/tests/unit/test_help_pages.py @@ -0,0 +1,161 @@ +from types import SimpleNamespace + +from linodecli import help_pages + + +class TestHelpPages: + def test_filter_markdown_links(self): + """ + Ensures that Markdown links are properly converted to their rich equivalents. + """ + + original_text = "Here's [a relative link](/docs/cool) and [an absolute link](https://cloud.linode.com)." + expected_text = ( + "Here's a relative link ([link=https://linode.com/docs/cool]https://linode.com/docs/cool[/link]) " + "and an absolute link ([link=https://cloud.linode.com]https://cloud.linode.com[/link])." + ) + + assert ( + help_pages._markdown_links_to_rich(original_text) == expected_text + ) + + def test_group_arguments(self, capsys): + # NOTE: We use SimpleNamespace here so we can do deep comparisons using == + args = [ + SimpleNamespace( + read_only=False, + required=True, + path="foo", + ), + SimpleNamespace(read_only=False, required=False, path="foo.bar"), + SimpleNamespace(read_only=False, required=False, path="foobaz"), + SimpleNamespace(read_only=False, required=False, path="foo.foo"), + SimpleNamespace(read_only=False, required=False, path="foobar"), + SimpleNamespace(read_only=False, required=True, path="barfoo"), + ] + + expected = [ + [ + SimpleNamespace(read_only=False, required=True, path="barfoo"), + ], + [ + SimpleNamespace(read_only=False, required=False, path="foobar"), + SimpleNamespace(read_only=False, required=False, path="foobaz"), + ], + [ + SimpleNamespace( + read_only=False, + required=True, + path="foo", + ), + SimpleNamespace( + read_only=False, required=False, path="foo.bar" + ), + SimpleNamespace( + read_only=False, required=False, path="foo.foo" + ), + ], + ] + + assert help_pages._help_group_arguments(args) == expected + + def test_action_help_get_method(self, capsys, mocker, mock_cli): + mocked_ops = mocker.MagicMock() + mocked_ops.summary = "test summary" + mocked_ops.docs_url = "https://website.com/endpoint" + mocked_ops.method = "get" + mocked_ops.action = "list" + mocked_ops.args = None + mocked_ops.samples = [ + {"lang": "CLI", "source": "linode-cli command action"} + ] + + mock_attr = mocker.MagicMock() + mock_attr.filterable = True + mock_attr.name = "filtername" + mocked_ops.response_model.attrs = [mock_attr] + + mock_cli.find_operation = mocker.Mock(return_value=mocked_ops) + + help_pages.print_help_action(mock_cli, "command", "action") + captured = capsys.readouterr() + assert "test summary" in captured.out + assert "API Documentation" in captured.out + assert "https://website.com/endpoint" in captured.out + assert "Example Usage: \n linode-cli command action" in captured.out + assert "Arguments" not in captured.out + assert "filter results" in captured.out + assert "filtername" in captured.out + + def test_help_with_ops(self, capsys, mocked_config): + mock_ops = {"testkey1": "testvalue1"} + help_pages.print_help_default(mock_ops, mocked_config) + captured = capsys.readouterr() + assert "testkey1" in captured.out + + def test_help_with_ops_with_plugins(self, capsys, mocker, mocked_config): + mock_ops = {"testkey1": "testvalue1"} + mocker.patch( + "linodecli.arg_helpers.plugins.available", + return_value=["testing.plugin"], + ) + help_pages.print_help_default(mock_ops, mocked_config) + captured = capsys.readouterr() + assert "testing.plugin" in captured.out + + # arg_helpers.print_help_action(cli, command, action) + def test_action_help_value_error(self, capsys, mock_cli): + help_pages.print_help_action(mock_cli, None, None) + captured = capsys.readouterr() + assert not captured.out + + def test_action_help_post_method(self, capsys, mocker, mock_cli): + mocked_ops = mocker.MagicMock() + mocked_ops.summary = "test summary" + mocked_ops.docs_url = "https://website.com/endpoint" + mocked_ops.method = "post" + mocked_ops.samples = [ + {"lang": "CLI", "source": "linode-cli command action\n --foo=bar"}, + {"lang": "CLI", "source": "linode-cli command action\n --bar=foo"}, + ] + + mocked_ops.args = [ + mocker.MagicMock( + read_only=False, + required=True, + path="path", + description="test description", + depth=0, + ), + mocker.MagicMock( + read_only=False, + required=False, + path="path2", + description="test description 2", + format="json", + nullable=True, + depth=0, + ), + ] + + mock_cli.find_operation = mocker.Mock(return_value=mocked_ops) + + help_pages.print_help_action(mock_cli, "command", "action") + captured = capsys.readouterr() + + assert "test summary" in captured.out + assert "API Documentation" in captured.out + assert "https://website.com/endpoint" in captured.out + assert ( + "Example Usages: \n" + " linode-cli command action\n" + " --foo=bar\n\n" + " linode-cli command action\n" + " --bar=foo\n\n" + ) in captured.out + assert "Arguments" in captured.out + assert "test description" in captured.out + assert "test description 2" in captured.out + assert "(required, nullable, conflicts with children)" in captured.out + assert "(JSON, nullable, conflicts with children)" in captured.out + assert "filter results" not in captured.out diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index dc0b4c923..433001eaa 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -1,10 +1,6 @@ from argparse import ArgumentParser -from linodecli.helpers import ( - filter_markdown_links, - pagination_args_shared, - register_args_shared, -) +from linodecli.helpers import pagination_args_shared, register_args_shared class TestHelpers: @@ -12,15 +8,6 @@ class TestHelpers: Unit tests for linodecli.helpers """ - def test_markdown_links(self): - original_text = "Here's [a relative link](/docs/cool) and [an absolute link](https://cloud.linode.com)." - expected_text = ( - "Here's a relative link (https://linode.com/docs/cool) " - "and an absolute link (https://cloud.linode.com)." - ) - - assert filter_markdown_links(original_text) == expected_text - def test_pagination_args_shared(self): parser = ArgumentParser() pagination_args_shared(parser) From f106959396db146def3cf320732ecbbe5dc4a0de Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 15:43:11 -0400 Subject: [PATCH 08/23] Move listing functions of the obj plugin to its own files (#587) --- linodecli/plugins/obj/__init__.py | 177 +---------------------- linodecli/plugins/obj/helpers.py | 17 +++ linodecli/plugins/obj/list.py | 168 +++++++++++++++++++++ tests/integration/obj/test_obj_plugin.py | 7 +- 4 files changed, 189 insertions(+), 180 deletions(-) create mode 100644 linodecli/plugins/obj/list.py diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 5ba3f0e6d..ea0279873 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -3,10 +3,7 @@ CLI Plugin for handling OBJ """ import getpass -import glob -import math import os -import platform import socket import sys import time @@ -14,7 +11,7 @@ from contextlib import suppress from datetime import datetime from math import ceil -from typing import Iterable, List +from typing import List from rich import print as rprint from rich.table import Table @@ -22,7 +19,6 @@ from linodecli.cli import CLI from linodecli.configuration import _do_get_request from linodecli.configuration.helpers import _default_thing_input -from linodecli.helpers import expand_globs, pagination_args_shared from linodecli.plugins import PluginContext, inherit_plugin_args from linodecli.plugins.obj.buckets import create_bucket, delete_bucket from linodecli.plugins.obj.config import ( @@ -42,12 +38,10 @@ from linodecli.plugins.obj.helpers import ( ProgressPercentage, _borderless_table, - _convert_datetime, _denominate, _pad_to, - _progress, - restricted_int_arg_type, ) +from linodecli.plugins.obj.list import list_all_objects, list_objects_or_buckets from linodecli.plugins.obj.objects import ( delete_object, get_object, @@ -61,22 +55,12 @@ try: import boto3 - from boto3.exceptions import S3UploadFailedError - from boto3.s3.transfer import MB, TransferConfig from botocore.exceptions import ClientError HAS_BOTO = True except ImportError: HAS_BOTO = False -TRUNCATED_MSG = ( - "Notice: Not all results were shown. If your would " - "like to get more results, you can add the '--all-row' " - "flag to the command or use the built-in pagination flags." -) - -INVALID_PAGE_MSG = "No result to show in this page." - def get_available_cluster(cli: CLI): """Get list of possible clusters for the account""" @@ -90,117 +74,6 @@ def get_available_cluster(cli: CLI): ] -def flip_to_page(iterable: Iterable, page: int = 1): - """Given a iterable object and return a specific iteration (page)""" - iterable = iter(iterable) - for _ in range(page - 1): - try: - next(iterable) - except StopIteration: - print(INVALID_PAGE_MSG) - sys.exit(2) - - return next(iterable) - - -def list_objects_or_buckets( - get_client, args, **kwargs -): # pylint: disable=too-many-locals,unused-argument,too-many-branches - """ - Lists buckets or objects - """ - parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " ls")) - pagination_args_shared(parser) - - parser.add_argument( - "bucket", - metavar="NAME", - type=str, - nargs="?", - help=( - "Optional. If not given, lists all buckets. If given, " - "lists the contents of the given bucket. May contain a " - "/ followed by a directory path to show the contents of " - "a directory within the named bucket." - ), - ) - - parsed = parser.parse_args(args) - client = get_client() - - if parsed.bucket: - # list objects - if "/" in parsed.bucket: - bucket_name, prefix = parsed.bucket.split("/", 1) - if not prefix.endswith("/"): - prefix += "/" - else: - bucket_name = parsed.bucket - prefix = "" - - data = [] - objects = [] - sub_directories = [] - pages = client.get_paginator("list_objects_v2").paginate( - Prefix=prefix, - Bucket=bucket_name, - Delimiter="/", - PaginationConfig={"PageSize": parsed.page_size}, - ) - try: - if parsed.all_rows: - results = pages - else: - page = flip_to_page(pages, parsed.page) - if page.get("IsTruncated", False): - print(TRUNCATED_MSG) - - results = [page] - except client.exceptions.NoSuchBucket: - print("No bucket named " + bucket_name) - sys.exit(2) - - for item in results: - objects.extend(item.get("Contents", [])) - sub_directories.extend(item.get("CommonPrefixes", [])) - - for d in sub_directories: - data.append((" " * 16, "DIR", d.get("Prefix"))) - for obj in objects: - key = obj.get("Key") - - # This is to remove the dir itself from the results - # when the the files list inside a directory (prefix) are desired. - if key == prefix: - continue - - data.append( - ( - _convert_datetime(obj.get("LastModified")), - obj.get("Size"), - key, - ) - ) - - if data: - tab = _borderless_table(data) - rprint(tab) - - sys.exit(0) - else: - # list buckets - buckets = client.list_buckets().get("Buckets", []) - data = [ - [_convert_datetime(b.get("CreationDate")), b.get("Name")] - for b in buckets - ] - - tab = _borderless_table(data) - rprint(tab) - - sys.exit(0) - - def generate_url(get_client, args, **kwargs): # pylint: disable=unused-argument """ Generates a URL to an object @@ -368,52 +241,6 @@ def show_usage(get_client, args, **kwargs): # pylint: disable=unused-argument sys.exit(0) -def list_all_objects( - get_client, args, **kwargs -): # pylint: disable=unused-argument - """ - Lists all objects in all buckets - """ - # this is for printing help when --help is in the args - parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " la")) - pagination_args_shared(parser) - - parsed = parser.parse_args(args) - - client = get_client() - - buckets = [b["Name"] for b in client.list_buckets().get("Buckets", [])] - - for b in buckets: - print() - objects = [] - pages = client.get_paginator("list_objects_v2").paginate( - Bucket=b, PaginationConfig={"PageSize": parsed.page_size} - ) - if parsed.all_rows: - results = pages - else: - page = flip_to_page(pages, parsed.page) - if page.get("IsTruncated", False): - print(TRUNCATED_MSG) - - results = [page] - - for page in results: - objects.extend(page.get("Contents", [])) - - for obj in objects: - size = obj.get("Size", 0) - - print( - f"{_convert_datetime(obj['LastModified'])} " - f"{_pad_to(size, 9, right_align=True)} " - f"{b}/{obj['Key']}" - ) - - sys.exit(0) - - COMMAND_MAP = { "mb": create_bucket, "rb": delete_bucket, diff --git a/linodecli/plugins/obj/helpers.py b/linodecli/plugins/obj/helpers.py index e216a2156..96e80bc74 100644 --- a/linodecli/plugins/obj/helpers.py +++ b/linodecli/plugins/obj/helpers.py @@ -2,7 +2,9 @@ The helper functions for the object storage plugin. """ +import sys from argparse import ArgumentTypeError +from collections.abc import Iterable from datetime import datetime from rich.table import Table @@ -10,6 +12,8 @@ from linodecli.plugins.obj.config import DATE_FORMAT +INVALID_PAGE_MSG = "No result to show in this page." + class ProgressPercentage: # pylint: disable=too-few-public-methods """ @@ -126,3 +130,16 @@ def _borderless_table(data): tab.add_row(*row) return tab + + +def flip_to_page(iterable: Iterable, page: int = 1): + """Given a iterable object and return a specific iteration (page)""" + iterable = iter(iterable) + for _ in range(page - 1): + try: + next(iterable) + except StopIteration: + print(INVALID_PAGE_MSG) + sys.exit(2) + + return next(iterable) diff --git a/linodecli/plugins/obj/list.py b/linodecli/plugins/obj/list.py new file mode 100644 index 000000000..19fadbb7a --- /dev/null +++ b/linodecli/plugins/obj/list.py @@ -0,0 +1,168 @@ +""" +The module for list things in the object storage service. +""" + +import sys +from argparse import ArgumentParser + +from rich import print as rprint + +from linodecli.helpers import pagination_args_shared +from linodecli.plugins import inherit_plugin_args +from linodecli.plugins.obj.config import PLUGIN_BASE +from linodecli.plugins.obj.helpers import ( + _borderless_table, + _convert_datetime, + _pad_to, + flip_to_page, +) + +TRUNCATED_MSG = ( + "Notice: Not all results were shown. If your would " + "like to get more results, you can add the '--all-row' " + "flag to the command or use the built-in pagination flags." +) + + +def list_objects_or_buckets( + get_client, args, **kwargs +): # pylint: disable=too-many-locals,unused-argument,too-many-branches + """ + Lists buckets or objects + """ + parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " ls")) + pagination_args_shared(parser) + + parser.add_argument( + "bucket", + metavar="NAME", + type=str, + nargs="?", + help=( + "Optional. If not given, lists all buckets. If given, " + "lists the contents of the given bucket. May contain a " + "/ followed by a directory path to show the contents of " + "a directory within the named bucket." + ), + ) + + parsed = parser.parse_args(args) + client = get_client() + + if parsed.bucket: + # list objects + if "/" in parsed.bucket: + bucket_name, prefix = parsed.bucket.split("/", 1) + if not prefix.endswith("/"): + prefix += "/" + else: + bucket_name = parsed.bucket + prefix = "" + + data = [] + objects = [] + sub_directories = [] + pages = client.get_paginator("list_objects_v2").paginate( + Prefix=prefix, + Bucket=bucket_name, + Delimiter="/", + PaginationConfig={"PageSize": parsed.page_size}, + ) + try: + if parsed.all_rows: + results = pages + else: + page = flip_to_page(pages, parsed.page) + if page.get("IsTruncated", False): + print(TRUNCATED_MSG) + + results = [page] + except client.exceptions.NoSuchBucket: + print("No bucket named " + bucket_name) + sys.exit(2) + + for item in results: + objects.extend(item.get("Contents", [])) + sub_directories.extend(item.get("CommonPrefixes", [])) + + for d in sub_directories: + data.append((" " * 16, "DIR", d.get("Prefix"))) + for obj in objects: + key = obj.get("Key") + + # This is to remove the dir itself from the results + # when the the files list inside a directory (prefix) are desired. + if key == prefix: + continue + + data.append( + ( + _convert_datetime(obj.get("LastModified")), + obj.get("Size"), + key, + ) + ) + + if data: + tab = _borderless_table(data) + rprint(tab) + + sys.exit(0) + else: + # list buckets + buckets = client.list_buckets().get("Buckets", []) + data = [ + [_convert_datetime(b.get("CreationDate")), b.get("Name")] + for b in buckets + ] + + tab = _borderless_table(data) + rprint(tab) + + sys.exit(0) + + +def list_all_objects( + get_client, args, **kwargs +): # pylint: disable=unused-argument + """ + Lists all objects in all buckets + """ + # this is for printing help when --help is in the args + parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " la")) + pagination_args_shared(parser) + + parsed = parser.parse_args(args) + + client = get_client() + + buckets = [b["Name"] for b in client.list_buckets().get("Buckets", [])] + + for b in buckets: + print() + objects = [] + pages = client.get_paginator("list_objects_v2").paginate( + Bucket=b, PaginationConfig={"PageSize": parsed.page_size} + ) + if parsed.all_rows: + results = pages + else: + page = flip_to_page(pages, parsed.page) + if page.get("IsTruncated", False): + print(TRUNCATED_MSG) + + results = [page] + + for page in results: + objects.extend(page.get("Contents", [])) + + for obj in objects: + size = obj.get("Size", 0) + + print( + f"{_convert_datetime(obj['LastModified'])} " + f"{_pad_to(size, 9, right_align=True)} " + f"{b}/{obj['Key']}" + ) + + sys.exit(0) diff --git a/tests/integration/obj/test_obj_plugin.py b/tests/integration/obj/test_obj_plugin.py index 55bf6226f..3d04aa9e1 100644 --- a/tests/integration/obj/test_obj_plugin.py +++ b/tests/integration/obj/test_obj_plugin.py @@ -7,11 +7,8 @@ import requests from pytest import MonkeyPatch -from linodecli.plugins.obj import ( - ENV_ACCESS_KEY_NAME, - ENV_SECRET_KEY_NAME, - TRUNCATED_MSG, -) +from linodecli.plugins.obj import ENV_ACCESS_KEY_NAME, ENV_SECRET_KEY_NAME +from linodecli.plugins.obj.list import TRUNCATED_MSG from tests.integration.fixture_types import GetTestFilesType, GetTestFileType from tests.integration.helpers import count_lines, exec_test_command From 586645179fd08c03df8b205f754c5d664ad07a23 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 14 Mar 2024 11:56:55 -0400 Subject: [PATCH 09/23] fix: Resolve help page error for list commands with non-standard names (#588) --- linodecli/help_pages.py | 2 +- .../integration/domains/test_domain_records.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py index ecf27ae87..a82f39877 100644 --- a/linodecli/help_pages.py +++ b/linodecli/help_pages.py @@ -159,7 +159,7 @@ def print_help_action( console.print() - if op.method == "get" and op.action == "list": + if op.method == "get" and op.response_model.is_paginated: _help_action_print_filter_args(console, op) return diff --git a/tests/integration/domains/test_domain_records.py b/tests/integration/domains/test_domain_records.py index 18d184c63..b8ea8d867 100644 --- a/tests/integration/domains/test_domain_records.py +++ b/tests/integration/domains/test_domain_records.py @@ -195,3 +195,21 @@ def test_delete_a_domain_record(test_domain_and_record): # Assert on status code returned from deleting domain assert process.returncode == SUCCESS_STATUS_CODE + + +def test_help_records_list(test_domain_and_record): + process = exec_test_command( + BASE_CMD + + [ + "records-list", + "--help", + ] + ) + output = process.stdout.decode() + + assert "Domain Records List" in output + assert "You may filter results with:" in output + assert "--type" in output + assert "--name" in output + assert "--target" in output + assert "--tag" in output From e96e70cfa88e5039e0a93f36967bbb31df79be85 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 20 Mar 2024 10:45:48 -0400 Subject: [PATCH 10/23] doc: Clarify how list argument differentiates objects (#590) --- wiki/Usage.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wiki/Usage.md b/wiki/Usage.md index 0510b1380..acbbbd4ad 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -93,13 +93,17 @@ linode-cli linodes create --region us-east --type g6-nanode-1 --tags tag1 --tags ``` Lists consisting of nested structures can also be expressed through the command line. +Duplicated attribute will signal a different object. For example, to create a Linode with a public interface on `eth0` and a VLAN interface on `eth1` you can execute the following:: ```bash linode-cli linodes create \ --region us-east --type g6-nanode-1 --image linode/ubuntu22.04 \ --root_pass "myr00tp4ss123" \ + # The first interface (index 0) is defined with the public purpose --interfaces.purpose public \ + # The second interface (index 1) is defined with the vlan purpose. + # The duplicate `interfaces.purpose` here tells the CLI to start building a new interface object. --interfaces.purpose vlan --interfaces.label my-vlan ``` From 01c4e2f04506152090fc14a6018515d4ddd1cf46 Mon Sep 17 00:00:00 2001 From: Vinay <143587840+vshanthe@users.noreply.github.com> Date: Fri, 29 Mar 2024 10:15:57 +0530 Subject: [PATCH 11/23] Integration test (#589) Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- tests/integration/account/test_account.py | 238 ++++++++++++++++++ .../account/test_account_transfer.py | 23 -- tests/integration/beta/test_beta_program.py | 51 ++++ tests/integration/database/test_database.py | 113 +++++++++ tests/integration/helpers.py | 5 + 5 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 tests/integration/account/test_account.py delete mode 100644 tests/integration/account/test_account_transfer.py create mode 100644 tests/integration/beta/test_beta_program.py create mode 100644 tests/integration/database/test_database.py diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py new file mode 100644 index 000000000..d126e6b4c --- /dev/null +++ b/tests/integration/account/test_account.py @@ -0,0 +1,238 @@ +from tests.integration.helpers import assert_headers_in_lines, exec_test_command + +BASE_CMD = ["linode-cli", "account"] + + +def test_account_transfer(): + res = ( + exec_test_command(BASE_CMD + ["transfer", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["billable", "quota", "used"] + assert_headers_in_lines(headers, lines) + + +def test_region_availability(): + res = ( + exec_test_command( + BASE_CMD + + ["get-account-availability", "us-east", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["region", "unavailable"] + assert_headers_in_lines(headers, lines) + + +def test_event_list(): + res = ( + exec_test_command( + ["linode-cli", "events", "list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + event_id = lines[1].split(",")[0] + + headers = ["entity.label", "username"] + assert_headers_in_lines(headers, lines) + return event_id + + +def test_event_view(): + event_id = test_event_list() + res = ( + exec_test_command( + [ + "linode-cli", + "events", + "view", + event_id, + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id", "action"] + assert_headers_in_lines(headers, lines) + + +def test_account_invoice_list(): + res = ( + exec_test_command( + BASE_CMD + ["invoices-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + invoice_id = lines[1].split(",")[0] + + headers = ["billing_source", "tax", "subtotal"] + assert_headers_in_lines(headers, lines) + return invoice_id + + +def test_account_invoice_view(): + invoice_id = test_account_invoice_list() + res = ( + exec_test_command( + BASE_CMD + ["invoice-view", invoice_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["billing_source", "tax", "subtotal"] + assert_headers_in_lines(headers, lines) + + +def test_account_invoice_items(): + invoice_id = test_account_invoice_list() + res = ( + exec_test_command( + BASE_CMD + ["invoice-items", invoice_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "from", "to"] + assert_headers_in_lines(headers, lines) + + +def test_account_logins_list(): + res = ( + exec_test_command(BASE_CMD + ["logins-list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + login_id = lines[1].split(",")[0] + + headers = ["ip", "username", "status"] + assert_headers_in_lines(headers, lines) + return login_id + + +def test_account_login_view(): + login_id = test_account_logins_list() + res = ( + exec_test_command( + BASE_CMD + ["login-view", login_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["ip", "username", "status"] + assert_headers_in_lines(headers, lines) + + +def test_account_setting_view(): + res = ( + exec_test_command(BASE_CMD + ["settings", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["longview_subscription", "network_helper"] + assert_headers_in_lines(headers, lines) + + +def test_user_list(): + res = ( + exec_test_command( + ["linode-cli", "users", "list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + user_id = lines[1].split(",")[0] + + headers = ["email", "username"] + assert_headers_in_lines(headers, lines) + return user_id + + +def test_user_view(): + user_id = test_user_list() + res = ( + exec_test_command( + ["linode-cli", "users", "view", user_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["email", "username"] + assert_headers_in_lines(headers, lines) + + +def test_payment_method_list(): + res = ( + exec_test_command( + ["linode-cli", "payment-methods", "list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["type", "is_default"] + assert_headers_in_lines(headers, lines) + + +def test_payment_list(): + res = ( + exec_test_command( + BASE_CMD + ["payments-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["date", "usd"] + assert_headers_in_lines(headers, lines) + + +def test_service_transfers(): + res = ( + exec_test_command( + [ + "linode-cli", + "service-transfers", + "list", + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["token", "expiry", "is_sender"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/account/test_account_transfer.py b/tests/integration/account/test_account_transfer.py deleted file mode 100644 index 10b610ca9..000000000 --- a/tests/integration/account/test_account_transfer.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -import subprocess -from typing import List - -env = os.environ.copy() -env["COLUMNS"] = "200" - - -def exec_test_command(args: List[str]): - process = subprocess.run( - args, - stdout=subprocess.PIPE, - env=env, - ) - return process - - -def test_account_transfer(): - process = exec_test_command(["linode-cli", "account", "transfer"]) - output = process.stdout.decode() - assert "billable" in output - assert "quota" in output - assert "used" in output diff --git a/tests/integration/beta/test_beta_program.py b/tests/integration/beta/test_beta_program.py new file mode 100644 index 000000000..ae87f3b45 --- /dev/null +++ b/tests/integration/beta/test_beta_program.py @@ -0,0 +1,51 @@ +import pytest + +from tests.integration.helpers import assert_headers_in_lines, exec_test_command + +BASE_CMD = ["linode-cli", "betas"] + + +def test_beta_list(): + res = ( + exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + if len(lines) < 2 or len(lines[1].split(",")) == 0: + pytest.skip("No beta program available to test") + else: + beta_id = lines[1].split(",")[0] + headers = ["label", "description"] + assert_headers_in_lines(headers, lines) + return beta_id + + +def test_beta_view(): + beta_id = test_beta_list() + if beta_id is None: + pytest.skip("No beta program available to test") + else: + res = ( + exec_test_command( + BASE_CMD + ["view", beta_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "description"] + assert_headers_in_lines(headers, lines) + + +def test_beta_enrolled(): + res = ( + exec_test_command(BASE_CMD + ["enrolled", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "enrolled"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/database/test_database.py b/tests/integration/database/test_database.py new file mode 100644 index 000000000..aebaeda71 --- /dev/null +++ b/tests/integration/database/test_database.py @@ -0,0 +1,113 @@ +import pytest + +from tests.integration.helpers import assert_headers_in_lines, exec_test_command + +BASE_CMD = ["linode-cli", "databases"] +pytestmark = pytest.mark.skip( + "This command is currently only available for customers who already have an active " + "Managed Database." +) + + +def test_engines_list(): + res = ( + exec_test_command(BASE_CMD + ["engines", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + engine_id = lines[1].split(",")[0] + + headers = ["id", "engine", "version"] + assert_headers_in_lines(headers, lines) + return engine_id + + +def test_engines_view(): + engine_id = test_engines_list() + res = ( + exec_test_command( + BASE_CMD + ["engine-view", engine_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + + lines = res.splitlines() + + headers = ["id", "engine", "version"] + assert_headers_in_lines(headers, lines) + + +def test_databases_list(): + res = ( + exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + + lines = res.splitlines() + + headers = ["id", "label", "region"] + assert_headers_in_lines(headers, lines) + + +def test_mysql_list(): + res = ( + exec_test_command(BASE_CMD + ["mysql-list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + + lines = res.splitlines() + + headers = ["id", "label", "region"] + + assert_headers_in_lines(headers, lines) + + +def test_postgresql_list(): + res = ( + exec_test_command( + BASE_CMD + ["postgresql-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + + lines = res.splitlines() + + headers = ["id", "label", "region"] + + assert_headers_in_lines(headers, lines) + + +def test_databases_types(): + res = ( + exec_test_command(BASE_CMD + ["types", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + node_id = lines[1].split(",")[0] + + headers = ["id", "label", "_split"] + assert_headers_in_lines(headers, lines) + return node_id + + +def test_databases_type_view(): + node_id = test_databases_types() + res = ( + exec_test_command( + BASE_CMD + ["type-view", node_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id", "label", "_split"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 05b207417..1e2676ca4 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -136,3 +136,8 @@ def remove_all(target: str): def count_lines(text: str): return len(list(filter(len, text.split("\n")))) + + +def assert_headers_in_lines(headers, lines): + for header in headers: + assert header in lines[0] From 96e9f97611f485d1074448308606336f327fc2d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:37:37 -0400 Subject: [PATCH 12/23] build(deps-dev): bump requests-mock from 1.11.0 to 1.12.1 (#595) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4c7268f99..36b2b4f4b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,7 +4,7 @@ black>=23.1.0 isort>=5.12.0 autoflake>=2.0.1 pytest-mock>=3.10.0 -requests-mock==1.11.0 +requests-mock==1.12.1 boto3-stubs[s3] build>=0.10.0 twine>=4.0.2 From a3cf252958d6f6603b371475eaa1ecf666b037ba Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:25:34 -0400 Subject: [PATCH 13/23] ref: Drop `version` script; consolidate version logic in setup.py (#594) --- setup.py | 15 ++++----------- version | 21 --------------------- 2 files changed, 4 insertions(+), 32 deletions(-) delete mode 100755 version diff --git a/setup.py b/setup.py index 522cb5f78..ddcf08cd7 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import os import pathlib import subprocess import sys @@ -10,6 +11,8 @@ here = pathlib.Path().absolute() +ENV_LINODE_CLI_VERSION = "LINODE_CLI_VERSION" + # get the long description from the README.md with open(here / "README.md", encoding="utf-8") as f: long_description = f.read() @@ -31,16 +34,6 @@ def get_baked_files(): return data_files -def get_version(): - """ - Uses the version file to calculate this package's version - """ - return ( - subprocess.check_output([sys.executable, "./version"]) - .decode("utf-8") - .rstrip() - ) - def get_baked_version(): """ @@ -71,7 +64,7 @@ def bake_version(v): version = get_baked_version() else: # Otherwise, retrieve and bake the version as normal - version = get_version() + version = os.getenv(ENV_LINODE_CLI_VERSION) or "0.0.0" bake_version(version) with open("requirements.txt") as f: diff --git a/version b/version deleted file mode 100755 index 5aa51e513..000000000 --- a/version +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# Usage: -# ./bin/version -# Prints the current version - -import os - -from packaging.version import parse - -ENV_LINODE_CLI_VERSION = "LINODE_CLI_VERSION" - - -def get_version(): - # We want to override the version if an environment variable is specified. - # This is useful for certain release and testing pipelines. - version_str = os.getenv(ENV_LINODE_CLI_VERSION) or "0.0.0" - - return parse(version_str).release - - -print("{}.{}.{}".format(*get_version())) From 5716fcd30a9c017babe201e16b6788c67cb19cdc Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 8 Apr 2024 14:28:14 -0400 Subject: [PATCH 14/23] Add `--debug` option to metadata plugin (#592) --- linodecli/arg_helpers.py | 13 +++++++------ linodecli/helpers.py | 10 ++++++++++ linodecli/plugins/metadata.py | 14 ++++++++++---- requirements.txt | 2 +- tests/unit/test_plugin_metadata.py | 10 ++++++++++ 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index a605b5b0f..24a850f9b 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -11,9 +11,12 @@ import yaml from linodecli import plugins - -from .completion import bake_completions -from .helpers import pagination_args_shared, register_args_shared +from linodecli.completion import bake_completions +from linodecli.helpers import ( + pagination_args_shared, + register_args_shared, + register_debug_arg, +) def register_args(parser): @@ -137,12 +140,10 @@ def register_args(parser): action="store_true", help="Prints version information and exits.", ) - parser.add_argument( - "--debug", action="store_true", help="Enable verbose HTTP debug output." - ) pagination_args_shared(parser) register_args_shared(parser) + register_debug_arg(parser) return parser diff --git a/linodecli/helpers.py b/linodecli/helpers.py index 9099cb22e..08f8023e2 100644 --- a/linodecli/helpers.py +++ b/linodecli/helpers.py @@ -93,6 +93,16 @@ def register_args_shared(parser: ArgumentParser): return parser +def register_debug_arg(parser: ArgumentParser): + """ + Add the debug argument to the given + ArgumentParser that may be shared across the CLI and plugins. + """ + parser.add_argument( + "--debug", action="store_true", help="Enable verbose HTTP debug output." + ) + + def expand_globs(pattern: str): """ Expand glob pattern (for example, '/some/path/*.txt') diff --git a/linodecli/plugins/metadata.py b/linodecli/plugins/metadata.py index 2d9597da5..3b186a28b 100644 --- a/linodecli/plugins/metadata.py +++ b/linodecli/plugins/metadata.py @@ -6,8 +6,8 @@ linode-cli metadata [ENDPOINT] """ -import argparse import sys +from argparse import ArgumentParser from linode_metadata import MetadataClient from linode_metadata.objects.error import ApiError @@ -16,6 +16,8 @@ from rich import print as rprint from rich.table import Table +from linodecli.helpers import register_debug_arg + PLUGIN_BASE = "linode-cli metadata" @@ -156,7 +158,7 @@ def get_ssh_keys(client: MetadataClient): } -def print_help(parser: argparse.ArgumentParser): +def print_help(parser: ArgumentParser): """ Print out the help info to the standard output """ @@ -181,7 +183,9 @@ def get_metadata_parser(): """ Builds argparser for Metadata plug-in """ - parser = argparse.ArgumentParser(PLUGIN_BASE, add_help=False) + parser = ArgumentParser(PLUGIN_BASE, add_help=False) + + register_debug_arg(parser) parser.add_argument( "endpoint", @@ -209,7 +213,9 @@ def call(args, context): # make a client, but only if we weren't printing help and endpoint is valid if "--help" not in args: try: - client = MetadataClient(user_agent=context.client.user_agent) + client = MetadataClient( + user_agent=context.client.user_agent, debug=parsed.debug + ) except ConnectTimeout as exc: raise ConnectionError( "Can't access Metadata service. Please verify that you are inside a Linode." diff --git a/requirements.txt b/requirements.txt index 9f8c23e51..051edbcfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ PyYAML packaging rich urllib3<3 -linode-metadata +linode-metadata>=0.3.0 diff --git a/tests/unit/test_plugin_metadata.py b/tests/unit/test_plugin_metadata.py index 72e3c6df7..8798dbf0f 100644 --- a/tests/unit/test_plugin_metadata.py +++ b/tests/unit/test_plugin_metadata.py @@ -7,6 +7,7 @@ from pytest import CaptureFixture from linodecli.plugins.metadata import ( + get_metadata_parser, print_instance_table, print_networking_tables, print_ssh_keys_table, @@ -129,3 +130,12 @@ def test_empty_ssh_key_table(capsys: CaptureFixture): assert "user" in captured_text.out assert "ssh key" in captured_text.out + + +def test_arg_parser(): + parser = get_metadata_parser() + parsed, args = parser.parse_known_args(["--debug"]) + assert parsed.debug + + parsed, args = parser.parse_known_args(["--something-else"]) + assert not parsed.debug From 47535fd7f05be0661ed77126273e0ad532f4ca2f Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Tue, 9 Apr 2024 11:17:09 -0400 Subject: [PATCH 15/23] ci: update labels and release drafter (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates to use githubs built in release notes and using the following labels. `**NOTE**: The labeler job is dry running on the PR to show what it will do, doesn't execute until we merge.` ### ⚠️ Breaking Change breaking-change: any changes that break end users or downstream workflows ### 🐛 Bug Fixes bugfix: changes that fix a existing bug ### 🚀 New Features new-feature: changes that add new features such as endpoints or tools ### 💡 Improvements improvement: changes that improve existing features or reflect small API changes ### 🧪 Testing Improvements testing: improvements to the testing workflows ### ⚙️ Repo/CI Improvements repo-ci-improvement: improvements to the CI workflow, like this PR! ### 📖 Documentation documentation: updates to the package/repo documentation or wiki ### 📦 Dependency Updates dependencies: Used by dependabot mostly ### Ignore For Release ignore-for-release: for PRs you dont want rendered in the changelog, usually the release merge to main --- .github/labels.yml | 14 ++++++++++++++ .github/workflows/labeler.yml | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/workflows/labeler.yml diff --git a/.github/labels.yml b/.github/labels.yml index 04507538d..2a28fc812 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,3 +1,4 @@ +# PR Labels - name: new-feature description: for new features in the changelog. color: 225fee @@ -13,6 +14,9 @@ - name: documentation description: for updates to the documentation in the changelog. color: d3e1e6 +- name: dependencies + description: dependency updates usually from dependabot + color: 5c9dff - name: testing description: for updates to the testing suite in the changelog. color: 933ac9 @@ -22,3 +26,13 @@ - name: ignore-for-release description: PRs you do not want to render in the changelog color: 7b8eac +- name: do-not-merge + description: PRs that should not be merged until the commented issue is resolved + color: eb1515 +# Issue Labels +- name: enhancement + description: issues that request a enhancement + color: 22ee47 +- name: bug + description: issues that report a bug + color: ed8e21 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..da42b7e4a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,31 @@ +name: labeler + +on: + push: + branches: + - 'main' + paths: + - '.github/labels.yml' + - '.github/workflows/labeler.yml' + pull_request: + paths: + - '.github/labels.yml' + - '.github/workflows/labeler.yml' + +jobs: + labeler: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Run Labeler + uses: crazy-max/ghaction-github-labeler@de749cf181958193cb7debf1a9c5bb28922f3e1b + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + yaml-file: .github/labels.yml + dry-run: ${{ github.event_name == 'pull_request' }} + exclude: | + help* + *issue From 70dfe44296ee7dab88be87eb518606157030bfa6 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:20:13 -0400 Subject: [PATCH 16/23] ref: Improve maintainability of `colors.py` (#598) --- linodecli/baked/colors.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/linodecli/baked/colors.py b/linodecli/baked/colors.py index 72181e74f..70ab04b1a 100644 --- a/linodecli/baked/colors.py +++ b/linodecli/baked/colors.py @@ -1,11 +1,22 @@ """ -Applies shell color escapes for pretty printing +Applies shell color escapes for pretty printing. """ import os import platform +CLEAR_COLOR = "\x1b[0m" +COLOR_CODE_MAP = { + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "black": "\x1b[30m", + "white": "\x1b[40m", +} + + DO_COLORS = True + # !! Windows compatibility for ANSI color codes !! # # If we're running on windows, we need to run the "color" command to enable @@ -30,21 +41,20 @@ DO_COLORS = False -CLEAR_COLOR = "\x1b[0m" -COLOR_CODE_MAP = { - "red": "\x1b[31m", - "green": "\x1b[32m", - "yellow": "\x1b[33m", - "black": "\x1b[30m", - "white": "\x1b[40m", -} - - -def colorize_string(string, color): +def colorize_string(string: str, color: str) -> str: """ Returns the requested string, wrapped in ANSI color codes to colorize it as requested. On platforms where colors are not supported, this just returns the string passed into it. + + :param string: The content to colorize. + :type string: str + :param color: The color to colorize the string with. + (one of red, green, yellow, black, white) + :type color: str + + :returns: The colorized string. + :rtype: string """ if not DO_COLORS: return string From d05821aaa5c7c13e58dc79baa66ace927a21ab0a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 10 Apr 2024 13:00:11 -0400 Subject: [PATCH 17/23] ref: Refactor the `configuration` package for maintainability (#597) --- linodecli/configuration/__init__.py | 448 +--------------- linodecli/configuration/auth.py | 144 ++++- linodecli/configuration/config.py | 584 +++++++++++++++++++++ linodecli/configuration/helpers.py | 160 +++--- tests/unit/test_configuration.py | 4 +- wiki/development/Development - Skeleton.md | 3 +- 6 files changed, 784 insertions(+), 559 deletions(-) create mode 100644 linodecli/configuration/config.py diff --git a/linodecli/configuration/__init__.py b/linodecli/configuration/__init__.py index 2e2035b88..93062629a 100644 --- a/linodecli/configuration/__init__.py +++ b/linodecli/configuration/__init__.py @@ -1,19 +1,17 @@ """ -Handles configuring the cli, as well as loading configs so that they can be -used elsewhere. +Configuration helper package for the Linode CLI. """ -import argparse -import os -import sys -from typing import Dict - +# Private methods need to be imported explicitly +from .auth import * from .auth import ( _check_full_access, _do_get_request, _get_token_terminal, _get_token_web, ) +from .config import * +from .helpers import * from .helpers import ( _bool_input, _check_browsers, @@ -22,440 +20,4 @@ _default_thing_input, _get_config, _get_config_path, - _handle_no_default_user, ) - -ENV_TOKEN_NAME = "LINODE_CLI_TOKEN" - - -class CLIConfig: - """ - Generates the necessary config for the Linode CLI - """ - - def __init__(self, base_url, username=None, skip_config=False): - self.base_url = base_url - self.username = username - self.config = _get_config(load=not skip_config) - self.running_plugin = None - self.used_env_token = False - - self._configured = False - - self.configure_with_pat = "--token" in sys.argv - - if ( - not skip_config - and not self.config.has_option("DEFAULT", "default-user") - and self.config.has_option("DEFAULT", "token") - ): - _handle_no_default_user(self) - - environ_token = os.getenv(ENV_TOKEN_NAME, None) - - if ( - not self.config.has_option("DEFAULT", "default-user") - and not skip_config - and environ_token is None - ): - self.configure() - elif environ_token is not None: - self.used_env_token = True - - def default_username(self): - """ - Returns the default-user Username - """ - if self.config.has_option("DEFAULT", "default-user"): - return self.config.get("DEFAULT", "default-user") - return "" - - def set_user(self, username): - """ - Sets the acting username. If this username is not in the config, this is - an error. This overrides the default username - """ - if not self.config.has_section(username): - print(f"User {username} is not configured!") - sys.exit(1) - - self.username = username - - def remove_user(self, username): - """ - Removes the requested user from the config. If the user is the default, - this exits with error - """ - if self.default_username() == username: - print( - f"Cannot remove {username} as they are the default user! You can " - "change the default user with: `linode-cli set-user USERNAME`" - ) - sys.exit(1) - - if self.config.has_section(username): - self.config.remove_section(username) - self.write_config() - - def print_users(self): - """ - Prints all users available and exits - """ - print("Configured Users: ") - default_user = self.default_username() - - for sec in self.config.sections(): - if sec != "DEFAULT": - print(f'{"*" if sec == default_user else " "} {sec}') - - sys.exit(0) - - def set_default_user(self, username): - """ - Sets the default user. If that user isn't in the config, exits with error - """ - if not self.config.has_section(username): - print(f"User {username} is not configured!") - sys.exit(1) - - self.config.set("DEFAULT", "default-user", username) - self.write_config() - - def get_token(self): - """ - Returns the token for a configured user - """ - if self.used_env_token: - return os.environ.get(ENV_TOKEN_NAME, None) - - if self.config.has_option( - self.username or self.default_username(), "token" - ): - return self.config.get( - self.username or self.default_username(), "token" - ) - return "" - - def get_value(self, key): - """ - Retrieves and returns an existing config value for the current user. This - is intended for plugins to use instead of having to deal with figuring out - who the current user is when accessing their config. - - .. warning:: - Plugins _MUST NOT_ set values for the user's config except through - ``plugin_set_value`` below. - - :param key: The key to look up. - :type key: str - - :returns: The value for that key, or None if the key doesn't exist for the - current user. - :rtype: any - """ - username = self.username or self.default_username() - - if not self.config.has_option(username, key): - return None - - return self.config.get(username, key) - - # plugin methods - these are intended for plugins to utilize to store their - # own persistent config information - def plugin_set_value(self, key, value): - """ - Sets a new config value for a plugin for the current user. Plugin config - keys are set in the following format:: - - plugin-{plugin_name}-{key} - - Values set with this method are intended to be retrieved with ``plugin_get_value`` - below. - - :param key: The config key to set - this is needed to retrieve the value - :type key: str - :param value: The value to set for this key - :type value: any - """ - if self.running_plugin is None: - raise RuntimeError( - "No running plugin to retrieve configuration for!" - ) - - username = self.username or self.default_username() - self.config.set(username, f"plugin-{self.running_plugin}-{key}", value) - - def plugin_get_value(self, key): - """ - Retrieves and returns a config value previously set for a plugin. Your - plugin should have set this value in the past. If this value does not - exist in the config, ``None`` is returned. This is the only time - ``None`` is returned, so receiving this value should be treated as - "plugin is not configured." - - :param key: The key of the value to return - :type key: str - - :returns: The value for this plugin for this key, or None if not set - :rtype: any - """ - if self.running_plugin is None: - raise RuntimeError( - "No running plugin to retrieve configuration for!" - ) - - username = self.username or self.default_username() - full_key = f"plugin-{self.running_plugin}-{key}" - - if not self.config.has_option(username, full_key): - return None - - return self.config.get(username, full_key) - - # TODO: this is more of an argparsing function than it is a config function - # might be better to move this to argparsing during refactor and just have - # configuration return defaults or keys or something - def update( - self, namespace, allowed_defaults - ): # pylint: disable=too-many-branches - """ - This updates a Namespace (as returned by ArgumentParser) with config values - if they aren't present in the Namespace already. - """ - if self.used_env_token and self.config is None: - return None - username = self.username or self.default_username() - if not self.config.has_option(username, "token") and not os.environ.get( - ENV_TOKEN_NAME, None - ): - print(f"User {username} is not configured.") - sys.exit(1) - if not self.config.has_section(username) or allowed_defaults is None: - return namespace - - warn_dict = {} - ns_dict = vars(namespace) - for key in allowed_defaults: - if key not in ns_dict: - continue - if ns_dict[key] is not None: - continue - # plugins set config options that start with 'plugin-' - # these don't get included in the updated namespace - if key.startswith("plugin-"): - continue - value = None - if self.config.has_option(username, key): - value = self.config.get(username, key) - else: - value = ns_dict[key] - - if not value: - continue - - if key == "authorized_users": - ns_dict[key] = [value] - warn_dict[key] = [value] - else: - ns_dict[key] = value - warn_dict[key] = value - - if not any( - x in ["--suppress-warnings", "--no-headers"] for x in sys.argv - ): - print( - f"using default values: {warn_dict}, " - "use --no-defaults flag to disable defaults" - ) - return argparse.Namespace(**ns_dict) - - def write_config(self): - """ - Saves the config file as it is right now. This can be used by plugins - to save values they've set, and is used internally to update the config - on disk when a new user if configured. - """ - if not os.path.exists(f"{os.path.expanduser('~')}/.config"): - os.makedirs(f"{os.path.expanduser('~')}/.config") - with open(_get_config_path(), "w", encoding="utf-8") as f: - self.config.write(f) - - def configure( - self, - ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals - """ - This assumes we're running interactively, and prompts the user - for a series of defaults in order to make future CLI calls - easier. This also sets up the config file. - """ - # If configuration has already been done in this run, don't do it again. - if self._configured: - return - config = {} - # we're configuring the default user if there is no default user configured - # yet - is_default = not self.config.has_option("DEFAULT", "default-user") - username = None - token = None - - print( - "Welcome to the Linode CLI. This will walk you through some initial setup." - ) - - if ENV_TOKEN_NAME in os.environ: - print( - f"Using token from {ENV_TOKEN_NAME}.\n" - "Note that no token will be saved in your configuration file.\n" - f" * If you lose or remove {ENV_TOKEN_NAME}.\n" - f" * All profiles will use {ENV_TOKEN_NAME}." - ) - username = "DEFAULT" - token = os.getenv(ENV_TOKEN_NAME) - - else: - if _check_browsers() and not self.configure_with_pat: - print( - "The CLI will use its web-based authentication to log you in.\n" - "If you prefer to supply a Personal Access Token," - "use `linode-cli configure --token`." - ) - input( - "Press enter to continue. " - "This will open a browser and proceed with authentication." - ) - username, config["token"] = _get_token_web(self.base_url) - else: - username, config["token"] = _get_token_terminal(self.base_url) - token = config["token"] - - print(f"\nConfiguring {username}\n") - - # Configuring Defaults - - regions = [ - r["id"] for r in _do_get_request(self.base_url, "/regions")["data"] - ] - types = [ - t["id"] - for t in _do_get_request(self.base_url, "/linode/types")["data"] - ] - images = [ - i["id"] for i in _do_get_request(self.base_url, "/images")["data"] - ] - - is_full_access = _check_full_access(self.base_url, token) - - auth_users = [] - - if is_full_access: - users = _do_get_request( - self.base_url, - "/account/users", - token=token, - # Allow 401 responses so tokens without - # account perms can be configured - status_validator=lambda status: status == 401, - ) - - if "data" in users: - auth_users = [ - u["username"] for u in users["data"] if "ssh_keys" in u - ] - - # get the preferred things - config["region"] = _default_thing_input( - "Default Region for operations.", - regions, - "Default Region (Optional): ", - "Please select a valid Region, or press Enter to skip", - current_value=_config_get_with_default( - self.config, username, "region" - ), - ) - - config["type"] = _default_thing_input( - "Default Type of Linode to deploy.", - types, - "Default Type of Linode (Optional): ", - "Please select a valid Type, or press Enter to skip", - current_value=_config_get_with_default( - self.config, username, "type" - ), - ) - - config["image"] = _default_thing_input( - "Default Image to deploy to new Linodes.", - images, - "Default Image (Optional): ", - "Please select a valid Image, or press Enter to skip", - current_value=_config_get_with_default( - self.config, username, "image" - ), - ) - - if auth_users: - config["authorized_users"] = _default_thing_input( - "Select the user that should be given default SSH access to new Linodes.", - auth_users, - "Default Option (Optional): ", - "Please select a valid Option, or press Enter to skip", - current_value=_config_get_with_default( - self.config, username, "authorized_users" - ), - ) - - if _bool_input("Configure a custom API target?", default=False): - self._configure_api_target(config) - - # save off the new configuration - if username != "DEFAULT" and not self.config.has_section(username): - self.config.add_section(username) - - if not is_default: - if username != self.default_username(): - is_default = _bool_input( - "Make this user the default when using the CLI?" - ) - - if not is_default: # they didn't change the default user - print( - f"Active user will remain {self.config.get('DEFAULT', 'default-user')}" - ) - - if is_default: - # if this is the default user, make it so - self.config.set("DEFAULT", "default-user", username) - print(f"Active user is now {username}") - - for k, v in config.items(): - if v is None: - if self.config.has_option(username, k): - self.config.remove_option(username, k) - - continue - - self.config.set(username, k, v) - - self.write_config() - os.chmod(_get_config_path(), 0o600) - self._configured = True - - @staticmethod - def _configure_api_target(config: Dict[str, str]): - config["api_host"] = _default_text_input( - "NOTE: Skipping this field will use the default Linode API host.\n" - 'API host override (e.g. "api.dev.linode.com")', - optional=True, - ) - - config["api_version"] = _default_text_input( - "NOTE: Skipping this field will use the default Linode API version.\n" - 'API version override (e.g. "v4beta")', - optional=True, - ) - - config["api_scheme"] = _default_text_input( - "NOTE: Skipping this field will use the HTTPS scheme.\n" - 'API scheme override (e.g. "https")', - optional=True, - ) diff --git a/linodecli/configuration/auth.py b/linodecli/configuration/auth.py index 960354ea6..e77a6501a 100644 --- a/linodecli/configuration/auth.py +++ b/linodecli/configuration/auth.py @@ -8,15 +8,19 @@ import webbrowser from http import server from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple import requests from linodecli.helpers import API_CA_PATH TOKEN_GENERATION_URL = "https://cloud.linode.com/profile/tokens" -# This is used for web-based configuration + +# The hardcoded OAuth client ID for use in web authentication. +# This client object exists under an official Linode account. OAUTH_CLIENT_ID = "5823b4627e45411d18e9" -# in the event that we can't load the styled landing page from file, this will + +# In the event that we can't load the styled landing page from file, this will # do as a landing page DEFAULT_LANDING_PAGE = """

Success


You may return to your terminal to continue..

@@ -30,8 +34,23 @@ def _handle_response_status( - response, exit_on_error=None, status_validator=None + response: requests.Response, + exit_on_error: bool = False, + status_validator: Optional[Callable[[int], bool]] = None, ): + """ + Handle the response status code and handle errors if necessary. + + :param response: The response object from the API call. + :type response: requests.Response + :param exit_on_error: If true, the CLI should exit if the response contains an error. + Defaults to False. + :type exit_on_error: bool + :param status_validator: A custom response validator function to run before + the default validation. + :type status_validator: Optional[Callable[int], bool] + """ + if status_validator is not None and status_validator(response.status_code): return @@ -45,15 +64,35 @@ def _handle_response_status( # TODO: merge config do_request and cli do_request def _do_get_request( - base_url, url, token=None, exit_on_error=True, status_validator=None -): + base_url: str, + path: str, + token: Optional[str] = None, + exit_on_error: bool = True, + status_validator: Optional[Callable[[int], bool]] = None, +) -> Dict[str, Any]: """ - Does helper get requests during configuration + Runs an HTTP GET request. + + :param base_url: The base URL of the API. + :type base_url: str + :param path: The path of the API endpoint. + :type path: str + :param token: The authentication token to be used for this request. + :type token: Optional[str] + :param exit_on_error: If true, the CLI should exit if the response contains an error. + Defaults to False. + :type exit_on_error: bool + :param status_validator: A custom response validator function to run + before the default validation. + :type status_validator: Optional[Callable[int], bool] + + :returns: The response from the API request. + :rtype: Dict[str, Any] """ return _do_request( base_url, requests.get, - url, + path, token=token, exit_on_error=exit_on_error, status_validator=status_validator, @@ -61,16 +100,36 @@ def _do_get_request( def _do_request( - base_url, - method, - url, - token=None, - exit_on_error=None, - body=None, - status_validator=None, + base_url: str, + method: Callable, + path: str, + token: Optional[str] = None, + exit_on_error: bool = False, + body: Optional[Dict[str, Any]] = None, + status_validator: Optional[Callable[[int], bool]] = None, ): # pylint: disable=too-many-arguments """ - Does helper requests during configuration + Runs an HTTP request. + + :param base_url: The base URL of the API. + :type base_url: str + :param method: The request method function to use. + :type method: Callable + :param path: The path of the API endpoint. + :type path: str + :param token: The authentication token to be used for this request. + :type token: Optional[str] + :param exit_on_error: If true, the CLI should exit if the response contains an error. + Defaults to False. + :type exit_on_error: bool + :param body: The body of this request. + :type body: Optional[Dict[str, Any]] + :param status_validator: A custom response validator function to run before + the default validation. + :type status_validator: Optional[Callable[int], bool] + + :returns: The response body as a JSON object. + :rtype: Dict[str, Any] """ headers = {} @@ -79,7 +138,7 @@ def _do_request( headers["Content-type"] = "application/json" result = method( - base_url + url, headers=headers, json=body, verify=API_CA_PATH + base_url + path, headers=headers, json=body, verify=API_CA_PATH ) _handle_response_status( @@ -89,7 +148,18 @@ def _do_request( return result.json() -def _check_full_access(base_url, token): +def _check_full_access(base_url: str, token: str) -> bool: + """ + Checks whether the given token has full-access permissions. + + :param base_url: The base URL for the API. + :type base_url: str + :param token: The access token to use. + :type token :str + + :returns: Whether the user has full access. + :rtype: bool + """ headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", @@ -107,10 +177,18 @@ def _check_full_access(base_url, token): return result.status_code == 204 -def _username_for_token(base_url, token): +def _username_for_token(base_url: str, token: str) -> str: """ A helper function that returns the username associated with a token by - requesting it from the API + requesting it from the API. + + :param base_url: The base URL for the API. + :type base_url: str + :param token: The access token to use. + :type token :str + + :returns: The username for this token. + :rtype: str """ u = _do_get_request(base_url, "/profile", token=token, exit_on_error=False) if "errors" in u: @@ -121,10 +199,16 @@ def _username_for_token(base_url, token): return u["username"] -def _get_token_terminal(base_url): +def _get_token_terminal(base_url: str) -> Tuple[str, str]: """ Handles prompting the user for a Personal Access Token and checking it to ensure it works. + + :param base_url: The base URL for the API. + :type base_url: str + + :returns: A tuple containing the user's username and token. + :rtype: Tuple[str, str] """ print( f""" @@ -144,12 +228,15 @@ def _get_token_terminal(base_url): return username, token -def _get_token_web(base_url): +def _get_token_web(base_url: str) -> Tuple[str, str]: """ - Handles OAuth authentication for the CLI. This requires us to get a temporary - token over OAuth and then use it to create a permanent token for the CLI. - This function returns the token the CLI should use, or exits if anything - goes wrong. + Generates a token using OAuth/web authentication.. + + :param base_url: The base URL of the API. + :type base_url: str + + :return: A tuple containing the username and web token. + :rtype: Tuple[str, str] """ temp_token = _handle_oauth_callback() username = _username_for_token(base_url, temp_token) @@ -176,10 +263,13 @@ def _get_token_web(base_url): return username, result["token"] -def _handle_oauth_callback(): +def _handle_oauth_callback() -> str: """ Sends the user to a URL to perform an OAuth login for the CLI, then redirects - them to a locally-hosted page that captures the token + them to a locally-hosted page that captures the token. + + :returns: The temporary OAuth token. + :rtype: str """ # load up landing page HTML landing_page_path = Path(__file__).parent.parent / "oauth-landing-page.html" diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py new file mode 100644 index 000000000..4b4fd453e --- /dev/null +++ b/linodecli/configuration/config.py @@ -0,0 +1,584 @@ +""" +Contains logic for loading, updating, and saving Linode CLI configurations. +""" + +import argparse +import os +import sys +from typing import Any, Dict, List, Optional + +from .auth import ( + _check_full_access, + _do_get_request, + _get_token_terminal, + _get_token_web, +) +from .helpers import ( + _bool_input, + _check_browsers, + _config_get_with_default, + _default_text_input, + _default_thing_input, + _get_config, + _get_config_path, +) + +ENV_TOKEN_NAME = "LINODE_CLI_TOKEN" + + +class CLIConfig: + """ + Generates the necessary config for the Linode CLI + """ + + def __init__( + self, base_url: str, username: str = None, skip_config: bool = False + ): + """ + Initializes a new instance of the CLIConfig class. + + :param base_url: The base URL for the Linode API. + :type base_url: str + :param username: (optional) The username to use for authentication. Defaults to None. + :type: username: str + :param skip_config: (optional) If True, skip loading the configuration file. + Defaults to False. + :type skip_config: bool + """ + self.base_url = base_url + self.username = username + self.config = _get_config(load=not skip_config) + self.running_plugin = None + self.used_env_token = False + + self._configured = False + + self.configure_with_pat = "--token" in sys.argv + + if ( + not skip_config + and not self.config.has_option("DEFAULT", "default-user") + and self.config.has_option("DEFAULT", "token") + ): + self._handle_no_default_user() + + environ_token = os.getenv(ENV_TOKEN_NAME, None) + + if ( + not self.config.has_option("DEFAULT", "default-user") + and not skip_config + and environ_token is None + ): + self.configure() + elif environ_token is not None: + self.used_env_token = True + + def default_username(self) -> str: + """ + Returns the `default-user` username. + + :returns: The `default-user` username or an empty string. + :rtype: str + """ + if self.config.has_option("DEFAULT", "default-user"): + return self.config.get("DEFAULT", "default-user") + + return "" + + def set_user(self, username: str): + """ + Sets the acting username. If this username is not in the config, this is + an error. This overrides the default username + + :param username: The username to set. + :type username: str + """ + if not self.config.has_section(username): + print(f"User {username} is not configured!") + sys.exit(1) + + self.username = username + + def remove_user(self, username: str): + """ + Removes the requested user from the config. If the user is the default, + this exits with error. + + :param username: The username to remove. + :type username: str + """ + if self.default_username() == username: + print( + f"Cannot remove {username} as they are the default user! You can " + "change the default user with: `linode-cli set-user USERNAME`" + ) + sys.exit(1) + + if self.config.has_section(username): + self.config.remove_section(username) + self.write_config() + + def print_users(self): + """ + Prints all users available to stdout and exits. + """ + print("Configured Users: ") + default_user = self.default_username() + + for sec in self.config.sections(): + if sec != "DEFAULT": + print(f'{"*" if sec == default_user else " "} {sec}') + + sys.exit(0) + + def set_default_user(self, username: str): + """ + Sets the default user. If that user isn't in the config, exits with error + """ + if not self.config.has_section(username): + print(f"User {username} is not configured!") + sys.exit(1) + + self.config.set("DEFAULT", "default-user", username) + self.write_config() + + def get_token(self) -> str: + """ + Returns the token for a configured user. + + :returns: The token retrieved from the environment or config. + :rtype: str + """ + if self.used_env_token: + return os.environ.get(ENV_TOKEN_NAME, None) + + if self.config.has_option( + self.username or self.default_username(), "token" + ): + return self.config.get( + self.username or self.default_username(), "token" + ) + return "" + + def get_value(self, key: str) -> Optional[Any]: + """ + Retrieves and returns an existing config value for the current user. This + is intended for plugins to use instead of having to deal with figuring out + who the current user is when accessing their config. + + .. warning:: + Plugins _MUST NOT_ set values for the user's config except through + ``plugin_set_value`` below. + + :param key: The key to look up. + :type key: str + + :returns: The value for that key, or None if the key doesn't exist for the + current user. + :rtype: any + """ + username = self.username or self.default_username() + + if not self.config.has_option(username, key): + return None + + return self.config.get(username, key) + + # plugin methods - these are intended for plugins to utilize to store their + # own persistent config information + def plugin_set_value(self, key: str, value: Any): + """ + Sets a new config value for a plugin for the current user. Plugin config + keys are set in the following format:: + + plugin-{plugin_name}-{key} + + Values set with this method are intended to be retrieved with ``plugin_get_value`` + below. + + :param key: The config key to set - this is needed to retrieve the value + :type key: str + :param value: The value to set for this key + :type value: any + """ + if self.running_plugin is None: + raise RuntimeError( + "No running plugin to retrieve configuration for!" + ) + + username = self.username or self.default_username() + self.config.set(username, f"plugin-{self.running_plugin}-{key}", value) + + def plugin_get_value(self, key: str) -> Optional[Any]: + """ + Retrieves and returns a config value previously set for a plugin. Your + plugin should have set this value in the past. If this value does not + exist in the config, ``None`` is returned. This is the only time + ``None`` is returned, so receiving this value should be treated as + "plugin is not configured." + + :param key: The key of the value to return + :type key: str + + :returns: The value for this plugin for this key, or None if not set + :rtype: any + """ + if self.running_plugin is None: + raise RuntimeError( + "No running plugin to retrieve configuration for!" + ) + + username = self.username or self.default_username() + full_key = f"plugin-{self.running_plugin}-{key}" + + if not self.config.has_option(username, full_key): + return None + + return self.config.get(username, full_key) + + # TODO: this is more of an argparsing function than it is a config function + # might be better to move this to argparsing during refactor and just have + # configuration return defaults or keys or something + def update( + self, namespace: argparse.Namespace, allowed_defaults: List[str] + ) -> argparse.Namespace: + # pylint: disable=too-many-branches + """ + This updates a Namespace (as returned by ArgumentParser) with config values + if they aren't present in the Namespace already. + + :param namespace: The argparse namespace parsed from the user's input + :type namespace: argparse.Namespace + :param allowed_defaults: A list of allowed default keys to pull from the config. + :type allowed_defaults: List[str] + + :returns: The updated namespace. + :rtype: argparse.Namespace + """ + if self.used_env_token and self.config is None: + return None + + username = self.username or self.default_username() + if not self.config.has_option(username, "token") and not os.environ.get( + ENV_TOKEN_NAME, None + ): + print(f"User {username} is not configured.") + sys.exit(1) + if not self.config.has_section(username) or allowed_defaults is None: + return namespace + + warn_dict = {} + ns_dict = vars(namespace) + for key in allowed_defaults: + if key not in ns_dict: + continue + if ns_dict[key] is not None: + continue + # plugins set config options that start with 'plugin-' + # these don't get included in the updated namespace + if key.startswith("plugin-"): + continue + value = None + if self.config.has_option(username, key): + value = self.config.get(username, key) + else: + value = ns_dict[key] + + if not value: + continue + + if key == "authorized_users": + ns_dict[key] = [value] + warn_dict[key] = [value] + else: + ns_dict[key] = value + warn_dict[key] = value + + if not any( + x in ["--suppress-warnings", "--no-headers"] for x in sys.argv + ): + print( + f"Using default values: {warn_dict}; " + "use the --no-defaults flag to disable defaults" + ) + return argparse.Namespace(**ns_dict) + + def write_config(self): + """ + Saves the config file as it is right now. This can be used by plugins + to save values they've set, and is used internally to update the config + on disk when a new user if configured. + """ + + # Create the config path isf necessary + config_path = f"{os.path.expanduser('~')}/.config" + if not os.path.exists(config_path): + os.makedirs(config_path) + + with open(_get_config_path(), "w", encoding="utf-8") as f: + self.config.write(f) + + def configure( + self, + ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals + """ + This assumes we're running interactively, and prompts the user + for a series of defaults in order to make future CLI calls + easier. This also sets up the config file. + """ + # If configuration has already been done in this run, don't do it again. + if self._configured: + return + + config = {} + + # we're configuring the default user if there is no default user configured + # yet + is_default = not self.config.has_option("DEFAULT", "default-user") + + username = None + token = None + + print( + "Welcome to the Linode CLI. This will walk you through some initial setup." + ) + + if ENV_TOKEN_NAME in os.environ: + print( + f"Using token from {ENV_TOKEN_NAME}.\n" + "Note that no token will be saved in your configuration file.\n" + f" * If you lose or remove {ENV_TOKEN_NAME}.\n" + f" * All profiles will use {ENV_TOKEN_NAME}." + ) + username = "DEFAULT" + token = os.getenv(ENV_TOKEN_NAME) + + else: + if _check_browsers() and not self.configure_with_pat: + print( + "The CLI will use its web-based authentication to log you in.\n" + "If you prefer to supply a Personal Access Token," + "use `linode-cli configure --token`." + ) + input( + "Press enter to continue. " + "This will open a browser and proceed with authentication." + ) + username, config["token"] = _get_token_web(self.base_url) + else: + username, config["token"] = _get_token_terminal(self.base_url) + token = config["token"] + + print(f"\nConfiguring {username}\n") + + # Configuring Defaults + regions = [ + r["id"] for r in _do_get_request(self.base_url, "/regions")["data"] + ] + types = [ + t["id"] + for t in _do_get_request(self.base_url, "/linode/types")["data"] + ] + images = [ + i["id"] for i in _do_get_request(self.base_url, "/images")["data"] + ] + + is_full_access = _check_full_access(self.base_url, token) + + auth_users = [] + + if is_full_access: + users = _do_get_request( + self.base_url, + "/account/users", + token=token, + # Allow 401 responses so tokens without + # account perms can be configured + status_validator=lambda status: status == 401, + ) + + if "data" in users: + auth_users = [ + u["username"] for u in users["data"] if "ssh_keys" in u + ] + + # get the preferred things + config["region"] = _default_thing_input( + "Default Region for operations.", + regions, + "Default Region (Optional): ", + "Please select a valid Region, or press Enter to skip", + current_value=_config_get_with_default( + self.config, username, "region" + ), + ) + + config["type"] = _default_thing_input( + "Default Type of Linode to deploy.", + types, + "Default Type of Linode (Optional): ", + "Please select a valid Type, or press Enter to skip", + current_value=_config_get_with_default( + self.config, username, "type" + ), + ) + + config["image"] = _default_thing_input( + "Default Image to deploy to new Linodes.", + images, + "Default Image (Optional): ", + "Please select a valid Image, or press Enter to skip", + current_value=_config_get_with_default( + self.config, username, "image" + ), + ) + + if auth_users: + config["authorized_users"] = _default_thing_input( + "Select the user that should be given default SSH access to new Linodes.", + auth_users, + "Default Option (Optional): ", + "Please select a valid Option, or press Enter to skip", + current_value=_config_get_with_default( + self.config, username, "authorized_users" + ), + ) + + if _bool_input("Configure a custom API target?", default=False): + self._configure_api_target(config) + + # save off the new configuration + if username != "DEFAULT" and not self.config.has_section(username): + self.config.add_section(username) + + if not is_default: + if username != self.default_username(): + is_default = _bool_input( + "Make this user the default when using the CLI?" + ) + + if not is_default: # they didn't change the default user + print( + f"Active user will remain {self.config.get('DEFAULT', 'default-user')}" + ) + + if is_default: + # if this is the default user, make it so + self.config.set("DEFAULT", "default-user", username) + print(f"Active user is now {username}") + + for k, v in config.items(): + if v is None: + if self.config.has_option(username, k): + self.config.remove_option(username, k) + + continue + + self.config.set(username, k, v) + + self.write_config() + os.chmod(_get_config_path(), 0o600) + self._configured = True + + @staticmethod + def _configure_api_target(config: Dict[str, Any]): + """ + Configure the API target with custom parameters. + + :param config: A dictionary containing the configuration parameters for the API target. + :type config: Dict[str, Any] + """ + config["api_host"] = _default_text_input( + "NOTE: Skipping this field will use the default Linode API host.\n" + 'API host override (e.g. "api.dev.linode.com")', + optional=True, + ) + + config["api_version"] = _default_text_input( + "NOTE: Skipping this field will use the default Linode API version.\n" + 'API version override (e.g. "v4beta")', + optional=True, + ) + + config["api_scheme"] = _default_text_input( + "NOTE: Skipping this field will use the HTTPS scheme.\n" + 'API scheme override (e.g. "https")', + optional=True, + ) + + def _handle_no_default_user(self): # pylint: disable=too-many-branches + """ + Handles the case where there is no default user in the config. + """ + users = [c for c in self.config.sections() if c != "DEFAULT"] + + if len(users) == 1: + # only one user configured - they're the default + self.config.set("DEFAULT", "default-user", users[0]) + self.write_config() + return + + if len(users) == 0: + # config is new or _really_ old + token = self.config.get("DEFAULT", "token") + + if token is not None: + # there's a token in the config - configure that user + u = _do_get_request( + self.base_url, "/profile", token=token, exit_on_error=False + ) + + if "errors" in u: + # this token was bad - reconfigure + self.configure() + return + + # setup config for this user + username = u["username"] + + self.config.set("DEFAULT", "default-user", username) + self.config.add_section(username) + self.config.set(username, "token", token) + + config_keys = ( + "region", + "type", + "image", + "mysql_engine", + "postgresql_engine", + "authorized_keys", + "api_host", + "api_version", + "api_scheme", + ) + + for key in config_keys: + if not self.config.has_option("DEFAULT", key): + continue + + self.config.set( + username, key, self.config.get("DEFAULT", key) + ) + + self.write_config() + else: + # got nothin', reconfigure + self.configure() + + # this should be handled + return + + # more than one user - prompt for the default + print("Please choose the active user. Configured users are:") + for u in users: + print(f" {u}") + print() + + while True: + username = input("Active user: ") + + if username in users: + self.config.set("DEFAULT", "default-user", username) + self.write_config() + return + print(f"No user {username}") diff --git a/linodecli/configuration/helpers.py b/linodecli/configuration/helpers.py index a63967d26..d80f377ca 100644 --- a/linodecli/configuration/helpers.py +++ b/linodecli/configuration/helpers.py @@ -5,9 +5,7 @@ import configparser import os import webbrowser -from typing import Any, Callable, Optional - -from .auth import _do_get_request +from typing import Any, Callable, List, Optional LEGACY_CONFIG_NAME = ".linode-cli" LEGACY_CONFIG_DIR = os.path.expanduser("~") @@ -33,9 +31,12 @@ } -def _get_config_path(): +def _get_config_path() -> str: """ Returns the path to the config file. + + :returns: The path to the local config file. + :rtype: str """ path = f"{LEGACY_CONFIG_DIR}/{LEGACY_CONFIG_NAME}" if os.path.exists(path): @@ -44,7 +45,7 @@ def _get_config_path(): return f"{CONFIG_DIR}/{CONFIG_NAME}" -def _get_config(load=True): +def _get_config(load: bool = True): """ Returns a new ConfigParser object that represents the CLI's configuration. If load is false, we won't load the config from disk. @@ -52,6 +53,9 @@ def _get_config(load=True): :param load: If True, load the config from the default path. Otherwise, don't (and just return an empty ConfigParser) :type load: bool + + :returns: The loaded config parser. + :rtype: configparser.ConfigParser """ conf = configparser.ConfigParser() @@ -61,7 +65,13 @@ def _get_config(load=True): return conf -def _check_browsers(): +def _check_browsers() -> bool: + """ + Checks if any browsers on the local machine are installed and usable. + + :returns: Whether at least one known-working browser is found. + :rtype: bool + """ # let's see if we _can_ use web try: webbrowser.get() @@ -84,12 +94,34 @@ def _check_browsers(): def _default_thing_input( - ask, things, prompt, error, optional=True, current_value=None + ask: str, + things: List[Any], + prompt: str, + error: str, + optional: bool = True, + current_value: Optional[Any] = None, ): # pylint: disable=too-many-arguments """ Requests the user choose from a list of things with the given prompt and - error if they choose something invalid. If optional, the user may hit + error if they choose something invalid. If optional, the user may hit enter to not configure this option. + + :param ask: The initial question to ask the user. + :type ask: str + :param things: A list of options for the user to choose from. + :type things: List[Any] + :param prompt: The prompt to show before the user input. + :type prompt: str + :param error: The error to display if a user's input is invalid. + :type error: str + :param optional: Whether this prompt is optional. Defaults to True. + :type optional: bool + :param current_value: The current value of the corresponding field, + allowing users to leave a config value unchanged. + :type current_value: str + + :returns: The user's selected option. + :rtype: Any """ print(f"\n{ask} Choices are:") @@ -140,13 +172,25 @@ def _default_thing_input( def _default_text_input( ask: str, - default: str = None, + default: Optional[str] = None, optional: bool = False, validator: Callable[[str], Optional[str]] = None, ) -> Optional[str]: # pylint: disable=too-many-arguments """ Requests the user to enter a certain string of text with the given prompt. If optional, the user may hit enter to not configure this option. + + :param ask: The initial question to ask the user. + :type ask: str + :param default: The default value for this input. + :type default: Optional[str] + :param optional: Whether this prompt is optional. + :type optional: bool + :param validator: A function to validate the user's input with. + :type validator: Callable[[str], Optional[str]] + + :returns: The user's input. + :rtype: str """ prompt_text = f"\n{ask} " @@ -184,9 +228,17 @@ def _default_text_input( def _bool_input( prompt: str, default: bool = True -): # pylint: disable=too-many-arguments +) -> bool: # pylint: disable=too-many-arguments """ Requests the user to enter either `y` or `n` given a prompt. + + :param prompt: The prompt to ask the user. + :type prompt: str + :param default: The default value for this input. Defaults to True. + :type default: bool + + :returns: The user's input. + :rtype: bool """ while True: user_input = input(f"\n{prompt} [y/N]: ").strip().lower() @@ -206,86 +258,20 @@ def _config_get_with_default( user: str, field: str, default: Any = None, -) -> Optional[Any]: +) -> Any: """ Gets a ConfigParser value and returns a default value if the key isn't found. + + :param user: The user to get a value for. + :type user: str + :param field: The name of the field to get the value for. + :type field: str + :param default: The default value to use if a value isn't found. Defaults to None. + :type default: Any + + :returns: The value pulled from the config or the default value. + :rtype: Any """ return ( config.get(user, field) if config.has_option(user, field) else default ) - - -def _handle_no_default_user(self): # pylint: disable=too-many-branches - """ - Handle the case that there is no default user in the config - """ - users = [c for c in self.config.sections() if c != "DEFAULT"] - - if len(users) == 1: - # only one user configured - they're the default - self.config.set("DEFAULT", "default-user", users[0]) - self.write_config() - return - - if len(users) == 0: - # config is new or _really_ old - token = self.config.get("DEFAULT", "token") - - if token is not None: - # there's a token in the config - configure that user - u = _do_get_request( - self.base_url, "/profile", token=token, exit_on_error=False - ) - - if "errors" in u: - # this token was bad - reconfigure - self.configure() - return - - # setup config for this user - username = u["username"] - - self.config.set("DEFAULT", "default-user", username) - self.config.add_section(username) - self.config.set(username, "token", token) - - config_keys = ( - "region", - "type", - "image", - "mysql_engine", - "postgresql_engine", - "authorized_keys", - "api_host", - "api_version", - "api_scheme", - ) - - for key in config_keys: - if not self.config.has_option("DEFAULT", key): - continue - - self.config.set(username, key, self.config.get("DEFAULT", key)) - - self.write_config() - else: - # got nothin', reconfigure - self.configure() - - # this should be handled - return - - # more than one user - prompt for the default - print("Please choose the active user. Configured users are:") - for u in users: - print(f" {u}") - print() - - while True: - username = input("Active user: ") - - if username in users: - self.config.set("DEFAULT", "default-user", username) - self.write_config() - return - print(f"No user {username}") diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index fe4e400b7..25529bdfb 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -278,7 +278,9 @@ def mock_input(prompt): patch("linodecli.configuration.open", mock_open()), patch("builtins.input", mock_input), contextlib.redirect_stdout(io.StringIO()), - patch("linodecli.configuration._check_browsers", lambda: False), + patch( + "linodecli.configuration.config._check_browsers", lambda: False + ), patch.dict(os.environ, {}, clear=True), requests_mock.Mocker() as m, ): diff --git a/wiki/development/Development - Skeleton.md b/wiki/development/Development - Skeleton.md index b47d4df7c..145f49ffc 100644 --- a/wiki/development/Development - Skeleton.md +++ b/wiki/development/Development - Skeleton.md @@ -8,8 +8,9 @@ The following section outlines the purpose of each file in the CLI. * `request.py` - Contains the `OpenAPIRequest` and `OpenAPIRequestArg` classes * `response.py` - Contains `OpenAPIResponse` and `OpenAPIResponseAttr` classes * `configuration` - * `__init__.py` - Contains the `CLIConfig` class and the logic for the interactive configuration prompt + * `__init__.py` - Contains imports for certain classes in this package * `auth.py` - Contains all the logic for the token generation OAuth workflow + * `config.py` - Contains all the logic for loading, updating, and saving CLI configs * `helpers.py` - Contains various config-related helpers * `plugins` * `__init__.py` - Contains the shared wrapper that allows plugins to access CLI functionality From 98cc51c7b3fca7b6148c3b49f3382a81e29149a8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 12 Apr 2024 10:30:31 -0400 Subject: [PATCH 18/23] ref: Refactor `plugins/__init__.py` for maintainability (#599) --- linodecli/arg_helpers.py | 4 +- linodecli/plugins/__init__.py | 115 +-------------- linodecli/plugins/plugins.py | 162 +++++++++++++++++++++ tests/unit/test_arg_helpers.py | 4 +- wiki/development/Development - Skeleton.md | 3 +- 5 files changed, 170 insertions(+), 118 deletions(-) create mode 100644 linodecli/plugins/plugins.py diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 24a850f9b..9210f0fa2 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -177,7 +177,7 @@ def register_plugin(module, config, ops): msg = "Plugin name conflicts with CLI operation - registration failed." return msg, 12 - if plugin_name in plugins.available_local: + if plugin_name in plugins.AVAILABLE_LOCAL: msg = "Plugin name conflicts with internal CLI plugin - registration failed." return msg, 13 @@ -219,7 +219,7 @@ def remove_plugin(plugin_name, config): """ Remove a plugin """ - if plugin_name in plugins.available_local: + if plugin_name in plugins.AVAILABLE_LOCAL: msg = f"{plugin_name} is bundled with the CLI and cannot be removed" return msg, 13 diff --git a/linodecli/plugins/__init__.py b/linodecli/plugins/__init__.py index 625f01cf5..ab1667cc6 100644 --- a/linodecli/plugins/__init__.py +++ b/linodecli/plugins/__init__.py @@ -1,116 +1,5 @@ """ -Initialize plugins for the CLI +Init file for the Linode CLI plugins package. """ -import sys -from argparse import ArgumentParser -from importlib import import_module -from os import listdir -from os.path import dirname -from pathlib import Path - -from linodecli.cli import CLI -from linodecli.helpers import register_args_shared - -this_file = Path(__file__) -reserved_files = {this_file} - - -def is_single_file_plugin(f: Path): - """ - Determine if the file is a single-file plugin. - """ - return f.suffix == ".py" - - -def is_module_plugin(f: Path): - """ - Determine if the file is a module (directory) based plugin. - """ - return f.is_dir() and f.name[:1] != "_" - - -def is_plugin(f: Path): - """ - Determine if the file is a linode-cli plugin. - """ - if f in reserved_files: - return False - return is_module_plugin(f) or is_single_file_plugin(f) - - -available_local = [ - f.stem for f in Path.iterdir(this_file.parent) if is_plugin(f) -] - - -def available(config): - """ - Returns a list of plugins that are available - """ - additional = [] - if config.config.has_option("DEFAULT", "registered-plugins"): - registered_plugins = config.config.get("DEFAULT", "registered-plugins") - - additional = registered_plugins.split(",") - - return available_local + additional - - -def invoke(name, args, context): - """ - Given the plugin name, executes a plugin - """ - # setup config to know what plugin is running - context.client.config.running_plugin = name - - if name in available_local: - plugin = import_module("linodecli.plugins." + name) - plugin.call(args, context) - elif name in available(context.client.config): - # this is a third-party plugin - try: - plugin_module_name = context.client.config.config.get( - "DEFAULT", f"plugin-name-{name}" - ) - except KeyError: - print(f"Plugin {name} is misconfigured - please re-register it") - sys.exit(9) - try: - plugin = import_module(plugin_module_name) - except ImportError: - print( - f"Expected module '{plugin_module_name}' not found. " - "Either {name} is misconfigured, or the backing module was uninstalled." - ) - sys.exit(10) - plugin.call(args, context) - else: - raise ValueError("No plugin named {name}") - - -class PluginContext: # pylint: disable=too-few-public-methods - """ - This class contains all context information provided to plugins when invoked. - This includes access to the underlying CLI object to access the user's account, - and the CLI access token the user has provided. - """ - - def __init__(self, token: str, client: CLI): - """ - Constructs a new PluginContext with the given information - """ - self.token = token - self.client = client - - -def inherit_plugin_args(parser: ArgumentParser): - """ - This function allows plugin-defined ArgumentParsers to inherit - certain CLI configuration arguments (`--as-user`, etc.). - - These arguments will be automatically applied to the CLI instance - provided in the PluginContext object. - """ - - return register_args_shared(parser) +from .plugins import * diff --git a/linodecli/plugins/plugins.py b/linodecli/plugins/plugins.py new file mode 100644 index 000000000..f658750db --- /dev/null +++ b/linodecli/plugins/plugins.py @@ -0,0 +1,162 @@ +""" +Initialize plugins for the CLI +""" + +import sys +from argparse import ArgumentParser +from importlib import import_module +from pathlib import Path +from typing import List + +from linodecli.cli import CLI +from linodecli.configuration import CLIConfig +from linodecli.helpers import register_args_shared + +THIS_FILE = Path(__file__) + +# Contains a list of files/directories to ignore +# when searching for plugins. +RESERVED_FILES = {THIS_FILE, THIS_FILE.parent / "__init__.py"} + + +class PluginContext: # pylint: disable=too-few-public-methods + """ + This class contains all context information provided to plugins when invoked. + This includes access to the underlying CLI object to access the user's account, + and the CLI access token the user has provided. + """ + + def __init__(self, token: str, client: CLI): + """ + Constructs a new PluginContext with the given information + """ + self.token = token + self.client = client + + +def is_single_file_plugin(f: Path) -> bool: + """ + Determine if the file is a single-file plugin. + + :param f: The path of the file to identify. + :type f: Path + + :returns: Whether the given file is a single-file plugin. + :rtype: bool + """ + return f.suffix == ".py" + + +def is_module_plugin(f: Path) -> bool: + """ + Determine if the file is a module (directory) based plugin. + + :param f: The path of the file to identify. + :type f: Path + + :returns: Whether the given file is a module plugin. + :rtype: bool + """ + return f.is_dir() and f.name[:1] != "_" + + +def is_plugin(f: Path) -> bool: + """ + Determine if the file is a linode-cli plugin. + + :param f: The path of the file to validate against. + :type f: Path + + :returns: Whether the given file is a plugin. + :rtype: bool + """ + if f in RESERVED_FILES: + return False + + return is_module_plugin(f) or is_single_file_plugin(f) + + +AVAILABLE_LOCAL = [ + f.stem for f in Path.iterdir(THIS_FILE.parent) if is_plugin(f) +] + + +def available(config: CLIConfig) -> List[str]: + """ + Returns a list of plugins that are available. + + :param config: The Linode CLI config to reference. + :type config: CLIConfig + + :returns: A list of all available plugins. + :rtype: List[str] + """ + + additional = [] + + if config.config.has_option("DEFAULT", "registered-plugins"): + registered_plugins = config.config.get("DEFAULT", "registered-plugins") + + additional = registered_plugins.split(",") + + return AVAILABLE_LOCAL + additional + + +def invoke(name: str, args: List[str], context: PluginContext): + """ + Invokes a plugin based on the given name, arguments, and plugin context. + + :param name: The name of the plugin to invoke. + :type name: str + :param args: A list of arguments passed into the plugin CLI. + :type args: List[str] + :param context: The PluginContext containing data about the CLI, configuration, etc. + :type context: PluginContext + @param args: A list of string arguments passed to the plugin. + """ + # setup config to know what plugin is running + context.client.config.running_plugin = name + + if name in AVAILABLE_LOCAL: + # If this is a local plugin, import it with the adjusted module prefix + plugin = import_module("linodecli.plugins." + name) + elif name in available(context.client.config): + # If this is a third-party plugin, retrieve its module name from the config + try: + plugin_module_name = context.client.config.config.get( + "DEFAULT", f"plugin-name-{name}" + ) + except KeyError: + print(f"Plugin {name} is misconfigured - please re-register it") + sys.exit(9) + + try: + plugin = import_module(plugin_module_name) + except ImportError: + print( + f"Expected module '{plugin_module_name}' not found. " + "Either {name} is misconfigured, or the backing module was uninstalled." + ) + sys.exit(10) + else: + raise ValueError("No plugin named {name}") + + plugin.call(args, context) + + +def inherit_plugin_args(parser: ArgumentParser) -> ArgumentParser: + """ + This function allows plugin-defined ArgumentParsers to inherit + certain CLI configuration arguments (`--as-user`, etc.). + + These arguments will be automatically applied to the CLI instance + provided in the PluginContext object. + + :param parser: The argument parser to be supplied shared arguments. + :type parser: ArgumentParser + + :returns: The updated argument parser. + :rtype: ArgumentParser + """ + + return register_args_shared(parser) diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index 9b4fb55f1..f77d5f8fc 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -70,7 +70,7 @@ def test_register_plugin_in_available_local( "linodecli.arg_helpers.import_module", return_value=module_mocker ) mocker.patch( - "linodecli.arg_helpers.plugins.available_local", ["testing.plugin"] + "linodecli.arg_helpers.plugins.AVAILABLE_LOCAL", ["testing.plugin"] ) msg, code = arg_helpers.register_plugin("a", mocked_config, {}) assert "conflicts with internal CLI plugin" in msg @@ -140,7 +140,7 @@ def test_remove_plugin_success(self, mocker, mocked_config): def test_remove_plugin_in_available_local(self, mocker, mocked_config): mocker.patch( - "linodecli.arg_helpers.plugins.available_local", ["testing.plugin"] + "linodecli.arg_helpers.plugins.AVAILABLE_LOCAL", ["testing.plugin"] ) msg, code = arg_helpers.remove_plugin("testing.plugin", mocked_config) assert "cannot be removed" in msg diff --git a/wiki/development/Development - Skeleton.md b/wiki/development/Development - Skeleton.md index 145f49ffc..ff9633d77 100644 --- a/wiki/development/Development - Skeleton.md +++ b/wiki/development/Development - Skeleton.md @@ -13,7 +13,8 @@ The following section outlines the purpose of each file in the CLI. * `config.py` - Contains all the logic for loading, updating, and saving CLI configs * `helpers.py` - Contains various config-related helpers * `plugins` - * `__init__.py` - Contains the shared wrapper that allows plugins to access CLI functionality + * `__init__.py` - Contains imports for certain classes in this package + * `plugins.py` - Contains the shared wrapper that allows plugins to access CLI functionality * `__init__.py` - Contains the main entrypoint for the CLI; routes top-level commands to their corresponding functions * `__main__.py` - Calls the project entrypoint in `__init__.py` * `api_request.py` - Contains logic for building API request bodies, making API requests, and handling API responses/errors From 9f7ebc8e29883c4675915ce888ef99f43a03285f Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:50:22 -0700 Subject: [PATCH 19/23] test: Update outdated backup test and address test warnings messages (#600) Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- Makefile | 6 +- tests/integration/conftest.py | 7 +++ tests/integration/linodes/helpers_linodes.py | 62 ++++++++++++++++++-- tests/integration/linodes/test_backups.py | 30 +++++++++- tod_scripts | 2 +- 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 545e1e6d5..2da296e9c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # # Makefile for more convenient building of the Linode CLI and its baked content # -INTEGRATION_TEST_PATH := +MODULE := TEST_CASE_COMMAND := ifdef TEST_CASE @@ -62,7 +62,7 @@ testunit: .PHONY: testint testint: - pytest tests/integration/${INTEGRATION_TEST_PATH} ${TEST_CASE_COMMAND} --disable-warnings + pytest tests/integration/${MODULE} ${TEST_CASE_COMMAND} .PHONY: testall testall: @@ -89,4 +89,4 @@ format: black isort autoflake @PHONEY: smoketest smoketest: - pytest -m smoke tests/integration --disable-warnings + pytest -m smoke tests/integration diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e85c06e88..e69dc14b2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -443,3 +443,10 @@ def create_vpc_w_subnet(): )[0] return vpc_json + + +@pytest.mark.smoke +def pytest_configure(config): + config.addinivalue_line( + "markers", "smoke: mark test as part of smoke test suite" + ) diff --git a/tests/integration/linodes/helpers_linodes.py b/tests/integration/linodes/helpers_linodes.py index 546078e6c..a0519a96f 100644 --- a/tests/integration/linodes/helpers_linodes.py +++ b/tests/integration/linodes/helpers_linodes.py @@ -99,6 +99,38 @@ def create_linode(test_region=DEFAULT_REGION): return linode_id +def create_linode_backup_disabled(test_region=DEFAULT_REGION): + result = set_backups_enabled_in_account_settings(toggle=False) + + # create linode + linode_id = ( + exec_test_command( + [ + "linode-cli", + "linodes", + "create", + "--type", + DEFAULT_LINODE_TYPE, + "--region", + test_region, + "--image", + DEFAULT_TEST_IMAGE, + "--root_pass", + DEFAULT_RANDOM_PASS, + "--format=id", + "--text", + "--no-headers", + "--backups_enabled", + "false", + ] + ) + .stdout.decode() + .rstrip() + ) + + return linode_id + + def shutdown_linodes(): linode_ids = ( exec_test_command( @@ -205,10 +237,30 @@ def create_linode_and_wait( ) linode_id = output - # wait until linode is running - assert ( - wait_until(linode_id=linode_id, timeout=240, status="running"), - "linode failed to change status to running", - ) + # wait until linode is running, wait_until returns True when it is in running state + result = (wait_until(linode_id=linode_id, timeout=240, status="running"),) + + assert result, "linode failed to change status to running" return linode_id + + +def set_backups_enabled_in_account_settings(toggle: bool): + command = [ + "linode-cli", + "account", + "settings-update", + "--format", + "backups_enabled", + "--text", + "--no-headers", + ] + + if toggle: + command.extend(["--backups_enabled", "true"]) + else: + command.extend(["--backups_enabled", "false"]) + + result = exec_test_command(command).stdout.decode().rstrip() + + return result diff --git a/tests/integration/linodes/test_backups.py b/tests/integration/linodes/test_backups.py index 00226630b..c0fdbf60b 100755 --- a/tests/integration/linodes/test_backups.py +++ b/tests/integration/linodes/test_backups.py @@ -8,6 +8,8 @@ BASE_CMD, create_linode, create_linode_and_wait, + create_linode_backup_disabled, + set_backups_enabled_in_account_settings, ) # ################################################################## @@ -26,12 +28,32 @@ def create_linode_setup(): delete_target_id("linodes", linode_id) -def test_create_linode_with_backup_disabled(create_linode_setup): - linode_id = create_linode_setup +@pytest.fixture +def create_linode_backup_disabled_setup(): + res = set_backups_enabled_in_account_settings(toggle=False) + + if res == "True": + raise ValueError( + "Backups are unexpectedly enabled before setting up the test." + ) + + linode_id = create_linode_backup_disabled() + + yield linode_id + + delete_target_id("linodes", linode_id) + + +def test_create_linode_with_backup_disabled( + create_linode_backup_disabled_setup, +): + linode_id = create_linode_backup_disabled_setup result = exec_test_command( BASE_CMD + [ "list", + "--id", + linode_id, "--format=id,enabled", "--delimiter", ",", @@ -42,6 +64,10 @@ def test_create_linode_with_backup_disabled(create_linode_setup): assert re.search(linode_id + ",False", result) + result = set_backups_enabled_in_account_settings(toggle=True) + + assert "True" in result + @pytest.mark.smoke def test_enable_backups(create_linode_setup): diff --git a/tod_scripts b/tod_scripts index eec4b9955..41b85dd2c 160000 --- a/tod_scripts +++ b/tod_scripts @@ -1 +1 @@ -Subproject commit eec4b99557cef6f40e8b5b7de00357dc49fb041c +Subproject commit 41b85dd2c5588b5b343b8ee365b2f4f196cd9a7f From 7ebdb7c2a6620a0d2876afd934ba4a168dfa220a Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:51:50 -0400 Subject: [PATCH 20/23] ref: Refactor `baked/operation.py` (#601) --- linodecli/baked/operation.py | 139 ++++++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 18 deletions(-) diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index a104710be..de2aad2c0 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -11,19 +11,27 @@ from collections import defaultdict from getpass import getpass from os import environ, path -from typing import Any, List, Tuple +from typing import Any, Dict, List, Tuple +import openapi3.paths from openapi3.paths import Operation from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest from linodecli.baked.response import OpenAPIResponse +from linodecli.output import OutputHandler from linodecli.overrides import OUTPUT_OVERRIDES -def parse_boolean(value): +def parse_boolean(value: str) -> bool: """ A helper to allow accepting booleans in from argparse. This is intended to be passed to the `type=` kwarg for ArgumentParser.add_argument. + + :param value: The value to be parsed into boolean. + :type value: str + + :returns: The boolean value of the input. + :rtype: bool """ if value.lower() in ("yes", "true", "y", "1"): return True @@ -32,10 +40,16 @@ def parse_boolean(value): raise argparse.ArgumentTypeError("Expected a boolean value") -def parse_dict(value): +def parse_dict(value: str) -> dict: """ A helper function to decode incoming JSON data as python dicts. This is intended to be passed to the `type=` kwarg for ArgumentParaser.add_argument. + + :param value: The json string to be parsed into dict. + :type value: str + + :returns: The dict value of the input. + :rtype: dict """ if not isinstance(value, str): raise argparse.ArgumentTypeError("Expected a JSON string") @@ -68,10 +82,16 @@ class ExplicitEmptyListValue: """ -def wrap_parse_nullable_value(arg_type): +def wrap_parse_nullable_value(arg_type: str) -> TYPES: """ A helper function to parse `null` as None for nullable CLI args. This is intended to be called and passed to the `type=` kwarg for ArgumentParser.add_argument. + + :param arg_type: The arg type. + :type arg_type: str + + :returns: The nullable value of the type. + :rtype: TYPES """ def type_func(value): @@ -93,7 +113,13 @@ class ArrayAction(argparse.Action): empty lists using a singular "[]" argument value. """ - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: List, + option_string: str = None, + ): if getattr(namespace, self.dest) is None: setattr(namespace, self.dest, []) @@ -121,7 +147,13 @@ class ListArgumentAction(argparse.Action): lists in the output namespace. """ - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: List, + option_string: str = None, + ): if getattr(namespace, self.dest) is None: setattr(namespace, self.dest, []) @@ -149,7 +181,7 @@ def __call__(self, parser, namespace, values, option_string=None): adjacent_items = {k: getattr(namespace, k) for k in adjacent_keys} - # Find the deepest field so we can know if + # Find the deepest field, so we can know if # we're starting a new object. deepest_length = max(len(x) for x in adjacent_items.values()) @@ -175,7 +207,13 @@ class PasswordPromptAction(argparse.Action): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str, + option_string: str = None, + ): # if not provided on the command line, pull from the environment if it # exists at this key environ_key = f"LINODE_CLI_{self.dest.upper()}" @@ -202,7 +240,13 @@ class OptionalFromFileAction(argparse.Action): the file exists, otherwise it will fall back to using the provided value. """ - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str, + option_string: str = None, + ): if isinstance(values, str): input_path = path.expanduser(values) @@ -232,7 +276,7 @@ class OpenAPIOperationParameter: A parameter is a variable element of the URL path, generally an ID or slug """ - def __init__(self, parameter): + def __init__(self, parameter: openapi3.paths.Parameter): """ :param parameter: The Parameter object this is parsing values from :type parameter: openapi3.Parameter @@ -254,7 +298,7 @@ def __init__(self, command, operation: Operation, method, params): """ Wraps an openapi3.Operation object and handles pulling out values relevant to the Linode CLI. - .. note:: + note:: This function runs _before pickling! As such, this is the only place where the OpenAPI3 objects can be accessed safely (as they are not usable when unpickled!) @@ -341,16 +385,32 @@ def args(self): return self.request.attrs if self.request else [] @staticmethod - def _flatten_url_path(tag): + def _flatten_url_path(tag: str) -> str: + """ + Returns the lowercase of the tag to build up url path. Replace space with hyphen. + + :param tag: The tag value to be flattened. + :type tag: str + + :returns: The flattened tag. + :rtype: str + """ + new_tag = tag.lower() new_tag = re.sub(r"[^a-z ]", "", new_tag).replace(" ", "-") return new_tag def process_response_json( - self, json, handler + self, json: Dict[str, Any], handler: OutputHandler ): # pylint: disable=redefined-outer-name """ - Processes the response as JSON and prints + Processes the response as JSON and prints. + + :param json: The json response. + :type json: Dict[str, Any] + + :param handler: The CLI output handler. + :type handler: OutputHandler """ if self.response_model is None: return @@ -366,7 +426,14 @@ def process_response_json( json = self.response_model.fix_json(json) handler.print_response(self.response_model, json) - def _add_args_filter(self, parser): + def _add_args_filter(self, parser: argparse.ArgumentParser): + """ + Builds up filter args for GET operation. + + :param parser: The parser to use. + :type parser: ArgumentParser + """ + # build args for filtering filterable_args = [] for attr in self.response_model.attrs: @@ -404,7 +471,19 @@ def _add_args_filter(self, parser): help="Either “asc” or “desc”. Defaults to “asc”. Requires +order_by", ) - def _add_args_post_put(self, parser) -> List[Tuple[str, str]]: + def _add_args_post_put( + self, parser: argparse.ArgumentParser + ) -> List[Tuple[str, str]]: + """ + Builds up args for POST and PUT operations. + + :param parser: The parser to use. + :type parser: ArgumentParser + + :returns: A list of arguments. + :rtype: List[Tuple[str, str]] + """ + list_items = [] # build args for body JSON @@ -468,6 +547,9 @@ def _validate_parent_child_conflicts(self, parsed: argparse.Namespace): """ This method validates that no child arguments (e.g. --interfaces.purpose) are specified alongside their parent (e.g. --interfaces). + + :param parsed: The parsed arguments. + :type parsed: Namespace """ conflicts = defaultdict(list) @@ -508,8 +590,23 @@ def _validate_parent_child_conflicts(self, parsed: argparse.Namespace): @staticmethod def _handle_list_items( - list_items: List[Tuple[str, str]], parsed: Any + list_items: List[Tuple[str, str]], parsed: argparse.Namespace + ) -> ( + argparse.Namespace ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements + """ + Groups list items and parses nested list. + + :param list_items: The list items to be handled. + :type list_items: List[Tuple[str, str]] + + :param parsed: The parsed arguments. + :type parsed: argparse.Namespace + + :returns: The parsed arguments updated with the list items. + :rtype: argparse.Namespace + """ + lists = {} # group list items as expected @@ -582,10 +679,16 @@ def _handle_list_items( return parsed - def parse_args(self, args): + def parse_args(self, args: Any) -> argparse.Namespace: """ Given sys.argv after the operation name, parse args based on the params and args of this operation + + :param args: The arguments to be parsed. + :type args: Any + + :returns: The parsed arguments. + :rtype: Namespace """ # build an argparse From f41b13115070940b80e3db90afe0c12bf1e93803 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:32:39 -0400 Subject: [PATCH 21/23] ref: Move build configuration from `setup.py` to `pyproject.toml`; apply various repo/CI refactors (#602) --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/workflows/e2e-suite-pr.yml | 4 +- .github/workflows/e2e-suite.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .../{oci-build.yml => publish-oci.yml} | 21 ++-- .github/workflows/pull-request.yml | 2 +- .github/workflows/unit-tests.yml | 4 +- Dockerfile | 8 +- Jenkinsfile | 4 - MANIFEST.in | 3 - Makefile | 13 ++- linodecli/__init__.py | 16 +-- linodecli/arg_helpers.py | 2 - linodecli/completion.py | 13 --- linodecli/version.py | 5 + pyproject.toml | 45 +++++++++ requirements-dev.txt | 10 -- requirements.txt | 7 -- setup.py | 99 +------------------ tests/unit/test_arg_helpers.py | 25 ----- tests/unit/test_completion.py | 20 ---- wiki/Installation.md | 2 +- wiki/development/Development - Setup.md | 2 +- 23 files changed, 93 insertions(+), 218 deletions(-) rename .github/workflows/{oci-build.yml => publish-oci.yml} (74%) delete mode 100644 Jenkinsfile create mode 100644 linodecli/version.py delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 6409f7a7b..d5344a878 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -8,7 +8,7 @@ body: attributes: label: CLI Version description: What version of linode-cli are you running? `linode-cli -v` - placeholder: linode-cli 5.24.0 Built off spec version 4.138.0 + placeholder: linode-cli 5.24.0 Built from spec version 4.138.0 validations: required: true - type: textarea diff --git a/.github/workflows/e2e-suite-pr.yml b/.github/workflows/e2e-suite-pr.yml index 8c03db253..74c5a4390 100644 --- a/.github/workflows/e2e-suite-pr.yml +++ b/.github/workflows/e2e-suite-pr.yml @@ -68,7 +68,7 @@ jobs: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install .[dev,obj] - name: Install the CLI run: make install @@ -158,7 +158,7 @@ jobs: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install .[obj,dev] - name: Install the CLI run: make install diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index aacf5e283..4ffc7f2ac 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -33,7 +33,7 @@ jobs: run: pip install certifi -U - name: Install deps - run: pip install -r requirements.txt -r requirements-dev.txt + run: pip install .[obj,dev] - name: Install Package run: make install diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index a22274fca..c71ab50a9 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -21,7 +21,7 @@ jobs: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install .[obj,dev] - name: Install Linode CLI run: make install diff --git a/.github/workflows/oci-build.yml b/.github/workflows/publish-oci.yml similarity index 74% rename from .github/workflows/oci-build.yml rename to .github/workflows/publish-oci.yml index 1d4307467..1fe7f1583 100644 --- a/.github/workflows/oci-build.yml +++ b/.github/workflows/publish-oci.yml @@ -34,13 +34,18 @@ jobs: # This is necessary as we want to ensure that version tags # are properly formatted before passing them into the # DockerFile. - - name: Get CLI version - run: | - export CLI_VERSION=$(./version) - echo "CLI_VERSION=$CLI_VERSION" >> $GITHUB_OUTPUT - env: - LINODE_CLI_VERSION: ${{ github.event.release.tag_name }} + - uses: actions/github-script@v7 id: cli_version + with: + script: | + let tag_name = '${{ github.event.release.tag_name }}'; + + if (tag_name.startsWith("v")) { + tag_name = tag_name.slice(1); + } + + return tag_name; + result-encoding: string - name: Build and push to DockerHub uses: docker/build-push-action@2eb1c1961a95fc15694676618e422e8ba1d63825 # pin@v4.1.1 @@ -49,7 +54,7 @@ jobs: file: Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: linode/cli:${{ steps.cli_version.outputs.CLI_VERSION }},linode/cli:latest + tags: linode/cli:${{ steps.cli_version.outputs.result }},linode/cli:latest build-args: | - linode_cli_version=${{ steps.cli_version.outputs.CLI_VERSION }} + linode_cli_version=${{ steps.cli_version.outputs.result }} github_token=${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1b779a181..678e9598f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -16,7 +16,7 @@ jobs: - name: install boto3 run: pip3 install boto3 - name: install dependencies - run: pip3 install -r requirements-dev.txt -r requirements.txt + run: pip install .[obj,dev] - name: run linter run: make lint diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6375953db..e4119a022 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -25,7 +25,7 @@ jobs: run: pip install certifi -U - name: Install deps - run: pip install -r requirements.txt -r requirements-dev.txt + run: pip install .[dev] - name: Install Package run: make install @@ -53,7 +53,7 @@ jobs: run: pip install certifi -U - name: Install deps - run: pip install -r requirements.txt -r requirements-dev.txt + run: pip install .[dev] - name: Install Package shell: pwsh diff --git a/Dockerfile b/Dockerfile index 892ba8e3b..499343bc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,15 +5,13 @@ ARG github_token WORKDIR /src -COPY requirements.txt . - RUN apt-get update && \ - apt-get install -y make git && \ - pip3 install -r requirements.txt && \ - pip3 install build + apt-get install -y make git COPY . . +RUN make requirements + RUN LINODE_CLI_VERSION=$linode_cli_version GITHUB_TOKEN=$github_token make build FROM python:3.11-slim diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 184999cd9..000000000 --- a/Jenkinsfile +++ /dev/null @@ -1,4 +0,0 @@ -library 'cli-builder' - -buildCli() - diff --git a/MANIFEST.in b/MANIFEST.in index a89873bb4..65ea339f1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,2 @@ include linodecli/data-3 include linodecli/oauth-landing-page.html -include linode-cli.sh -include baked_version -include requirements.txt diff --git a/Makefile b/Makefile index 2da296e9c..53c2cb7e1 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,11 @@ ifndef SPEC override SPEC = $(shell ./resolve_spec_url ${SPEC_VERSION}) endif +# Version-related variables +VERSION_FILE := ./linodecli/version.py +VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of the Linode CLI.\n\"\"\"\n\n +LINODE_CLI_VERSION ?= "0.0.0.dev" + .PHONY: install install: check-prerequisites requirements build pip3 install --force dist/*.whl @@ -27,13 +32,17 @@ else cp data-3 linodecli/ endif +.PHONY: create-version +create-version: + @printf "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_CLI_VERSION}\"\n" > $(VERSION_FILE) + .PHONY: build -build: clean bake +build: clean create-version bake python3 -m build --wheel --sdist .PHONY: requirements requirements: - pip3 install --upgrade -r requirements.txt -r requirements-dev.txt + pip3 install --upgrade .[dev,obj] .PHONY: lint lint: build diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 7116e3410..db97605a7 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -21,17 +21,14 @@ remove_plugin, ) from .cli import CLI -from .completion import bake_completions, get_completions +from .completion import get_completions from .configuration import ENV_TOKEN_NAME from .help_pages import print_help_action, print_help_default from .helpers import handle_url_overrides from .output import OutputMode +from .version import __version__ -# this might not be installed at the time of building -try: - VERSION = version("linode-cli") -except: - VERSION = "building" +VERSION = __version__ BASE_URL = "https://api.linode.com/v4" @@ -112,7 +109,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if not parsed.command: # print version info and exit - but only if no command was given print(f"linode-cli {VERSION}") - print(f"Built off spec version {cli.spec_version}") + print(f"Built from spec version {cli.spec_version}") sys.exit(0) else: # something else might want to parse version @@ -208,11 +205,6 @@ def main(): # pylint: disable=too-many-branches,too-many-statements cli.config.remove_user(parsed.action) sys.exit(0) - # special command to bake shell completion script - if parsed.command == "bake-bash": - bake_completions(cli.ops) - sys.exit(0) - # check for plugin invocation if parsed.command not in cli.ops and parsed.command in plugins.available( cli.config diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 9210f0fa2..8cd242adf 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -11,7 +11,6 @@ import yaml from linodecli import plugins -from linodecli.completion import bake_completions from linodecli.helpers import ( pagination_args_shared, register_args_shared, @@ -263,4 +262,3 @@ def bake_command(cli, spec_loc): sys.exit(2) cli.bake(spec) - bake_completions(cli.ops) diff --git a/linodecli/completion.py b/linodecli/completion.py index acd9529b5..6a402eecf 100644 --- a/linodecli/completion.py +++ b/linodecli/completion.py @@ -7,19 +7,6 @@ from openapi3 import OpenAPI -def bake_completions(ops): - """ - Given a baked CLI, generates and saves a bash completion file - """ - if "_base_url" in ops: - del ops["_base_url"] - if "_spec_version" in ops: - del ops["_spec_version"] - rendered = get_bash_completions(ops) - with open("linode-cli.sh", "w", encoding="utf-8") as bash_f: - bash_f.write(rendered) - - def get_completions(ops, help_flag, action): """ Handle shell completions based on `linode-cli completion ____` diff --git a/linodecli/version.py b/linodecli/version.py new file mode 100644 index 000000000..ee9cc4095 --- /dev/null +++ b/linodecli/version.py @@ -0,0 +1,5 @@ +""" +The version of the Linode CLI. +""" + +__version__ = "0.0.0.dev" diff --git a/pyproject.toml b/pyproject.toml index 1b1607902..8cba2146a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,51 @@ requires = ["setuptools", "wheel", "packaging"] build-backend = "setuptools.build_meta" +[project] +name = "linode-cli" +authors = [{ name = "Akamai Technologies Inc.", email = "developers@linode.com" }] +description = "The official command-line interface for interacting with the Linode API." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "BSD-3-Clause" } +classifiers = [] +dependencies = [ + "openapi3", + "requests", + "PyYAML", + "packaging", + "rich", + "urllib3<3", + "linode-metadata>=0.3.0" +] +dynamic = ["version"] + +[project.optional-dependencies] +obj = ["boto3"] +dev = [ + "pylint>=2.17.4", + "pytest>=7.3.1", + "black>=23.1.0", + "isort>=5.12.0", + "autoflake>=2.0.1", + "pytest-mock>=3.10.0", + "requests-mock==1.12.1", + "boto3-stubs[s3]", + "build>=0.10.0", + "twine>=4.0.2" +] + +[project.scripts] +linode-cli = "linodecli:main" +linode = "linodecli:main" +lin = "linodecli:main" + +[tool.setuptools.dynamic] +version = { attr = "linodecli.version.__version__" } + +[tool.setuptools.packages.find] +include = ["linodecli*"] + [tool.isort] profile = "black" line_length = 80 diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 36b2b4f4b..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,10 +0,0 @@ -pylint>=2.17.4 -pytest>=7.3.1 -black>=23.1.0 -isort>=5.12.0 -autoflake>=2.0.1 -pytest-mock>=3.10.0 -requests-mock==1.12.1 -boto3-stubs[s3] -build>=0.10.0 -twine>=4.0.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 051edbcfa..000000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -openapi3 -requests -PyYAML -packaging -rich -urllib3<3 -linode-metadata>=0.3.0 diff --git a/setup.py b/setup.py index ddcf08cd7..606849326 100755 --- a/setup.py +++ b/setup.py @@ -1,98 +1,3 @@ -#!/usr/bin/env python3 -import os -import pathlib -import subprocess -import sys -import platform +from setuptools import setup -from setuptools import setup, find_packages -from os import path - -here = pathlib.Path().absolute() - - -ENV_LINODE_CLI_VERSION = "LINODE_CLI_VERSION" - -# get the long description from the README.md -with open(here / "README.md", encoding="utf-8") as f: - long_description = f.read() - - -def get_baked_files(): - """ - A helper to retrieve the baked files included with this package. This is - to assist with building from source, where baked files may not be present - on a fresh clone - """ - data_files = [] - - completion_dir = "/etc/bash_completion.d" - - if path.isfile("linode-cli.sh") and platform.system() != "Windows": - data_files.append((completion_dir, ["linode-cli.sh"])) - - return data_files - - - -def get_baked_version(): - """ - Attempts to read the version from the baked_version file - """ - with open("./baked_version", "r", encoding="utf-8") as f: - result = f.read() - - return result - - -def bake_version(v): - """ - Writes the given version to the baked_version file - """ - with open("./baked_version", "w", encoding="utf-8") as f: - f.write(v) - - -# If there's already a baked version, use it rather than attempting -# to resolve the version from env. -# This is useful for installing from an SDist where the version -# cannot be dynamically resolved. -# -# NOTE: baked_version is deleted when running `make build` and `make install`, -# so it should always be recreated during the build process. -if path.isfile("baked_version"): - version = get_baked_version() -else: - # Otherwise, retrieve and bake the version as normal - version = os.getenv(ENV_LINODE_CLI_VERSION) or "0.0.0" - bake_version(version) - -with open("requirements.txt") as f: - requirements = f.read().splitlines() - -setup( - name="linode-cli", - version=version, - description="CLI for the Linode API", - long_description=long_description, - long_description_content_type="text/markdown", - author="Linode", - author_email="developers@linode.com", - url="https://www.linode.com/docs/api/", - packages=find_packages(include=["linodecli*"]), - license="BSD 3-Clause License", - install_requires=requirements, - extras_require={ - "obj": ["boto3"], - }, - entry_points={ - "console_scripts": [ - "linode-cli = linodecli:main", - "linode = linodecli:main", - "lin = linodecli:main", - ] - }, - data_files=get_baked_files(), - python_requires=">=3.8", - include_package_data=True, -) +setup() diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index f77d5f8fc..e5ad7b3a8 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -157,28 +157,3 @@ def test_bake_command_bad_website(self, capsys, mock_cli): captured = capsys.readouterr() assert ex.value.code == 2 assert "Request failed to https://website.com" in captured.out - - def test_bake_command_good_website(self, capsys, mocker, mock_cli): - mock_cli.bake = print - mocker.patch("linodecli.completion.bake_completions") - - mock_res = mocker.MagicMock() - mock_res.status_code = 200 - mock_res.content = "yaml loaded" - mocker.patch("requests.get", return_value=mock_res) - mocker.patch("yaml.safe_load", return_value=mock_res.content) - - arg_helpers.bake_command(mock_cli, "realwebsite") - captured = capsys.readouterr() - assert "yaml loaded" in captured.out - - def test_bake_command_good_file(self, capsys, mocker, mock_cli): - mock_cli.bake = print - mocker.patch("linodecli.completion.bake_completions") - mocker.patch("os.path.exists", return_value=True) - mocker.patch("builtins.open", mocker.mock_open()) - mocker.patch("yaml.safe_load", return_value="yaml loaded") - - arg_helpers.bake_command(mock_cli, "real/file") - captured = capsys.readouterr() - assert "yaml loaded" in captured.out diff --git a/tests/unit/test_completion.py b/tests/unit/test_completion.py index 017a9c38c..912bdd095 100644 --- a/tests/unit/test_completion.py +++ b/tests/unit/test_completion.py @@ -3,8 +3,6 @@ Unit tests for linodecli.completion """ -from unittest.mock import mock_open, patch - from linodecli import completion @@ -77,21 +75,3 @@ def test_get_completions(self): actual = completion.get_completions(self.ops, True, "") assert "[SHELL]" in actual - - def test_bake_completions(self, mocker): - """ - Test bake_completions write to file - """ - m = mock_open() - with patch("linodecli.completion.open", m, create=True): - new_ops = self.ops - new_ops["_base_url"] = "bloo" - new_ops["_spec_version"] = "berry" - - completion.bake_completions(new_ops) - - assert "_base_url" not in new_ops - assert "_spec_version" not in new_ops - - m.assert_called_with("linode-cli.sh", "w", encoding="utf-8") - m.return_value.write.assert_called_once_with(self.bash_expected) diff --git a/wiki/Installation.md b/wiki/Installation.md index 99deac707..79db6635a 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -46,7 +46,7 @@ In order to successfully build the CLI, your system will require the following: - The `make` command - `python3` -- `pip3` (to install `requirements.txt`) +- `pip3` (to install project dependencies) Before attempting a build, install python dependencies like this:: ```bash diff --git a/wiki/development/Development - Setup.md b/wiki/development/Development - Setup.md index b789d82bb..bf667fcbe 100644 --- a/wiki/development/Development - Setup.md +++ b/wiki/development/Development - Setup.md @@ -60,7 +60,7 @@ linode-cli --version # Output: # linode-cli 0.0.0 -# Built off spec version 4.173.0 +# Built from spec version 4.173.0 # # The 0.0.0 implies this is a locally built version of the CLI ``` From 88f01045e79cefa6ac49d272e40dc5b3d3aa9017 Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:26:35 -0400 Subject: [PATCH 22/23] new: update pyenv to use environment name (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates `pyenv` to point to local environment setup by devenv. --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..dcb3e2ff5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +linode-cli From feecb3ade3b99abe1d3d75f6891684485d6c040a Mon Sep 17 00:00:00 2001 From: Vinay <143587840+vshanthe@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:01:41 +0530 Subject: [PATCH 23/23] Add test integration (#593) --- tests/integration/account/test_account.py | 132 +++++++-- tests/integration/beta/test_beta_program.py | 29 +- tests/integration/database/test_database.py | 62 ++++- tests/integration/helpers.py | 10 +- tests/integration/lke/test_clusters.py | 58 +++- tests/integration/longview/test_longview.py | 158 +++++++++++ tests/integration/managed/test_managed.py | 255 ++++++++++++++++++ tests/integration/obj/test_obj_bucket.py | 148 ++++++++++ .../regions/test_plugin_region_table.py | 83 +++++- 9 files changed, 882 insertions(+), 53 deletions(-) create mode 100644 tests/integration/longview/test_longview.py create mode 100644 tests/integration/managed/test_managed.py create mode 100644 tests/integration/obj/test_obj_bucket.py diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index d126e6b4c..6c52aee59 100644 --- a/tests/integration/account/test_account.py +++ b/tests/integration/account/test_account.py @@ -1,3 +1,5 @@ +import pytest + from tests.integration.helpers import assert_headers_in_lines, exec_test_command BASE_CMD = ["linode-cli", "account"] @@ -39,16 +41,36 @@ def test_event_list(): .rstrip() ) lines = res.splitlines() - - event_id = lines[1].split(",")[0] - headers = ["entity.label", "username"] assert_headers_in_lines(headers, lines) - return event_id -def test_event_view(): - event_id = test_event_list() +@pytest.fixture +def get_event_id(): + event_id = ( + exec_test_command( + [ + "linode-cli", + "events", + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = event_id[0].split(",")[0] + yield first_id + + +def test_event_view(get_event_id): + event_id = get_event_id res = ( exec_test_command( [ @@ -78,16 +100,35 @@ def test_account_invoice_list(): .rstrip() ) lines = res.splitlines() - - invoice_id = lines[1].split(",")[0] - headers = ["billing_source", "tax", "subtotal"] assert_headers_in_lines(headers, lines) - return invoice_id -def test_account_invoice_view(): - invoice_id = test_account_invoice_list() +@pytest.fixture +def get_invoice_id(): + invoice_id = ( + exec_test_command( + BASE_CMD + + [ + "invoices-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = invoice_id[0] + yield first_id + + +def test_account_invoice_view(get_invoice_id): + invoice_id = get_invoice_id res = ( exec_test_command( BASE_CMD + ["invoice-view", invoice_id, "--text", "--delimiter=,"] @@ -101,8 +142,8 @@ def test_account_invoice_view(): assert_headers_in_lines(headers, lines) -def test_account_invoice_items(): - invoice_id = test_account_invoice_list() +def test_account_invoice_items(get_invoice_id): + invoice_id = get_invoice_id res = ( exec_test_command( BASE_CMD + ["invoice-items", invoice_id, "--text", "--delimiter=,"] @@ -123,16 +164,35 @@ def test_account_logins_list(): .rstrip() ) lines = res.splitlines() - - login_id = lines[1].split(",")[0] - headers = ["ip", "username", "status"] assert_headers_in_lines(headers, lines) - return login_id -def test_account_login_view(): - login_id = test_account_logins_list() +@pytest.fixture +def get_login_id(): + login_id = ( + exec_test_command( + BASE_CMD + + [ + "logins-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = login_id[0] + yield first_id + + +def test_account_login_view(get_login_id): + login_id = get_login_id res = ( exec_test_command( BASE_CMD + ["login-view", login_id, "--text", "--delimiter=,"] @@ -167,16 +227,36 @@ def test_user_list(): .rstrip() ) lines = res.splitlines() - - user_id = lines[1].split(",")[0] - headers = ["email", "username"] assert_headers_in_lines(headers, lines) - return user_id -def test_user_view(): - user_id = test_user_list() +@pytest.fixture +def get_user_id(): + user_id = ( + exec_test_command( + [ + "linode-cli", + "users", + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = user_id[0].split(",")[0] + yield first_id + + +def test_user_view(get_user_id): + user_id = get_user_id res = ( exec_test_command( ["linode-cli", "users", "view", user_id, "--text", "--delimiter=,"] diff --git a/tests/integration/beta/test_beta_program.py b/tests/integration/beta/test_beta_program.py index ae87f3b45..4628de1c3 100644 --- a/tests/integration/beta/test_beta_program.py +++ b/tests/integration/beta/test_beta_program.py @@ -16,14 +16,35 @@ def test_beta_list(): if len(lines) < 2 or len(lines[1].split(",")) == 0: pytest.skip("No beta program available to test") else: - beta_id = lines[1].split(",")[0] headers = ["label", "description"] assert_headers_in_lines(headers, lines) - return beta_id -def test_beta_view(): - beta_id = test_beta_list() +@pytest.fixture +def get_beta_id(): + beta_id = ( + exec_test_command( + BASE_CMD + + [ + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = beta_id[0] + yield first_id + + +def test_beta_view(get_beta_id): + beta_id = get_beta_id if beta_id is None: pytest.skip("No beta program available to test") else: diff --git a/tests/integration/database/test_database.py b/tests/integration/database/test_database.py index aebaeda71..b98732553 100644 --- a/tests/integration/database/test_database.py +++ b/tests/integration/database/test_database.py @@ -16,16 +16,35 @@ def test_engines_list(): .rstrip() ) lines = res.splitlines() - - engine_id = lines[1].split(",")[0] - headers = ["id", "engine", "version"] assert_headers_in_lines(headers, lines) - return engine_id -def test_engines_view(): - engine_id = test_engines_list() +@pytest.fixture +def get_engine_id(): + engine_id = ( + exec_test_command( + BASE_CMD + + [ + "engines", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = engine_id[0] + yield first_id + + +def test_engines_view(get_engine_id): + engine_id = get_engine_id res = ( exec_test_command( BASE_CMD + ["engine-view", engine_id, "--text", "--delimiter=,"] @@ -90,16 +109,35 @@ def test_databases_types(): .rstrip() ) lines = res.splitlines() - - node_id = lines[1].split(",")[0] - headers = ["id", "label", "_split"] assert_headers_in_lines(headers, lines) - return node_id -def test_databases_type_view(): - node_id = test_databases_types() +@pytest.fixture +def get_node_id(): + node_id = ( + exec_test_command( + BASE_CMD + + [ + "types", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = node_id[0] + yield first_id + + +def test_databases_type_view(get_node_id): + node_id = get_node_id res = ( exec_test_command( BASE_CMD + ["type-view", node_id, "--text", "--delimiter=,"] diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 1e2676ca4..da52828d8 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -63,8 +63,9 @@ def delete_tag(arg: str): assert result.returncode == SUCCESS_STATUS_CODE -def delete_target_id(target: str, id: str): - result = exec_test_command(["linode-cli", target, "delete", id]) +def delete_target_id(target: str, id: str, subcommand: str = "delete"): + command = ["linode-cli", target, subcommand, id] + result = exec_test_command(command) assert result.returncode == SUCCESS_STATUS_CODE @@ -89,11 +90,6 @@ def remove_lke_clusters(): exec_test_command(["linode-cli", "lke", "cluster-delete", id]) -def delete_target_id(target: str, id: str): - result = exec_test_command(["linode-cli", target, "delete", id]) - assert result.returncode == SUCCESS_STATUS_CODE - - def remove_all(target: str): entity_ids = "" if target == "stackscripts": diff --git a/tests/integration/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index ba46c609b..927462874 100644 --- a/tests/integration/lke/test_clusters.py +++ b/tests/integration/lke/test_clusters.py @@ -2,7 +2,11 @@ import pytest -from tests.integration.helpers import exec_test_command, remove_lke_clusters +from tests.integration.helpers import ( + assert_headers_in_lines, + exec_test_command, + remove_lke_clusters, +) BASE_CMD = ["linode-cli", "lke"] @@ -64,3 +68,55 @@ def test_deploy_an_lke_cluster(): time.sleep(15) remove_lke_clusters() + + +def test_lke_cluster_list(): + res = ( + exec_test_command( + BASE_CMD + ["clusters-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "k8s_version"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def test_version_id(): + version_id = ( + exec_test_command( + BASE_CMD + + [ + "versions-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = version_id[0] + yield first_id + + +def test_beta_view(test_version_id): + version_id = test_version_id + res = ( + exec_test_command( + BASE_CMD + ["version-view", version_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/longview/test_longview.py b/tests/integration/longview/test_longview.py new file mode 100644 index 000000000..fd766cf00 --- /dev/null +++ b/tests/integration/longview/test_longview.py @@ -0,0 +1,158 @@ +import time + +import pytest + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, +) + +BASE_CMD = ["linode-cli", "longview"] + + +def test_create_longview_client(): + new_label = str(time.time_ns()) + "label" + exec_test_command( + BASE_CMD + + [ + "create", + "--label", + new_label, + "--text", + "--no-headers", + ] + ) + + +def test_longview_client_list(): + res = ( + exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["id", "label", "created"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_client_id(): + client_id = ( + exec_test_command( + BASE_CMD + + [ + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = client_id[0] + yield first_id + + +def test_client_view(get_client_id): + client_id = get_client_id + res = ( + exec_test_command( + BASE_CMD + ["view", client_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["id", "label", "created"] + assert_headers_in_lines(headers, lines) + + +def test_update_longview_client_list(get_client_id): + client_id = get_client_id + new_label = str(time.time_ns()) + "label" + updated_label = ( + exec_test_command( + BASE_CMD + + [ + "update", + client_id, + "--label", + new_label, + "--text", + "--no-headers", + "--format=label", + ] + ) + .stdout.decode() + .rstrip() + ) + assert new_label == updated_label + delete_target_id(target="longview", id=client_id) + + +def test_longview_plan_view(): + res = ( + exec_test_command(BASE_CMD + ["plan-view", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["id", "label", "clients_included"] + assert_headers_in_lines(headers, lines) + + +def test_longview_subscriptions_list(): + res = ( + exec_test_command( + BASE_CMD + ["subscriptions-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["id", "label", "clients_included"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_subscriptions_id(): + subscriptions_id = ( + exec_test_command( + BASE_CMD + + [ + "subscriptions-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = subscriptions_id[0] + yield first_id + + +def test_longview_subscriptions_list_view(get_subscriptions_id): + subscriptions_id = get_subscriptions_id + res = ( + exec_test_command( + BASE_CMD + + ["subscription-view", subscriptions_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["id", "label", "clients_included"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/managed/test_managed.py b/tests/integration/managed/test_managed.py new file mode 100644 index 000000000..c8e53478b --- /dev/null +++ b/tests/integration/managed/test_managed.py @@ -0,0 +1,255 @@ +import secrets +import time + +import pytest + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_random_text, +) + +BASE_CMD = ["linode-cli", "managed"] +unique_name = "test-user-" + str(int(time.time())) + + +def test_managed_contact_create(): + exec_test_command( + BASE_CMD + + [ + "contact-create", + "--name", + unique_name, + "--email", + unique_name + "@linode.com", + "--text", + "--no-headers", + ] + ) + + +def test_managed_contact_list(): + res = ( + exec_test_command( + BASE_CMD + ["contacts-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["name", "email", "group"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_contact_id(): + contact_id = ( + exec_test_command( + BASE_CMD + + [ + "contacts-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = contact_id[0] + yield first_id + + +def test_managed_contact_view(get_contact_id): + contact_id = get_contact_id + res = ( + exec_test_command( + BASE_CMD + ["contact-view", contact_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["name", "email", "group"] + assert_headers_in_lines(headers, lines) + + +def test_managed_contact_update(get_contact_id): + contact_id = get_contact_id + unique_name1 = str(time.time_ns()) + "test" + update_name = ( + exec_test_command( + BASE_CMD + + [ + "contact-update", + contact_id, + "--name", + unique_name1, + "--text", + "--no-headers", + "--format=name", + ] + ) + .stdout.decode() + .rstrip() + ) + assert update_name == unique_name1 + delete_target_id( + target="managed", subcommand="contact-delete", id=contact_id + ) + + +def test_managed_credential_create(): + label = "test-label" + secrets.token_hex(4) + password = get_random_text() + exec_test_command( + BASE_CMD + + [ + "credential-create", + "--label", + label, + "--username", + unique_name, + "--password", + password, + "--text", + "--no-headers", + ] + ) + + +def test_managed_credentials_list(): + res = ( + exec_test_command( + BASE_CMD + ["credentials-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "last_decrypted"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_credential_id(): + credential_id = ( + exec_test_command( + BASE_CMD + + [ + "credentials-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = credential_id[0] + yield first_id + + +def test_managed_credentials_view(get_credential_id): + credential_id = get_credential_id + res = ( + exec_test_command( + BASE_CMD + + ["credential-view", credential_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "last_decrypted"] + assert_headers_in_lines(headers, lines) + + +def test_managed_credentials_update(get_credential_id): + credential_id = get_credential_id + new_label = "test-label" + secrets.token_hex(4) + update_label = ( + exec_test_command( + BASE_CMD + + [ + "credential-update", + credential_id, + "--label", + new_label, + "--text", + "--no-headers", + "--format=label", + ] + ) + .stdout.decode() + .rstrip() + ) + assert update_label == new_label + delete_target_id( + target="managed", subcommand="credential-revoke", id=credential_id + ) + + +def test_managed_credentials_sshkey_view(): + res = ( + exec_test_command( + BASE_CMD + ["credential-sshkey-view", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["ssh_key"] + assert_headers_in_lines(headers, lines) + + +def test_managed_issues_list(): + res = ( + exec_test_command(BASE_CMD + ["issues-list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["created", "services"] + assert_headers_in_lines(headers, lines) + + +def test_managed_linode_settings_list(): + res = ( + exec_test_command( + BASE_CMD + ["linode-settings-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "group"] + assert_headers_in_lines(headers, lines) + + +def test_managed_linode_service_list(): + res = ( + exec_test_command( + BASE_CMD + ["services-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["service_type", "consultation_group"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/obj/test_obj_bucket.py b/tests/integration/obj/test_obj_bucket.py new file mode 100644 index 000000000..637a702f8 --- /dev/null +++ b/tests/integration/obj/test_obj_bucket.py @@ -0,0 +1,148 @@ +import time + +import pytest + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, +) + +BASE_CMD = ["linode-cli", "object-storage"] + + +def test_clusters_list(): + res = ( + exec_test_command( + BASE_CMD + ["clusters-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["domain", "status", "region"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_cluster_id(): + cluster_id = ( + exec_test_command( + BASE_CMD + + [ + "clusters-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = cluster_id[0] + yield first_id + + +def test_clusters_view(get_cluster_id): + cluster_id = get_cluster_id + res = ( + exec_test_command( + BASE_CMD + ["clusters-view", cluster_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["domain", "status", "region"] + assert_headers_in_lines(headers, lines) + + +def test_create_obj_storage_key(): + new_label = str(time.time_ns()) + "label" + exec_test_command( + BASE_CMD + + [ + "keys-create", + "--label", + new_label, + "--text", + "--no-headers", + ] + ) + + +def test_obj_storage_key_list(): + res = ( + exec_test_command(BASE_CMD + ["keys-list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "access_key", "secret_key"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_key_id(): + key_id = ( + exec_test_command( + BASE_CMD + + [ + "keys-list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = key_id[0] + yield first_id + + +def test_obj_storage_key_view(get_key_id): + key_id = get_key_id + res = ( + exec_test_command( + BASE_CMD + ["keys-view", key_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "access_key", "secret_key"] + assert_headers_in_lines(headers, lines) + + +def test_obj_storage_key_update(get_key_id): + key_id = get_key_id + new_label = str(time.time_ns()) + "label" + updated_label = ( + exec_test_command( + BASE_CMD + + [ + "keys-update", + key_id, + "--label", + new_label, + "--text", + "--no-headers", + "--format=label", + ] + ) + .stdout.decode() + .rstrip() + ) + assert new_label == updated_label + delete_target_id( + target="object-storage", subcommand="keys-delete", id=key_id + ) diff --git a/tests/integration/regions/test_plugin_region_table.py b/tests/integration/regions/test_plugin_region_table.py index 216d26d82..8d15788b3 100644 --- a/tests/integration/regions/test_plugin_region_table.py +++ b/tests/integration/regions/test_plugin_region_table.py @@ -2,14 +2,18 @@ import subprocess from typing import List -BASE_CMD = ["linode-cli", "region-table"] +import pytest + +from tests.integration.helpers import assert_headers_in_lines, exec_test_command + +BASE_CMD = ["linode-cli", "regions"] # Set the console width to 150 env = os.environ.copy() env["COLUMNS"] = "150" -def exec_test_command(args: List[str]): +def exe_test_command(args: List[str]): process = subprocess.run( args, stdout=subprocess.PIPE, @@ -19,7 +23,7 @@ def exec_test_command(args: List[str]): def test_output(): - process = exec_test_command(BASE_CMD) + process = exe_test_command(["linode-cli", "region-table"]) output = process.stdout.decode() lines = output.split("\n") lines = lines[3 : len(lines) - 2] @@ -27,3 +31,76 @@ def test_output(): assert "-" in line assert "✔" in line assert "│" in line + + +def test_regions_list(): + res = ( + exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "country", "capabilities"] + assert_headers_in_lines(headers, lines) + + +def test_regions_list_avail(): + res = ( + exec_test_command(BASE_CMD + ["list-avail", "--text", "--delimiter=,"]) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["region", "plan", "available"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_region_id(): + region_id = ( + exec_test_command( + BASE_CMD + + [ + "list-avail", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "region", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = region_id[0] + yield first_id + + +def test_regions_view(get_region_id): + region_id = get_region_id + res = ( + exec_test_command( + BASE_CMD + ["view", region_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "country", "capabilities"] + assert_headers_in_lines(headers, lines) + + +def test_regions_view_avail(get_region_id): + region_id = get_region_id + res = ( + exec_test_command( + BASE_CMD + ["view-avail", region_id, "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["region", "plan", "available"] + assert_headers_in_lines(headers, lines)