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/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/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/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 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/.python-version b/.python-version new file mode 100644 index 000000000..dcb3e2ff5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +linode-cli 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 12148fedc..53c2cb7e1 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 @@ -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 -r requirements.txt -r requirements-dev.txt + pip3 install --upgrade .[dev,obj] .PHONY: lint lint: build @@ -62,7 +71,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 +98,4 @@ format: black isort autoflake @PHONEY: smoketest smoketest: - pytest -m smoke tests/integration --disable-warnings + pytest -m smoke tests/integration diff --git a/linodecli/__init__.py b/linodecli/__init__.py index a5af35c6b..db97605a7 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -15,24 +15,20 @@ from linodecli import plugins from .arg_helpers import ( - action_help, bake_command, - help_with_ops, register_args, register_plugin, 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" @@ -113,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 @@ -155,7 +151,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 @@ -209,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 @@ -257,6 +248,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..8cd242adf 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -5,19 +5,17 @@ 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 - -from .completion import bake_completions -from .helpers import pagination_args_shared, register_args_shared +from linodecli.helpers import ( + pagination_args_shared, + register_args_shared, + register_debug_arg, +) def register_args(parser): @@ -141,12 +139,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 @@ -180,7 +176,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 @@ -222,7 +218,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 @@ -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 @@ -436,4 +262,3 @@ def bake_command(cli, spec_loc): sys.exit(2) cli.bake(spec) - bake_completions(cli.ops) 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 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 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/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/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 7f62df7a1..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 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. + + :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/linodecli/help_pages.py b/linodecli/help_pages.py new file mode 100644 index 000000000..a82f39877 --- /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.response_model.is_paginated: + _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..08f8023e2 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 @@ -117,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/__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/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/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/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/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 4c7268f99..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.11.0 -boto3-stubs[s3] -build>=0.10.0 -twine>=4.0.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9f8c23e51..000000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -openapi3 -requests -PyYAML -packaging -rich -urllib3<3 -linode-metadata diff --git a/setup.py b/setup.py index 522cb5f78..606849326 100755 --- a/setup.py +++ b/setup.py @@ -1,105 +1,3 @@ -#!/usr/bin/env python3 -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() - - -# 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_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(): - """ - 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 = get_version() - 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/integration/account/test_account.py b/tests/integration/account/test_account.py new file mode 100644 index 000000000..6c52aee59 --- /dev/null +++ b/tests/integration/account/test_account.py @@ -0,0 +1,318 @@ +import pytest + +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() + headers = ["entity.label", "username"] + assert_headers_in_lines(headers, lines) + + +@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( + [ + "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() + headers = ["billing_source", "tax", "subtotal"] + assert_headers_in_lines(headers, lines) + + +@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=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["billing_source", "tax", "subtotal"] + assert_headers_in_lines(headers, lines) + + +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=,"] + ) + .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() + headers = ["ip", "username", "status"] + assert_headers_in_lines(headers, lines) + + +@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=,"] + ) + .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() + headers = ["email", "username"] + assert_headers_in_lines(headers, lines) + + +@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=,"] + ) + .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..4628de1c3 --- /dev/null +++ b/tests/integration/beta/test_beta_program.py @@ -0,0 +1,72 @@ +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: + headers = ["label", "description"] + assert_headers_in_lines(headers, lines) + + +@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: + 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/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/database/test_database.py b/tests/integration/database/test_database.py new file mode 100644 index 000000000..b98732553 --- /dev/null +++ b/tests/integration/database/test_database.py @@ -0,0 +1,151 @@ +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() + headers = ["id", "engine", "version"] + assert_headers_in_lines(headers, lines) + + +@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=,"] + ) + .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() + headers = ["id", "label", "_split"] + assert_headers_in_lines(headers, lines) + + +@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=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id", "label", "_split"] + assert_headers_in_lines(headers, lines) 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 diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 05b207417..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": @@ -136,3 +132,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] 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/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/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 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) diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index e934158f0..e5ad7b3a8 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 @@ -151,134 +151,9 @@ 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") 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/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/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) 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 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 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())) 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/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/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/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 ``` 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..bf667fcbe --- /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 from 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..ff9633d77 --- /dev/null +++ b/wiki/development/Development - Skeleton.md @@ -0,0 +1,32 @@ +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 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 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 + * `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