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