diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 7c73e3209..b1e19ab49 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -235,6 +235,8 @@ jobs: runs-on: ubuntu-latest needs: [integration_tests] if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository + outputs: + summary: ${{ steps.set-test-summary.outputs.summary }} steps: - name: Checkout code @@ -275,14 +277,26 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + - name: Generate test summary and save to output + id: set-test-summary + run: | + filename=$(ls | grep -E '^[0-9]{12}_cli_test_report\.xml$') + test_output=$(python3 e2e_scripts/tod_scripts/generate_test_summary.py "${filename}") + { + echo 'summary<> "$GITHUB_OUTPUT" + notify-slack: runs-on: ubuntu-latest - needs: [integration_tests] + needs: [integration_tests, process-upload-report] if: ${{ (success() || failure()) && github.repository == 'linode/linode-cli' }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack + id: main_message uses: slackapi/slack-github-action@v2.0.0 with: method: chat.postMessage @@ -293,7 +307,7 @@ jobs: - type: section text: type: mrkdwn - text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* ${{ needs.integration_tests.result == 'success' && ':white_check_mark:' || ':failed:' }}" - type: divider - type: section fields: @@ -312,3 +326,14 @@ jobs: elements: - type: mrkdwn text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + + - name: Test summary thread + if: success() + uses: slackapi/slack-github-action@v2.0.0 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + thread_ts: "${{ steps.main_message.outputs.ts }}" + text: "${{ needs.process-upload-report.outputs.summary }}" \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 444c69ffd..30bcb1956 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@b54af0c25861143e7c8813d7cbbf46d2c341680c + uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d24ab9e8b..39d006ef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,13 +39,13 @@ jobs: run: make requirements - name: Set up QEMU - uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # pin@v3.3.0 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # pin@v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # pin@v3.8.0 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # pin@v3.10.0 - name: Login to Docker Hub - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # pin@v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # pin@v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -67,7 +67,7 @@ jobs: result-encoding: string - name: Build and push to DockerHub - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # pin@v6.13.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # pin@v6.15.0 with: context: . file: Dockerfile diff --git a/.gitignore b/.gitignore index 77601cba7..42f1b3900 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ test/.env MANIFEST venv openapi*.yaml +openapi*.json diff --git a/Makefile b/Makefile index 27eb93550..8c87974e0 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ VERSION_FILE := ./linodecli/version.py VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of the Linode CLI.\n\"\"\"\n\n LINODE_CLI_VERSION ?= "0.0.0.dev" +BAKE_FLAGS := --debug + .PHONY: install install: check-prerequisites requirements build pip3 install --force dist/*.whl @@ -21,7 +23,7 @@ bake: clean ifeq ($(SKIP_BAKE), 1) @echo Skipping bake stage else - python3 -m linodecli bake ${SPEC} --skip-config + python3 -m linodecli bake ${SPEC} --skip-config $(BAKE_FLAGS) cp data-3 linodecli/ endif diff --git a/e2e_scripts b/e2e_scripts index 6b71cb72e..a906260be 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit 6b71cb72eb20a18ace82f9e73a0f99fe1141d625 +Subproject commit a906260be88208a24d0a1af002a48f9c5efbd561 diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 3fa192e8c..762c676f7 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -4,6 +4,7 @@ """ import argparse +import logging import os import sys from importlib.metadata import version @@ -38,6 +39,11 @@ TEST_MODE = os.getenv("LINODE_CLI_TEST_MODE") == "1" +# Configure the `logging` package log level depending on the --debug flag. +logging.basicConfig( + level=logging.DEBUG if "--debug" in argv else logging.WARNING, +) + # if any of these arguments are given, we don't need to prompt for configuration skip_config = ( any( diff --git a/linodecli/api_request.py b/linodecli/api_request.py index c133d823b..f0369370b 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -7,7 +7,8 @@ import os import sys import time -from typing import Any, Iterable, List, Optional +from logging import getLogger +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional import requests from packaging import version @@ -23,13 +24,24 @@ ) from .helpers import handle_url_overrides +if TYPE_CHECKING: + from linodecli.cli import CLI -def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]): +logger = getLogger(__name__) + + +def get_all_pages( + ctx: "CLI", operation: OpenAPIOperation, args: List[str] +) -> Dict[str, Any]: """ - Receive all pages of a resource from multiple - API responses then merge into one page. + Retrieves all pages of a resource from multiple API responses + and merges them into a single page. - :param ctx: The main CLI object + :param ctx: The main CLI object that maintains API request state. + :param operation: The OpenAPI operation to be executed. + :param args: A list of arguments passed to the API request. + + :return: A dictionary containing the merged results from all pages. """ ctx.page_size = 500 @@ -38,6 +50,7 @@ def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]): total_pages = result.get("pages") + # If multiple pages exist, generate results for all additional pages if total_pages and total_pages > 1: pages_needed = range(2, total_pages + 1) @@ -51,19 +64,25 @@ def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]): def do_request( - ctx, - operation, - args, - filter_header=None, - skip_error_handling=False, + ctx: "CLI", + operation: OpenAPIOperation, + args: List[str], + filter_header: Optional[dict] = None, + skip_error_handling: bool = False, ) -> ( Response ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements """ - Makes a request to an operation's URL and returns the resulting JSON, or - prints and error if a non-200 comes back + Makes an HTTP request to an API operation's URL and returns the resulting response. + Optionally retries the request if specified, handles errors, and supports debugging. + + :param ctx: The main CLI object that maintains API request state. + :param operation: The OpenAPI operation to be executed. + :param args: A list of arguments passed to the API request. + :param filter_header: Optional filter header to be included in the request (default: None). + :param skip_error_handling: Whether to skip error handling (default: False). - :param ctx: The main CLI object + :return: The `Response` object returned from the HTTP request. """ # TODO: Revisit using pre-built calls from OpenAPI method = getattr(requests, operation.method) @@ -87,14 +106,20 @@ def do_request( # Print response debug info is requested if ctx.debug_request: - _print_request_debug_info(method, url, headers, body) + # Multiline log entries aren't ideal, we should consider + # using single-line structured logging in the future. + logger.debug( + "\n%s", + "\n".join(_format_request_for_log(method, url, headers, body)), + ) result = method(url, headers=headers, data=body, verify=API_CA_PATH) # Print response debug info is requested if ctx.debug_request: - _print_response_debug_info(result) + logger.debug("\n%s", "\n".join(_format_response_for_log(result))) + # Retry the request if necessary while _check_retry(result) and not ctx.no_retry and ctx.retry_count < 3: time.sleep(_get_retry_after(result.headers)) ctx.retry_count += 1 @@ -102,24 +127,37 @@ def do_request( _attempt_warn_old_version(ctx, result) + # If the response is an error and we're not skipping error handling, raise an error if not 199 < result.status_code < 399 and not skip_error_handling: _handle_error(ctx, result) return result -def _merge_results_data(results: Iterable[dict]): - """Merge multiple json response into one""" +def _merge_results_data(results: Iterable[dict]) -> Optional[Dict[str, Any]]: + """ + Merges multiple JSON responses into one, combining their 'data' fields + and setting 'pages' and 'page' to 1 if they exist. + + :param results: An iterable of dictionaries containing JSON response data. + + :return: A merged dictionary containing the combined data or None if no results are provided. + """ iterator = iter(results) merged_result = next(iterator, None) + + # If there are no results to merge, return None if not merged_result: return None + # Set 'pages' and 'page' to 1 if they exist in the first result if "pages" in merged_result: merged_result["pages"] = 1 if "page" in merged_result: merged_result["page"] = 1 + + # Merge the 'data' fields by combining the 'data' from all results if "data" in merged_result: merged_result["data"] += list( itertools.chain.from_iterable(r["data"] for r in iterator) @@ -128,13 +166,21 @@ def _merge_results_data(results: Iterable[dict]): def _generate_all_pages_results( - ctx, + ctx: "CLI", operation: OpenAPIOperation, args: List[str], pages_needed: Iterable[int], -): +) -> Iterable[dict]: """ - :param ctx: The main CLI object + Generates results from multiple pages by iterating through the specified page numbers + and yielding the JSON response for each page.e. + + :param ctx: The main CLI object that maintains API request state. + :param operation: The OpenAPI operation to be executed. + :param args: A list of arguments passed to the API request. + :param pages_needed: An iterable of page numbers to request. + + :yield: The JSON response (as a dictionary) for each requested page. """ for p in pages_needed: ctx.page = p @@ -142,8 +188,21 @@ def _generate_all_pages_results( def _build_filter_header( - operation, parsed_args, filter_header=None + operation: OpenAPIOperation, + parsed_args: Any, + filter_header: Optional[dict] = None, ) -> Optional[str]: + """ + Builds a filter header for a request based on the parsed + arguments. This is used for GET requests to filter results according + to the specified arguments. If no filter is provided, returns None. + + :param operation: The OpenAPI operation to be executed. + :param parsed_args: The parsed arguments from the CLI or request + :param filter_header: Optional filter header to be included in the request (default: None). + + :return: A JSON string representing the filter header, or None if no filters are applied. + """ if operation.method != "get": # Non-GET operations don't support filters return None @@ -188,7 +247,19 @@ def _build_filter_header( return json.dumps(result) if len(result) > 0 else None -def _build_request_url(ctx, operation, parsed_args) -> str: +def _build_request_url( + ctx: "CLI", operation: OpenAPIOperation, parsed_args: Any +) -> str: + """ + Constructs the full request URL for an API operation, + incorporating user-defined API host and scheme overrides. + + :param ctx: The main CLI object that maintains API request state. + :param operation: The OpenAPI operation to be executed. + :param parsed_args: The parsed arguments from the CLI or request. + + :return: The fully constructed request URL as a string. + """ url_base = handle_url_overrides( operation.url_base, host=ctx.config.get_value("api_host"), @@ -206,6 +277,7 @@ def _build_request_url(ctx, operation, parsed_args) -> str: **vars(parsed_args), ) + # Append pagination parameters for GET requests if operation.method == "get": result += f"?page={ctx.page}&page_size={ctx.page_size}" @@ -214,10 +286,15 @@ def _build_request_url(ctx, operation, parsed_args) -> str: def _traverse_request_body(o: Any) -> Any: """ - This function traverses is intended to be called immediately before - request body serialization and contains special handling for dropping - keys with null values and translating ExplicitNullValue instances into - serializable null values. + Traverses a request body before serialization, handling special cases: + - Drops keys with `None` values (implicit null values). + - Converts `ExplicitEmptyListValue` instances to empty lists. + - Converts `ExplicitNullValue` instances to `None`. + - Recursively processes nested dictionaries and lists. + + :param o: The request body object to process. + + :return: A modified version of `o` with appropriate transformations applied. """ if isinstance(o, dict): result = {} @@ -253,7 +330,18 @@ def _traverse_request_body(o: Any) -> Any: return o -def _build_request_body(ctx, operation, parsed_args) -> Optional[str]: +def _build_request_body( + ctx: "CLI", operation: OpenAPIOperation, parsed_args: Any +) -> Optional[str]: + """ + Builds the request body for API calls, handling default values and nested structures. + + :param ctx: The main CLI object that maintains API request state. + :param operation: The OpenAPI operation to be executed. + :param parsed_args: The parsed arguments from the CLI or request. + + :return: A JSON string representing the request body, or None if not applicable. + """ if operation.method == "get": # Get operations don't have a body return None @@ -266,7 +354,7 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]: expanded_json = {} - # expand paths + # Expand dotted keys into nested dictionaries for k, v in vars(parsed_args).items(): if v is None or k in param_names: continue @@ -282,41 +370,71 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]: return json.dumps(_traverse_request_body(expanded_json)) -def _print_request_debug_info(method, url, headers, body): +def _format_request_for_log( + method: Any, + url: str, + headers: Dict[str, str], + body: str, +) -> List[str]: """ - Prints debug info for an HTTP request + Builds a debug output for the given request. + + :param method: The HTTP method of the request. + :param url: The URL of the request. + :param headers: The headers of the request. + :param body: The body of the request. + + :returns: The lines of the generated debug output. """ - print(f"> {method.__name__.upper()} {url}", file=sys.stderr) + result = [f"> {method.__name__.upper()} {url}"] + for k, v in headers.items(): # If this is the Authorization header, sanitize the token if k.lower() == "authorization": v = "Bearer " + "*" * 64 - print(f"> {k}: {v}", file=sys.stderr) - print("> Body:", file=sys.stderr) - print("> ", body or "", file=sys.stderr) - print("> ", file=sys.stderr) + result.append(f"> {k}: {v}") + + result.extend(["> Body:", f"> {body or ''}", "> "]) -def _print_response_debug_info(response): + return result + + +def _format_response_for_log( + response: requests.Response, +): """ - Prints debug info for a response from requests + Builds a debug output for the given response. + + :param response: The HTTP response to format. + + :returns: The lines of the generated debug output. """ + # these come back as ints, convert to HTTP version http_version = response.raw.version / 10 body = response.content.decode("utf-8", errors="replace") - print( - f"< HTTP/{http_version:.1f} {response.status_code} {response.reason}", - file=sys.stderr, - ) + result = [ + f"< HTTP/{http_version:.1f} {response.status_code} {response.reason}" + ] + for k, v in response.headers.items(): - print(f"< {k}: {v}", file=sys.stderr) - print("< Body:", file=sys.stderr) - print("< ", body or "", file=sys.stderr) - print("< ", file=sys.stderr) + result.append(f"< {k}: {v}") + result.extend(["< Body:", f"< {body or ''}", "< "]) + + return result + + +def _attempt_warn_old_version(ctx: "CLI", result: Any) -> None: + """ + Checks if the API version is newer than the CLI version and + warns the user if an upgrade is available. -def _attempt_warn_old_version(ctx, result): + :param ctx: The main CLI object that maintains API request state. + :param result: The HTTP response object from the API request. + """ if ctx.suppress_warnings: return @@ -398,9 +516,13 @@ def _attempt_warn_old_version(ctx, result): ) -def _handle_error(ctx, response): +def _handle_error(ctx: "CLI", response: Any) -> None: """ - Given an error message, properly displays the error to the user and exits. + Handles API error responses by displaying a formatted error message + and exiting with the appropriate error code. + + :param ctx: The main CLI object that maintains API request state. + :param response: The HTTP response object from the API request. """ print(f"Request failed: {response.status_code}", file=sys.stderr) @@ -422,7 +544,9 @@ def _handle_error(ctx, response): def _check_retry(response): """ - Check for valid retry scenario, returns true if retry is valid + Check for valid retry scenario, returns true if retry is valid. + + :param response: The HTTP response object from the API request. """ if response.status_code in (408, 429): # request timed out or rate limit exceeded @@ -436,6 +560,14 @@ def _check_retry(response): ) -def _get_retry_after(headers): +def _get_retry_after(headers: Dict[str, str]) -> int: + """ + Extracts the "Retry-After" value from the response headers and returns it + as an integer representing the number of seconds to wait before retrying. + + :param headers: The HTTP response headers as a dictionary. + + :return: The number of seconds to wait before retrying, or 0 if not specified. + """ retry_str = headers.get("Retry-After", "") return int(retry_str) if retry_str else 0 diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 5cd853ea3..c485d5f62 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -1,9 +1,14 @@ #!/usr/local/bin/python3 """ -Argument parser for the linode CLI +Argument parser for the linode CLI. +This module defines argument parsing, plugin registration, and plugin removal +functionalities for the Linode CLI. """ import sys +from argparse import ArgumentParser +from configparser import ConfigParser from importlib import import_module +from typing import Dict, Tuple from linodecli import plugins from linodecli.helpers import ( @@ -14,9 +19,13 @@ from linodecli.output.helpers import register_output_args_shared -def register_args(parser): +def register_args(parser: ArgumentParser) -> ArgumentParser: """ - Register static command arguments + Register static command arguments for the Linode CLI. + + :param parser: Argument parser object to which arguments will be added. + + :return: The updated ArgumentParser instance. """ parser.add_argument( "command", @@ -58,6 +67,7 @@ def register_args(parser): help="Prints version information and exits.", ) + # Register shared argument groups register_output_args_shared(parser) register_pagination_args_shared(parser) register_args_shared(parser) @@ -67,38 +77,51 @@ def register_args(parser): # TODO: maybe move to plugins/__init__.py -def register_plugin(module, config, ops): +def register_plugin( + module: str, config: ConfigParser, ops: Dict[str, str] +) -> Tuple[str, int]: """ - Handle registering a plugin - Registering sets up the plugin for all CLI users + Handle registering a plugin for the Linode CLI. + + :param module: The name of the module to be registered as a plugin. + :param config: Configuration parser object. + :param ops: Dictionary of existing CLI operations. + + :return: A tuple containing a message and an exit code. """ - # attempt to import the module to prove it is installed and exists + + # Attempt to import the module to prove it is installed and exists try: plugin = import_module(module) except ImportError: return f"Module {module} not installed", 10 + # Ensure the module defines a PLUGIN_NAME attribute try: plugin_name = plugin.PLUGIN_NAME except AttributeError: msg = f"{module} is not a valid Linode CLI plugin - missing PLUGIN_NAME" return msg, 11 + # Ensure the module has a 'call' function, which is required for execution try: call_func = plugin.call - del call_func + del call_func # Just checking if it exists, so we can discard it except AttributeError: msg = f"{module} is not a valid Linode CLI plugin - missing call" return msg, 11 + # Check if the plugin name conflicts with existing CLI operations if plugin_name in ops: msg = "Plugin name conflicts with CLI operation - registration failed." return msg, 12 + # Check if the plugin name conflicts with an internal CLI plugin if plugin_name in plugins.AVAILABLE_LOCAL: msg = "Plugin name conflicts with internal CLI plugin - registration failed." return msg, 13 + # Check if the plugin is already registered and ask for re-registration if needed reregistering = False if plugin_name in plugins.available(config): print( @@ -110,20 +133,26 @@ def register_plugin(module, config, ops): return "Registration aborted.", 0 reregistering = True + # Retrieve the list of already registered plugins from the config already_registered = [] if config.config.has_option("DEFAULT", "registered-plugins"): already_registered = config.config.get( "DEFAULT", "registered-plugins" ).split(",") + # If re-registering, remove the existing entry before adding it again if reregistering: already_registered.remove(plugin_name) config.config.remove_option("DEFAULT", f"plugin-name-{plugin_name}") + # Add the new plugin to the registered list already_registered.append(plugin_name) config.config.set( "DEFAULT", "registered-plugins", ",".join(already_registered) ) + + # Store the module name associated with this plugin in the config + # and save the updated config to persist changes config.config.set("DEFAULT", f"plugin-name-{plugin_name}", module) config.write_config() @@ -136,19 +165,27 @@ def register_plugin(module, config, ops): # TODO: also maybe move to plugins -def remove_plugin(plugin_name, config): +def remove_plugin(plugin_name: str, config: ConfigParser) -> Tuple[str, int]: """ - Remove a plugin + Remove a registered plugin from the Linode CLI. + + :param plugin_name: The name of the plugin to remove. + :param config: Configuration parser object that manages CLI settings. + + :return: A tuple containing a message and an exit code. """ + + # Check if the plugin is a built-in CLI plugin that cannot be removed if plugin_name in plugins.AVAILABLE_LOCAL: msg = f"{plugin_name} is bundled with the CLI and cannot be removed" return msg, 13 + # Check if the plugin is actually registered before attempting removal if plugin_name not in plugins.available(config): msg = f"{plugin_name} is not a registered plugin" return msg, 14 - # do the removal + # Do the removal current_plugins = config.config.get("DEFAULT", "registered-plugins").split( "," ) @@ -157,7 +194,7 @@ def remove_plugin(plugin_name, config): "DEFAULT", "registered-plugins", ",".join(current_plugins) ) - # if the config if malformed, don't blow up + # If the config is malformed, don't blow up if config.config.has_option("DEFAULT", f"plugin-name-{plugin_name}"): config.config.remove_option("DEFAULT", f"plugin-name-{plugin_name}") diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index a14b2a1e9..0c41d2b6a 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -5,6 +5,7 @@ import argparse import glob import json +import logging import platform import re import sys @@ -400,9 +401,10 @@ def __init__( self.docs_url = self._resolve_operation_docs_url(operation) if self.docs_url is None: - print( - f"INFO: Could not resolve docs URL for {operation}", - file=sys.stderr, + logging.warning( + "%s %s Could not resolve docs URL for operation", + self.method.upper(), + self.url_path, ) code_samples_ext = operation.extensions.get("code-samples") @@ -426,6 +428,13 @@ def arg_routes(self) -> Dict[str, List[OpenAPIRequestArg]]: """ return self.request.attr_routes if self.request else [] + @property + def attrs(self): + """ + Return a list of attributes from the request schema + """ + return self.response_model.attrs if self.response_model else [] + @staticmethod def _flatten_url_path(tag: str) -> str: """ diff --git a/linodecli/cli.py b/linodecli/cli.py index 1838afd25..545b469ec 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -8,6 +8,7 @@ import pickle import sys from json import JSONDecodeError +from logging import getLogger from sys import version_info from typing import IO, Any, ContextManager, Dict @@ -23,6 +24,8 @@ METHODS = ("get", "post", "put", "delete") +logger = getLogger(__name__) + class CLI: # pylint: disable=too-many-instance-attributes """ @@ -54,6 +57,7 @@ def bake(self, spec_location: str): """ try: + logger.debug("Loading and parsing OpenAPI spec: %s", spec_location) spec = self._load_openapi_spec(spec_location) except Exception as e: print(f"Failed to load spec: {e}") @@ -72,21 +76,60 @@ def bake(self, spec_location: str): command = path.extensions.get(ext["command"], "default") for m in METHODS: operation = getattr(path, m) - if operation is None or ext["skip"] in operation.extensions: + + if operation is None: + continue + + operation_log_fmt = f"{m.upper()} {path.path[-1]}" + + logger.debug( + "%s: Attempting to generate command for operation", + operation_log_fmt, + ) + + if ext["skip"] in operation.extensions: + logger.debug( + "%s: Skipping operation due to x-linode-cli-skip extension", + operation_log_fmt, + ) continue + action = operation.extensions.get( ext["action"], operation.operationId ) if not action: + logger.warning( + "%s: Skipping operation due to unresolvable action", + operation_log_fmt, + ) continue + if isinstance(action, list): action = action[0] + if command not in self.ops: self.ops[command] = {} - self.ops[command][action] = OpenAPIOperation( + + operation = OpenAPIOperation( command, operation, m, path.parameters ) + logger.debug( + "%s %s: Successfully built command for operation: " + "command='%s %s'; summary='%s'; paginated=%s; num_args=%s; num_attrs=%s", + operation.method.upper(), + operation.url_path, + operation.command, + operation.action, + operation.summary.rstrip("."), + operation.response_model + and operation.response_model.is_paginated, + len(operation.args), + len(operation.attrs), + ) + + self.ops[command][action] = operation + # hide the base_url from the spec away self.ops["_base_url"] = self.spec.servers[0].url self.ops["_spec_version"] = self.spec.info.version diff --git a/linodecli/helpers.py b/linodecli/helpers.py index f94096194..81a51eb12 100644 --- a/linodecli/helpers.py +++ b/linodecli/helpers.py @@ -106,7 +106,10 @@ def register_debug_arg(parser: ArgumentParser): ArgumentParser that may be shared across the CLI and plugins. """ parser.add_argument( - "--debug", action="store_true", help="Enable verbose HTTP debug output." + "--debug", + action="store_true", + help="Enable verbose debug logging, including displaying HTTP debug output and " + "configuring the Python logging package level to DEBUG.", ) diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 9fd981b65..b25ffbf43 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -173,7 +173,8 @@ def set_acl(get_client, args, **kwargs): # pylint: disable=unused-argument try: set_acl_func(**set_acl_options) - except ClientError: + except ClientError as e: + print(e, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) print("ACL updated") @@ -203,14 +204,16 @@ def show_usage(get_client, args, **kwargs): # pylint: disable=unused-argument bucket_names = [ b["Name"] for b in client.list_buckets().get("Buckets", []) ] - except ClientError: + except ClientError as e: + print(e, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) grand_total = 0 for b in bucket_names: try: objects = client.list_objects_v2(Bucket=b).get("Contents", []) - except ClientError: + except ClientError as e: + print(e, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) total = 0 obj_count = 0 @@ -358,7 +361,8 @@ def call( # we can't do anything - ask for an install print( "This plugin requires the 'boto3' module. Please install it by running " - "'pip3 install boto3' or 'pip install boto3'" + "'pip3 install boto3' or 'pip install boto3'", + file=sys.stderr, ) sys.exit( diff --git a/linodecli/plugins/obj/objects.py b/linodecli/plugins/obj/objects.py index f3e2513cf..56aa5cf3f 100644 --- a/linodecli/plugins/obj/objects.py +++ b/linodecli/plugins/obj/objects.py @@ -81,6 +81,10 @@ def upload_object( for f in files: file_path = Path(f).resolve() if not file_path.is_file(): + print( + f"Error: '{file_path}' is not a valid file or does not exist.", + file=sys.stderr, + ) sys.exit(ExitCodes.FILE_ERROR) to_upload.append(file_path) @@ -112,7 +116,8 @@ def upload_object( ) try: client.upload_file(**upload_options) - except S3UploadFailedError: + except S3UploadFailedError as e: + print(e, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) print("Done.") @@ -174,7 +179,8 @@ def get_object( # In the future we should allow the automatic creation of parent directories if not destination_parent.exists(): print( - f"ERROR: Output directory {destination_parent} does not exist locally." + f"ERROR: Output directory {destination_parent} does not exist locally.", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) diff --git a/linodecli/plugins/ssh.py b/linodecli/plugins/ssh.py index 2ed53cbdb..c644f7993 100644 --- a/linodecli/plugins/ssh.py +++ b/linodecli/plugins/ssh.py @@ -50,6 +50,11 @@ def call(args, context): # pylint: disable=too-many-branches action="store_true", help="If given, uses the Linode's SLAAC address for SSH.", ) + parser.add_argument( + "-d", + action="store_true", + help="If given, uses the Lindoe's domain name for SSH", + ) parsed, args = parser.parse_known_args(args) @@ -147,4 +152,7 @@ def parse_target_address( if ip.startswith("192.168"): continue + if getattr(parsed, "d"): + ip = ip.replace(".", "-") + ".ip.linodeusercontent.com" + return ip diff --git a/pyproject.toml b/pyproject.toml index 8b1c35e1c..27a8aea5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ 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.9" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" classifiers = [] dependencies = [ "openapi3", diff --git a/tests/integration/database/test_database.py b/tests/integration/database/test_database.py index b98732553..3fc9843bb 100644 --- a/tests/integration/database/test_database.py +++ b/tests/integration/database/test_database.py @@ -1,12 +1,15 @@ +import time + import pytest -from tests.integration.helpers import assert_headers_in_lines, exec_test_command +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, +) +from tests.integration.linodes.helpers_linodes import DEFAULT_LABEL 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(): @@ -20,6 +23,112 @@ def test_engines_list(): assert_headers_in_lines(headers, lines) +timestamp = str(time.time_ns()) +mysql_database_label = DEFAULT_LABEL + "-mysql-" + timestamp +postgresql_database_label = DEFAULT_LABEL + "-postgresql-" + timestamp + + +@pytest.fixture(scope="package", autouse=True) +def test_mysql_cluster(): + database_id = ( + exec_test_command( + BASE_CMD + + [ + "mysql-create", + "--type", + "g6-nanode-1", + "--region", + "us-ord", + "--label", + mysql_database_label, + "--engine", + "mysql/8", + "--text", + "--delimiter", + ",", + "--no-headers", + "--format", + "id", + "--no-defaults", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + ) + + yield database_id + + delete_target_id( + target="databases", delete_command="mysql-delete", id=database_id + ) + + +def test_mysql_suspend_resume(test_mysql_cluster): + database_id = test_mysql_cluster + res = exec_test_command( + BASE_CMD + ["mysql-suspend", database_id, "--text", "--delimiter=,"] + ).stdout.decode() + assert "Request failed: 400" not in res + + res = exec_test_command( + BASE_CMD + ["mysql-resume", database_id, "--text", "--delimiter=,"] + ).stdout.decode() + assert "Request failed: 400" not in res + + +@pytest.fixture(scope="package", autouse=True) +def test_postgresql_cluster(): + database_id = ( + exec_test_command( + BASE_CMD + + [ + "postgresql-create", + "--type", + "g6-nanode-1", + "--region", + "us-ord", + "--label", + postgresql_database_label, + "--engine", + "postgresql/16", + "--text", + "--delimiter", + ",", + "--no-headers", + "--format", + "id", + "--no-defaults", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + ) + + yield database_id + + delete_target_id( + target="databases", delete_command="postgresql-delete", id=database_id + ) + + +def test_postgresql_suspend_resume(test_postgresql_cluster): + database_id = test_postgresql_cluster + res = exec_test_command( + BASE_CMD + + ["postgresql-suspend", database_id, "--text", "--delimiter=,"] + ).stdout.decode() + assert "Request failed: 400" not in res + + res = exec_test_command( + BASE_CMD + ["postgresql-resume", database_id, "--text", "--delimiter=,"] + ).stdout.decode() + assert "Request failed: 400" not in res + + @pytest.fixture def get_engine_id(): engine_id = ( diff --git a/tests/integration/image/test_plugin_image_upload.py b/tests/integration/image/test_plugin_image_upload.py index 3174986b5..5a05b0536 100644 --- a/tests/integration/image/test_plugin_image_upload.py +++ b/tests/integration/image/test_plugin_image_upload.py @@ -199,3 +199,6 @@ def test_image_view(get_image_id): headers = ["label", "description"] assert_headers_in_lines(headers, lines) + + # assert that regions in the output + assert "regions" in lines diff --git a/tests/integration/obj/conftest.py b/tests/integration/obj/conftest.py new file mode 100644 index 000000000..01ce0b57b --- /dev/null +++ b/tests/integration/obj/conftest.py @@ -0,0 +1,126 @@ +import json +from dataclasses import dataclass +from typing import Callable, Optional + +import pytest +from pytest import MonkeyPatch + +from linodecli.plugins.obj import ENV_ACCESS_KEY_NAME, ENV_SECRET_KEY_NAME +from tests.integration.helpers import exec_test_command, get_random_text + +REGION = "us-southeast-1" +CLI_CMD = ["linode-cli", "object-storage"] +BASE_CMD = ["linode-cli", "obj", "--cluster", REGION] + + +@dataclass +class Keys: + access_key: str + secret_key: str + + +def patch_keys(keys: Keys, monkeypatch: MonkeyPatch): + assert keys.access_key is not None + assert keys.secret_key is not None + monkeypatch.setenv(ENV_ACCESS_KEY_NAME, keys.access_key) + monkeypatch.setenv(ENV_SECRET_KEY_NAME, keys.secret_key) + + +def delete_bucket(bucket_name: str, force: bool = True): + args = BASE_CMD + ["rb", bucket_name] + if force: + args.append("--recursive") + exec_test_command(args) + return bucket_name + + +@pytest.fixture +def create_bucket( + name_generator: Callable, keys: Keys, monkeypatch: MonkeyPatch +): + created_buckets = set() + patch_keys(keys, monkeypatch) + + def _create_bucket(bucket_name: Optional[str] = None): + if not bucket_name: + bucket_name = name_generator("test-bk") + + exec_test_command(BASE_CMD + ["mb", bucket_name]) + created_buckets.add(bucket_name) + return bucket_name + + yield _create_bucket + for bk in created_buckets: + try: + delete_bucket(bk) + except Exception as e: + logging.exception(f"Failed to cleanup bucket: {bk}, {e}") + + +@pytest.fixture +def static_site_index(): + return ( + "" + "" + "Hello World" + "" + "

Hello, World!

" + "" + ) + + +@pytest.fixture +def static_site_error(): + return ( + "" + "" + "Error" + "" + "

Error!

" + "" + ) + + +@pytest.fixture(scope="session") +def keys(): + response = json.loads( + exec_test_command( + CLI_CMD + + [ + "keys-create", + "--label", + "cli-integration-test-obj-key", + "--json", + ], + ).stdout.decode() + )[0] + _keys = Keys( + access_key=response.get("access_key"), + secret_key=response.get("secret_key"), + ) + yield _keys + exec_test_command(CLI_CMD + ["keys-delete", str(response.get("id"))]) + + +@pytest.fixture(scope="session") +def test_key(): + label = get_random_text(10) + key = ( + exec_test_command( + CLI_CMD + + [ + "keys-create", + "--label", + label, + "--text", + "--no-headers", + "--format=id", + ] + ) + .stdout.decode() + .strip() + ) + + yield key + + exec_test_command(CLI_CMD + ["keys-delete", key]) diff --git a/tests/integration/obj/test_obj_bucket.py b/tests/integration/obj/test_obj_bucket.py deleted file mode 100644 index 17f86dae4..000000000 --- a/tests/integration/obj/test_obj_bucket.py +++ /dev/null @@ -1,148 +0,0 @@ -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", delete_command="keys-delete", id=key_id - ) diff --git a/tests/integration/obj/test_obj_plugin.py b/tests/integration/obj/test_obj_plugin.py index 04930cfe9..abaf3fb26 100644 --- a/tests/integration/obj/test_obj_plugin.py +++ b/tests/integration/obj/test_obj_plugin.py @@ -1,110 +1,14 @@ -import json -import logging from concurrent.futures import ThreadPoolExecutor, wait -from dataclasses import dataclass from typing import Callable, Optional import pytest import requests from pytest import MonkeyPatch -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 - -REGION = "us-southeast-1" -CLI_CMD = ["linode-cli", "object-storage"] -BASE_CMD = ["linode-cli", "obj", "--cluster", REGION] - - -@dataclass -class Keys: - access_key: str - secret_key: str - - -@pytest.fixture -def static_site_index(): - return ( - "" - "" - "Hello World" - "" - "

Hello, World!

" - "" - ) - - -@pytest.fixture -def static_site_error(): - return ( - "" - "" - "Error" - "" - "

Error!

" - "" - ) - - -@pytest.fixture(scope="session") -def keys(): - response = json.loads( - exec_test_command( - CLI_CMD - + [ - "keys-create", - "--label", - "cli-integration-test-obj-key", - "--json", - ], - ).stdout.decode() - )[0] - _keys = Keys( - access_key=response.get("access_key"), - secret_key=response.get("secret_key"), - ) - yield _keys - exec_test_command(CLI_CMD + ["keys-delete", str(response.get("id"))]) - - -def patch_keys(keys: Keys, monkeypatch: MonkeyPatch): - assert keys.access_key is not None - assert keys.secret_key is not None - monkeypatch.setenv(ENV_ACCESS_KEY_NAME, keys.access_key) - monkeypatch.setenv(ENV_SECRET_KEY_NAME, keys.secret_key) - - -@pytest.fixture -def create_bucket( - name_generator: Callable, keys: Keys, monkeypatch: MonkeyPatch -): - created_buckets = set() - patch_keys(keys, monkeypatch) - - def _create_bucket(bucket_name: Optional[str] = None): - if not bucket_name: - bucket_name = name_generator("test-bk") - - exec_test_command(BASE_CMD + ["mb", bucket_name]) - created_buckets.add(bucket_name) - return bucket_name - - yield _create_bucket - for bk in created_buckets: - try: - delete_bucket(bk) - except Exception as e: - logging.exception(f"Failed to cleanup bucket: {bk}, {e}") - - -def delete_bucket(bucket_name: str, force: bool = True): - args = BASE_CMD + ["rb", bucket_name] - if force: - args.append("--recursive") - exec_test_command(args) - return bucket_name +from tests.integration.obj.conftest import BASE_CMD, REGION, Keys, patch_keys def test_obj_single_file_single_bucket( @@ -452,6 +356,6 @@ def test_generate_url( BASE_CMD + ["signurl", bucket, test_file.name, "+300"] ) url = process.stdout.decode() - response = requests.get(url) + response = requests.get(url.strip("\n")) assert response.text == content assert response.status_code == 200 diff --git a/tests/integration/obj/test_object_storage.py b/tests/integration/obj/test_object_storage.py new file mode 100644 index 000000000..8f45041fe --- /dev/null +++ b/tests/integration/obj/test_object_storage.py @@ -0,0 +1,307 @@ +import json +from typing import Callable, Optional + +import pytest +from pytest import MonkeyPatch + +from tests.integration.helpers import exec_test_command, get_random_text +from tests.integration.obj.conftest import CLI_CMD, REGION, Keys + + +def test_clusters_list(): + response = ( + exec_test_command(CLI_CMD + ["clusters-list", "--json"]) + .stdout.decode() + .rstrip() + ) + + clusters = json.loads(response) + + assert isinstance(clusters, list) + assert len(clusters) > 0 + + for cluster in clusters: + assert isinstance(cluster, dict) + assert { + "id", + "region", + "status", + "domain", + "static_site_domain", + }.issubset(cluster.keys()) + + assert cluster["id"] + assert cluster["region"] + assert cluster["status"] in {"available", "unavailable"} + assert cluster["domain"].endswith(".linodeobjects.com") + assert cluster["static_site_domain"].startswith("website-") + + +def test_clusters_view(): + response = ( + exec_test_command(CLI_CMD + ["clusters-view", REGION, "--json"]) + .stdout.decode() + .rstrip() + ) + + clusters = json.loads(response) + + assert isinstance(clusters, list) + assert len(clusters) == 1 + + for cluster in clusters: + assert isinstance(cluster, dict) + assert { + "id", + "region", + "status", + "domain", + "static_site_domain", + }.issubset(cluster.keys()) + + assert cluster["id"] == "us-southeast-1" + assert cluster["region"] == "us-southeast" + assert cluster["status"] in {"available", "unavailable"} + assert cluster["domain"].endswith(".linodeobjects.com") + assert cluster["static_site_domain"].startswith("website-") + + +def test_keys_create( + create_bucket: Callable[[Optional[str]], str], + keys: Keys, + monkeypatch: MonkeyPatch, +): + bucket_name = create_bucket() + region = "us-southeast" # Fixed typo + label = get_random_text(10) + response = ( + exec_test_command( + CLI_CMD + + [ + "keys-create", + "--label", + label, + "--bucket_access", + f'[{{"region": "{region}", "bucket_name": "{bucket_name}", "permissions": "read_write"}}]', + "--json", + ] + ) + .stdout.decode() + .rstrip() + ) + + data = json.loads(response) + key = data[0] + + assert key["id"] > 0 + assert key["label"] == label + assert key["access_key"] + assert key["secret_key"] + assert key["limited"] is True + + bucket = key["bucket_access"][0] + assert bucket["cluster"] == "us-southeast-1" + assert bucket["bucket_name"].startswith("test-bk") + assert bucket["permissions"] == "read_write" + assert bucket["region"] == "us-southeast" + + region_info = key["regions"][0] + assert region_info["id"] == "us-southeast" + assert region_info["s3_endpoint"].endswith(".linodeobjects.com") + assert region_info["endpoint_type"] == "E0" + + exec_test_command(CLI_CMD + ["keys-delete", str(key["id"])]) + + +def test_keys_delete(): + label = get_random_text(10) + + key = ( + exec_test_command( + CLI_CMD + + [ + "keys-create", + "--label", + label, + "--text", + "--no-headers", + "--format=id", + ] + ) + .stdout.decode() + .strip() + ) + + assert key, "Key creation failed, received empty key ID" + + # Delete the key + exec_test_command(CLI_CMD + ["keys-delete", key]) + + # Verify deletion by listing keys + keys_list = ( + exec_test_command(CLI_CMD + ["keys-list", "--text"]) + .stdout.decode() + .strip() + ) + + assert key not in keys_list, f"Key {key} still exists after deletion!" + + +def test_keys_list(test_key): + keys_list = ( + exec_test_command(CLI_CMD + ["keys-list", "--text"]) + .stdout.decode() + .strip() + ) + + assert test_key in keys_list + + +def test_keys_update(test_key): + update_label = get_random_text(10) + + updated_key_resp = ( + exec_test_command( + CLI_CMD + + [ + "keys-update", + test_key, + "--label", + update_label, + "--region", + "us-east", + "--json", + ] + ) + .stdout.decode() + .strip() + ) + + assert update_label in updated_key_resp + + +def test_keys_view(test_key): + view_resp = ( + exec_test_command( + CLI_CMD + + [ + "keys-view", + test_key, + "--json", + ] + ) + .stdout.decode() + .strip() + ) + + data = json.loads(view_resp) + + key = data[0] + + assert key["id"] > 0 + assert isinstance(key["label"], str) and key["label"] + assert key["access_key"] + assert key["secret_key"] == "[REDACTED]" + assert isinstance(key["limited"], bool) + + region = key["regions"][0] + assert region["id"] == "us-east" + assert region["s3_endpoint"].endswith(".linodeobjects.com") + assert region["endpoint_type"] in {"E0", "E1", "E2", "E3"} + + +def test_types(): + data = ( + exec_test_command( + CLI_CMD + + [ + "types", + "--json", + ] + ) + .stdout.decode() + .strip() + ) + + types = json.loads(data) + + assert isinstance(types, list) and len(types) > 0 + + for type in types: + assert "id" in type and isinstance(type["id"], str) and type["id"] + assert ( + "label" in type and isinstance(type["label"], str) and type["label"] + ) + assert "price" in type and isinstance(type["price"], dict) + assert "hourly" in type["price"] and isinstance( + type["price"]["hourly"], (int, float) + ) + assert "monthly" in type["price"] and ( + type["price"]["monthly"] is None + or isinstance(type["price"]["monthly"], (int, float)) + ) + assert "transfer" in type and isinstance(type["transfer"], int) + + if "region_prices" in type: + assert isinstance(type["region_prices"], list) + for region_price in type["region_prices"]: + assert ( + "id" in region_price + and isinstance(region_price["id"], str) + and region_price["id"] + ) + assert "hourly" in region_price and isinstance( + region_price["hourly"], (int, float) + ) + assert "monthly" in region_price and ( + region_price["monthly"] is None + or isinstance(region_price["monthly"], (int, float)) + ) + + +def test_endpoints(): + data = ( + exec_test_command( + CLI_CMD + + [ + "endpoints", + "--json", + ] + ) + .stdout.decode() + .strip() + ) + + endpoints = json.loads(data) + + assert isinstance(endpoints, list) + assert all("region" in e for e in endpoints) + assert all("endpoint_type" in e for e in endpoints) + assert all("s3_endpoint" in e for e in endpoints) + + us_east = next(e for e in endpoints if e["region"] == "us-east") + assert us_east["endpoint_type"] == "E0" + assert us_east["s3_endpoint"] == "us-east-1.linodeobjects.com" + + +@pytest.mark.skipif( + reason="Skipping until the command is fixed and aligned with techdocs example. Applicable for spec version after 4.197.1" +) +def test_transfers(): + data = ( + exec_test_command( + CLI_CMD + + [ + "transfers", + "--json", + ] + ) + .stdout.decode() + .strip() + ) + + transfers = json.loads(data) + + assert len(transfers) > 0 + assert "used" in transfers[0] + assert isinstance(transfers[0]["used"], int) diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 970f0c48a..3bf48969c 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -21,8 +21,6 @@ class TestAPIRequest: """ def test_response_debug_info(self): - stderr_buf = io.StringIO() - mock_response = SimpleNamespace( raw=SimpleNamespace(version=11.1), status_code=200, @@ -31,34 +29,32 @@ def test_response_debug_info(self): content=b"cool body", ) - with contextlib.redirect_stderr(stderr_buf): - api_request._print_response_debug_info(mock_response) + result = api_request._format_response_for_log(mock_response) - output = stderr_buf.getvalue() - assert "< HTTP/1.1 200 OK" in output - assert "< cool: test" in output - assert "< Body:" in output - assert "< cool body" in output - assert "< " in output + assert result == [ + "< HTTP/1.1 200 OK", + "< cool: test", + "< Body:", + "< cool body", + "< ", + ] def test_request_debug_info(self): - stderr_buf = io.StringIO() - - with contextlib.redirect_stderr(stderr_buf): - api_request._print_request_debug_info( - SimpleNamespace(__name__="get"), - "https://definitely.linode.com/", - {"cool": "test", "Authorization": "sensitiveinfo"}, - "cool body", - ) + result = api_request._format_request_for_log( + SimpleNamespace(__name__="get"), + "https://definitely.linode.com/", + {"cool": "test", "Authorization": "sensitiveinfo"}, + "cool body", + ) - output = stderr_buf.getvalue() - assert "> GET https://definitely.linode.com/" in output - assert "> cool: test" in output - assert f"> Authorization: Bearer {'*' * 64}" in output - assert "> Body:" in output - assert "> cool body" in output - assert "> " in output + assert result == [ + "> GET https://definitely.linode.com/", + "> cool: test", + f"> Authorization: Bearer {'*' * 64}", + "> Body:", + "> cool body", + "> ", + ] def test_build_request_body(self, mock_cli, create_operation): create_operation.allowed_defaults = ["region", "image"] diff --git a/tests/unit/test_plugin_ssh.py b/tests/unit/test_plugin_ssh.py index 92471fde9..216734da0 100644 --- a/tests/unit/test_plugin_ssh.py +++ b/tests/unit/test_plugin_ssh.py @@ -187,11 +187,17 @@ def test_parse_target_address(): "ipv6": "c001:d00d::1337/128", } - test_namespace = argparse.Namespace(**{"6": False}) + test_namespace = argparse.Namespace(**{"6": False, "d": False}) address = plugin.parse_target_address(test_namespace, test_target) assert address == "123.123.123.123" + # Hack to work around invalid key + setattr(test_namespace, "d", True) + + address = plugin.parse_target_address(test_namespace, test_target) + assert address == "123-123-123-123.ip.linodeusercontent.com" + # Hack to work around invalid key setattr(test_namespace, "6", True)