diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c77f7a84b..5de34ac4a 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build the Docker image run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN" env: diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index 66e9b7b3d..0c347f748 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -61,7 +61,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -77,7 +77,7 @@ jobs: env: LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 5f16dfb9b..e58b1fde5 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -101,7 +101,7 @@ jobs: run: sudo apt-get update -y - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} @@ -144,7 +144,7 @@ jobs: LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - name: Update PR Check Run - uses: actions/github-script@v6 + uses: actions/github-script@v7 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: @@ -244,61 +244,32 @@ jobs: steps: - name: Notify Slack - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Build Result:*\n${{ needs.integration_tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" - }, - { - "type": "mrkdwn", - "text": "*Branch:*\n`${{ github.ref_name }}`" - } - ] - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" - }, - { - "type": "mrkdwn", - "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" - } - ] - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ needs.integration_tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index baa498028..10cbad35d 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -25,7 +25,7 @@ jobs: ref: ${{ github.event.inputs.sha || github.ref }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -46,61 +46,32 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository - uses: slackapi/slack-github-action@v1.26.0 + uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" - }, - { - "type": "mrkdwn", - "text": "*Branch:*\n`${{ github.ref_name }}`" - } - ] - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" - }, - { - "type": "mrkdwn", - "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" - } - ] - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" \ No newline at end of file diff --git a/.github/workflows/publish-oci.yml b/.github/workflows/publish-oci.yml index e4db94de7..7e86b4d6b 100644 --- a/.github/workflows/publish-oci.yml +++ b/.github/workflows/publish-oci.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: setup python 3 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -23,7 +23,7 @@ jobs: uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # pin@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@ecf95283f03858871ff00b787d79c419715afc34 # pin@v2.7.0 + uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # pin@v3.8.0 - name: Login to Docker Hub uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # pin@v3.3.0 @@ -48,7 +48,7 @@ jobs: result-encoding: string - name: Build and push to DockerHub - uses: docker/build-push-action@2eb1c1961a95fc15694676618e422e8ba1d63825 # pin@v4.1.1 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # pin@v6.10.0 with: context: . file: Dockerfile diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 1023ecd08..46f3414aa 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Update system packages run: sudo apt-get update -y @@ -17,7 +17,7 @@ jobs: run: sudo apt-get install -y build-essential - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -34,6 +34,6 @@ jobs: LINODE_CLI_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # pin@release/v1.8.6 + uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # pin@release/v1.12.3 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 678e9598f..3c14f367a 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: setup python 3 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: install boto3 diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index be18d3536..9c8019534 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -11,20 +11,14 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.CLI_SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*New Release Published: _linode-cli_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file + channel: ${{ secrets.CLI_SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: "*New Release Published: _linode-cli_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 03722a74a..b345df278 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -15,7 +15,7 @@ jobs: private_key: ${{ secrets.CLI_RELEASE_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # We want to checkout the main branch ref: 'main' @@ -29,7 +29,7 @@ jobs: - name: Calculate the desired release version id: calculate_version - uses: actions/github-script@v6 + uses: actions/github-script@v7 env: SPEC_VERSION: ${{ github.event.client_payload.spec_version }} PREVIOUS_CLI_VERSION: ${{ steps.previoustag.outputs.tag }} @@ -60,13 +60,13 @@ jobs: id: calculate_head_sha run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - uses: rickstaa/action-create-tag@84c90e6ba79b47b5147dcb11ff25d6a0e06238ba # pin@v1 + - uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # pin@v1 with: tag: ${{ steps.calculate_version.outputs.result }} commit_sha: ${{ steps.calculate_head_sha.outputs.commit_sha }} - name: Release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # pin@v1 + uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # pin@v2.0.9 with: target_commitish: 'main' token: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c4b5890ae..f91c5bcf5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,7 +11,7 @@ jobs: python-version: [ '3.9','3.10','3.11', '3.12' ] steps: - name: Clone Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Update system packages run: sudo apt-get update -y @@ -42,10 +42,10 @@ jobs: runs-on: windows-latest steps: - name: Clone Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' diff --git a/linodecli/api_request.py b/linodecli/api_request.py index 2155039b7..c133d823b 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -162,26 +162,29 @@ def _build_filter_header( order_by = parsed_args_dict.pop("order_by") order = parsed_args_dict.pop("order") or "asc" - # The "+and" list to be used in the filter header - filter_list = [] + result = {} + + # A list filter allows a user to filter on multiple values in a list + # e.g. --tags foobar --tags foobar2 + list_filters = [] - for k, v in parsed_args_dict.items(): - if v is None: + for key, value in parsed_args_dict.items(): + if value is None: continue - # If this is a list, flatten it out - new_filters = [{k: j} for j in v] if isinstance(v, list) else [{k: v}] - filter_list.extend(new_filters) + if not isinstance(value, list): + result[key] = value + continue + + list_filters.extend(iter({key: entry} for entry in value)) + + if len(list_filters) > 0: + result["+and"] = list_filters - result = {} - if len(filter_list) > 0: - if len(filter_list) == 1: - result = filter_list[0] - else: - result["+and"] = filter_list if order_by is not None: result["+order_by"] = order_by result["+order"] = order + return json.dumps(result) if len(result) > 0 else None @@ -300,6 +303,7 @@ def _print_response_debug_info(response): """ # 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}", @@ -307,6 +311,8 @@ def _print_response_debug_info(response): ) 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) diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index 3eb3234e3..a14b2a1e9 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -18,7 +18,11 @@ from openapi3.paths import Operation, Parameter from linodecli.baked.parsing import simplify_description -from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest +from linodecli.baked.request import ( + OpenAPIFilteringRequest, + OpenAPIRequest, + OpenAPIRequestArg, +) from linodecli.baked.response import OpenAPIResponse from linodecli.exit_codes import ExitCodes from linodecli.output.output_handler import OutputHandler @@ -415,6 +419,13 @@ def args(self): """ return self.request.attrs if self.request else [] + @property + def arg_routes(self) -> Dict[str, List[OpenAPIRequestArg]]: + """ + Return a list of attributes from the request schema + """ + return self.request.attr_routes if self.request else [] + @staticmethod def _flatten_url_path(tag: str) -> str: """ diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index b0bf07340..4023d7434 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -2,7 +2,10 @@ Request details for a CLI Operation """ +from openapi3.schemas import Schema + from linodecli.baked.parsing import simplify_description +from linodecli.baked.util import _aggregate_schema_properties class OpenAPIRequestArg: @@ -134,66 +137,68 @@ def _parse_request_model(schema, prefix=None, parent=None, depth=0): """ args = [] - if schema.properties is not None: - for k, v in schema.properties.items(): - if v.type == "object" and not v.readOnly and v.properties: - # nested objects receive a prefix and are otherwise parsed normally - pref = prefix + "." + k if prefix else k - - args += _parse_request_model( - v, - prefix=pref, + properties, required = _aggregate_schema_properties(schema) + + if properties is None: + return args + + for k, v in properties.items(): + if ( + v.type == "object" + and not v.readOnly + and len(_aggregate_schema_properties(v)[0]) > 0 + ): + # nested objects receive a prefix and are otherwise parsed normally + pref = prefix + "." + k if prefix else k + + args += _parse_request_model( + 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" + and v.items + and v.items.type == "object" + and v.extensions.get("linode-cli-format") != "json" + ): + # handle lists of objects as a special case, where each property + # of the object in the list is its own argument + pref = prefix + "." + k if prefix else k + + # Support specifying this list as JSON + args.append( + OpenAPIRequestArg( + k, + v.items, + False, + prefix=prefix, + is_parent=True, parent=parent, - # NOTE: We do not increment the depth because dicts do not have - # parent arguments. depth=depth, ) - elif ( - v.type == "array" - and v.items - and v.items.type == "object" - and v.extensions.get("linode-cli-format") != "json" - ): - # handle lists of objects as a special case, where each property - # of the object in the list is its own argument - pref = prefix + "." + k if prefix else k - - # Support specifying this list as JSON - args.append( - OpenAPIRequestArg( - k, - v.items, - False, - prefix=prefix, - is_parent=True, - parent=parent, - depth=depth, - ) - ) + ) - 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 - # is among them and pass it into the OpenAPIRequestArg class. - required = False - if schema.required: - required = k in schema.required - args.append( - OpenAPIRequestArg( - k, - v, - required, - prefix=prefix, - parent=parent, - depth=depth, - ) + args += _parse_request_model( + v.items, + prefix=pref, + parent=pref, + depth=depth + 1, + ) + else: + args.append( + OpenAPIRequestArg( + k, + v, + k in required, + prefix=prefix, + parent=parent, + depth=depth, ) + ) return args @@ -212,15 +217,35 @@ def __init__(self, request): :type request: openapi3.MediaType """ self.required = request.schema.required + schema_override = request.extensions.get("linode-cli-use-schema") + + schema = request.schema + if schema_override: override = type(request)( request.path, {"schema": schema_override}, request._root ) override._resolve_references() - self.attrs = _parse_request_model(override.schema) - else: - self.attrs = _parse_request_model(request.schema) + schema = override.schema + + self.attrs = _parse_request_model(schema) + + # attr_routes stores all attribute routes defined using oneOf. + # For example, config-create uses one of to isolate HTTP, HTTPS, and TCP request attributes + self.attr_routes = {} + + if schema.oneOf is not None: + for entry in schema.oneOf: + entry_schema = Schema(schema.path, entry, request._root) + if entry_schema.title is None: + raise ValueError( + f"No title for oneOf entry in {schema.path}" + ) + + self.attr_routes[entry_schema.title] = _parse_request_model( + entry_schema + ) class OpenAPIFilteringRequest: @@ -249,3 +274,6 @@ def __init__(self, response_model): # actually parse out what we can filter by self.attrs = [c for c in response_model.attrs if c.filterable] + + # This doesn't apply since we're building from the response model + self.attr_routes = {} diff --git a/linodecli/baked/response.py b/linodecli/baked/response.py index 323c3ddef..9d08a7a46 100644 --- a/linodecli/baked/response.py +++ b/linodecli/baked/response.py @@ -4,6 +4,8 @@ from openapi3.paths import MediaType +from linodecli.baked.util import _aggregate_schema_properties + def _is_paginated(response): """ @@ -169,13 +171,27 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0): ) attrs = [] - if schema.properties is None: + + properties, _ = _aggregate_schema_properties(schema) + + if properties is None: return attrs - for k, v in schema.properties.items(): + for k, v in properties.items(): pref = prefix + "." + k if prefix else k - if v.type == "object": + if ( + v.type == "object" + and v.properties is None + and v.additionalProperties is not None + ): + # This is a dictionary with arbitrary keys + attrs.append( + OpenAPIResponseAttr( + k, v, prefix=prefix, nested_list_depth=nested_list_depth + ) + ) + elif v.type == "object": attrs += _parse_response_model(v, prefix=pref) elif v.type == "array" and v.items.type == "object": attrs += _parse_response_model( @@ -208,6 +224,7 @@ def __init__(self, response: MediaType): self.is_paginated = _is_paginated(response) schema_override = response.extensions.get("linode-cli-use-schema") + if schema_override: override = type(response)( response.path, {"schema": schema_override}, response._root @@ -222,6 +239,7 @@ def __init__(self, response: MediaType): ) else: self.attrs = _parse_response_model(response.schema) + self.rows = response.extensions.get("linode-cli-rows") self.nested_list = response.extensions.get("linode-cli-nested-list") self.subtables = response.extensions.get("linode-cli-subtables") diff --git a/linodecli/baked/util.py b/linodecli/baked/util.py new file mode 100644 index 000000000..fbf9744ae --- /dev/null +++ b/linodecli/baked/util.py @@ -0,0 +1,53 @@ +""" +Provides various utility functions for use in baking logic. +""" + +from collections import defaultdict +from typing import Any, Dict, Set, Tuple + +from openapi3.schemas import Schema + + +def _aggregate_schema_properties( + schema: Schema, +) -> Tuple[Dict[str, Any], Set[str]]: + """ + Aggregates all properties in the given schema, accounting properties + nested in oneOf and anyOf blocks. + + :param schema: The schema to aggregate properties from. + :return: The aggregated properties and a set containing the keys of required properties. + """ + + schema_count = 0 + properties = {} + required = defaultdict(lambda: 0) + + def _handle_schema(_schema: Schema): + if _schema.properties is None: + return + + nonlocal schema_count + schema_count += 1 + + properties.update(dict(_schema.properties)) + + # Aggregate required keys and their number of usages. + if _schema.required is not None: + for key in _schema.required: + required[key] += 1 + + _handle_schema(schema) + + one_of = schema.oneOf or [] + any_of = schema.anyOf or [] + + for entry in one_of + any_of: + # pylint: disable=protected-access + _handle_schema(Schema(schema.path, entry, schema._root)) + + return ( + properties, + # We only want to mark fields that are required by ALL subschema as required + set(key for key, count in required.items() if count == schema_count), + ) diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py index 44e80f461..f73b502b7 100644 --- a/linodecli/help_pages.py +++ b/linodecli/help_pages.py @@ -224,8 +224,13 @@ def print_help_action( _help_action_print_filter_args(console, op) return - if op.args: - _help_action_print_body_args(console, op) + if len(op.arg_routes) > 0: + # This operation uses oneOf so we need to render routes + # instead of the operation-level argument list. + for title, option in op.arg_routes.items(): + _help_action_print_body_args(console, op, option, title=title) + elif op.args: + _help_action_print_body_args(console, op, op.args) def _help_action_print_filter_args(console: Console, op: OpenAPIOperation): @@ -250,13 +255,15 @@ def _help_action_print_filter_args(console: Console, op: OpenAPIOperation): def _help_action_print_body_args( console: Console, op: OpenAPIOperation, + args: List[OpenAPIRequestArg], + title: Optional[str] = None, ): """ Pretty-prints all the body (POST/PUT) arguments for this operation. """ - console.print("[bold]Arguments:[/]") + console.print(f"[bold]Arguments{f' ({title})' if title else ''}:[/]") - for group in _help_group_arguments(op.args): + for group in _help_group_arguments(args): for arg in group: metadata = [] diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 32d2a3cf2..9e613ff7a 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -553,7 +553,7 @@ def _configure_plugin(client: CLI): cluster = _default_text_input( # pylint: disable=protected-access "Default cluster for operations (e.g. `us-mia-1`)", - optional=True, + optional=False, ) if cluster: diff --git a/linodecli/plugins/obj/objects.py b/linodecli/plugins/obj/objects.py index bdcaa64ab..f3e2513cf 100644 --- a/linodecli/plugins/obj/objects.py +++ b/linodecli/plugins/obj/objects.py @@ -91,7 +91,7 @@ def upload_object( bucket = parsed.bucket if "/" in parsed.bucket: bucket = parsed.bucket.split("/")[0] - prefix = parsed.bucket.lstrip(f"{bucket}/") + prefix = parsed.bucket.removeprefix(f"{bucket}/") upload_options = { "Bucket": bucket, diff --git a/pyproject.toml b/pyproject.toml index 311402da7..8ce03bfa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ 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" +requires-python = ">=3.9" license = { text = "BSD-3-Clause" } classifiers = [] dependencies = [ @@ -54,7 +54,7 @@ line_length = 80 [tool.black] line-length = 80 -target-version = ["py37", "py38", "py39", "py310", "py311"] +target-version = ["py39", "py310", "py311", "py312", "py313"] [tool.autoflake] expand-star-imports = true diff --git a/tests/fixtures/operation_with_one_ofs.yaml b/tests/fixtures/operation_with_one_ofs.yaml new file mode 100644 index 000000000..679e04ccb --- /dev/null +++ b/tests/fixtures/operation_with_one_ofs.yaml @@ -0,0 +1,74 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: http://localhost/v4 + +paths: + /foo/bar: + x-linode-cli-command: foo + post: + summary: Do something. + operationId: fooBarPost + description: This is description + requestBody: + description: Some description. + required: True + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' + +components: + schemas: + Foo: + oneOf: + - title: Usage 1 + type: object + required: + - foobar + - barfoo + properties: + foobar: + type: string + description: Some foobar. + barfoo: + type: integer + description: Some barfoo. + - title: Usage 2 + type: object + required: + - foobar + - foofoo + properties: + foobar: + type: string + description: Some foobar. + foofoo: + type: boolean + description: Some foofoo. + barbar: + description: Some barbar. + type: object + anyOf: + - type: object + properties: + foo: + type: string + description: Some foo. + bar: + type: integer + description: Some bar. + - type: object + properties: + baz: + type: boolean + description: Some baz. diff --git a/tests/fixtures/response_test_get.yaml b/tests/fixtures/response_test_get.yaml index 953ea2784..2881f7a67 100644 --- a/tests/fixtures/response_test_get.yaml +++ b/tests/fixtures/response_test_get.yaml @@ -29,6 +29,12 @@ paths: $ref: '#/components/schemas/PaginationEnvelope/properties/pages' results: $ref: '#/components/schemas/PaginationEnvelope/properties/results' + dictLike: + $ref: '#/components/schemas/DictionaryLikeObject' + standard: + $ref: '#/components/schemas/StandardObject' + objectArray: + $ref: '#/components/schemas/ArrayOfObjects' components: schemas: @@ -61,4 +67,27 @@ components: type: integer readOnly: true description: The total number of results. - example: 1 \ No newline at end of file + example: 1 + DictionaryLikeObject: + type: object + additionalProperties: + type: string # Arbitrary keys with string values + description: Dictionary with arbitrary keys + StandardObject: + type: object + properties: + key1: + type: string + key2: + type: integer + description: Standard object with defined properties + ArrayOfObjects: + type: array + items: + type: object + properties: + subkey1: + type: string + subkey2: + type: boolean + description: Array of objects diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index cdac0aa6f..fb173d2a2 100644 --- a/tests/integration/account/test_account.py +++ b/tests/integration/account/test_account.py @@ -17,6 +17,20 @@ def test_account_transfer(): assert_headers_in_lines(headers, lines) +def test_available_service(): + res = ( + exec_test_command( + BASE_CMD + ["get-availability", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["region", "unavailable"] + assert_headers_in_lines(headers, lines) + + def test_region_availability(): res = ( exec_test_command( @@ -91,6 +105,36 @@ def test_event_view(get_event_id): assert_headers_in_lines(headers, lines) +def test_event_read(get_event_id): + event_id = get_event_id + process = exec_test_command( + [ + "linode-cli", + "events", + "mark-read", + event_id, + "--text", + "--delimiter=,", + ] + ) + assert process.returncode == 0 + + +def test_event_seen(get_event_id): + event_id = get_event_id + process = exec_test_command( + [ + "linode-cli", + "events", + "mark-seen", + event_id, + "--text", + "--delimiter=,", + ] + ) + assert process.returncode == 0 + + def test_account_invoice_list(): res = ( exec_test_command( diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index dab183ed5..41f0e5bec 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -194,3 +194,24 @@ def get_random_region_with_caps( matching_region_ids = [region["id"] for region in matching_regions] return random.choice(matching_region_ids) if matching_region_ids else None + + +def get_cluster_id(label: str): + cluster_id = ( + exec_test_command( + [ + "linode-cli", + "lke", + "clusters-list", + "--text", + "--format=id", + "--no-headers", + "--label", + label, + ] + ) + .stdout.decode() + .rstrip() + ) + + return cluster_id diff --git a/tests/integration/linodes/test_configs.py b/tests/integration/linodes/test_configs.py index a934ab1d3..14fb090fa 100644 --- a/tests/integration/linodes/test_configs.py +++ b/tests/integration/linodes/test_configs.py @@ -25,7 +25,6 @@ @pytest.fixture(scope="session", autouse=True) def linode_instance_config_tests(linode_cloud_firewall): - linode_id = create_linode( firewall_id=linode_cloud_firewall, disk_encryption=False, diff --git a/tests/integration/linodes/test_disk.py b/tests/integration/linodes/test_disk.py index 3dff48a09..9472418c8 100644 --- a/tests/integration/linodes/test_disk.py +++ b/tests/integration/linodes/test_disk.py @@ -21,7 +21,6 @@ @pytest.fixture(scope="session", autouse=True) def linode_instance_disk_tests(linode_cloud_firewall): - linode_id = create_linode_and_wait( firewall_id=linode_cloud_firewall, disk_encryption=False, diff --git a/tests/integration/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index 3b214dba0..895b05d24 100644 --- a/tests/integration/lke/test_clusters.py +++ b/tests/integration/lke/test_clusters.py @@ -1,9 +1,12 @@ +import json + import pytest from tests.integration.helpers import ( assert_headers_in_lines, delete_target_id, exec_test_command, + get_cluster_id, get_random_region_with_caps, get_random_text, retry_exec_test_command_with_delay, @@ -84,26 +87,6 @@ def get_pool_nodesid(cluster_id): return first_id -def get_cluster_id(label: str): - cluster_id = ( - exec_test_command( - BASE_CMD - + [ - "clusters-list", - "--text", - "--format=id", - "--no-headers", - "--label", - label, - ] - ) - .stdout.decode() - .rstrip() - ) - - return cluster_id - - @pytest.fixture def test_lke_cluster(): label = get_random_text(8) + "_cluster" @@ -338,14 +321,14 @@ def test_view_pool(test_lke_cluster): ) lines = res.splitlines() - headers = ["type", "labels.value"] + headers = ["type", "labels"] assert_headers_in_lines(headers, lines) def test_update_node_pool(test_lke_cluster): cluster_id = test_lke_cluster node_pool_id = get_node_pool_id(cluster_id) - new_label = get_random_text(8) + "updated_pool" + new_value = get_random_text(8) + "updated_pool" result = ( exec_test_command( @@ -356,8 +339,8 @@ def test_update_node_pool(test_lke_cluster): node_pool_id, "--count", "5", - "--labels.value", - new_label, + "--labels", + json.dumps({"label-key": new_value}), "--text", "--no-headers", "--format=label", @@ -367,7 +350,7 @@ def test_update_node_pool(test_lke_cluster): .rstrip() ) - assert new_label in result + assert new_value in result def test_view_node(test_lke_cluster): @@ -464,6 +447,8 @@ def test_node_pool(test_lke_cluster): "1", "--type", "g6-standard-4", + "--labels", + '{ "example.com/my-app":"team1" }', "--text", "--format=id", "--no-headers", @@ -476,18 +461,23 @@ def test_node_pool(test_lke_cluster): yield node_pool_id -def test_pool_view(test_lke_cluster, test_node_pool): +def test_update_autoscaler(test_lke_cluster, test_node_pool): cluster_id = test_lke_cluster - node_pool_id = test_node_pool - node_pool = ( + result = ( exec_test_command( BASE_CMD + [ - "pool-view", + "pool-update", cluster_id, node_pool_id, + "--autoscaler.enabled", + "true", + "--autoscaler.min", + "1", + "--autoscaler.max", + "3", "--text", ] ) @@ -495,8 +485,6 @@ def test_pool_view(test_lke_cluster, test_node_pool): .rstrip() ) - lines = node_pool.splitlines() - headers = [ "autoscaler.enabled", "autoscaler.max", @@ -504,11 +492,44 @@ def test_pool_view(test_lke_cluster, test_node_pool): "count", "disk_encryption", "id", - "labels.key", - "labels.value", + "labels", "tags", "taints", "type", ] - assert_headers_in_lines(headers, lines) + assert_headers_in_lines(headers, result.splitlines()) + + assert "3" in result + assert "1" in result + + +def test_kubeconfig_view(test_lke_cluster): + cluster_id = test_lke_cluster + + kubeconfig = ( + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "kubeconfig-view", + cluster_id, + "--text", + ], + retries=5, + delay=60, + ) + .stdout.decode() + .strip() + ) + + header = ["kubeconfig"] + + assert_headers_in_lines(header, kubeconfig.splitlines()) + + assert kubeconfig + + +def test_cluster_nodes_recycle(test_lke_cluster): + cluster_id = test_lke_cluster + + exec_test_command(BASE_CMD + ["cluster-nodes-recycle", cluster_id]) diff --git a/tests/integration/lke/test_lke_acl.py b/tests/integration/lke/test_lke_acl.py new file mode 100644 index 000000000..2de03e47b --- /dev/null +++ b/tests/integration/lke/test_lke_acl.py @@ -0,0 +1,172 @@ +# │ cluster-acl-delete │ Delete the control plane access control list. │ +# │ cluster-acl-update │ Update the control plane access control list. │ +# │ cluster-acl-view │ Get the control plane access control list. │ +import pytest + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_cluster_id, + get_random_region_with_caps, + get_random_text, + retry_exec_test_command_with_delay, +) + +BASE_CMD = ["linode-cli", "lke"] + + +@pytest.fixture +def test_lke_cluster_acl(): + label = get_random_text(8) + "_cluster" + + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Kubernetes"] + ) + lke_version = ( + exec_test_command( + BASE_CMD + + [ + "versions-list", + "--text", + "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + .splitlines()[0] + ) + + cluster_label = ( + exec_test_command( + BASE_CMD + + [ + "cluster-create", + "--region", + test_region, + "--label", + label, + "--node_pools.type", + "g6-standard-1", + "--node_pools.count", + "1", + "--node_pools.disks", + '[{"type":"ext4","size":1024}]', + "--k8s_version", + lke_version, + "--control_plane.high_availability", + "true", + "--control_plane.acl.enabled", + "true", + "--text", + "--delimiter", + ",", + "--no-headers", + "--format", + "label", + "--no-defaults", + ] + ) + .stdout.decode() + .rstrip() + ) + + cluster_id = get_cluster_id(label=cluster_label) + + yield cluster_id + + delete_target_id( + target="lke", id=cluster_id, delete_command="cluster-delete" + ) + + +def test_cluster_acl_view(test_lke_cluster_acl): + cluster_id = test_lke_cluster_acl + + acl = ( + exec_test_command( + BASE_CMD + + [ + "cluster-acl-view", + cluster_id, + "--text", + ] + ) + .stdout.decode() + .strip() + ) + + headers = [ + "acl.enabled", + "acl.addresses.ipv4", + "acl.addresses.ipv6", + "acl.revision-id", + ] + + assert_headers_in_lines(headers, acl.splitlines()) + + assert "True" in acl + + +def test_cluster_acl_update(test_lke_cluster_acl): + cluster_id = test_lke_cluster_acl + + print("RUNNING TEST") + + # Verify the update + acl = ( + exec_test_command( + BASE_CMD + + [ + "cluster-acl-update", + cluster_id, + "--acl.addresses.ipv4", + "203.0.113.1", + "--acl.addresses.ipv6", + "2001:db8:1234:abcd::/64", + "--acl.enabled", + "true", + "--text", + ] + ) + .stdout.decode() + .strip() + ) + + headers = [ + "acl.enabled", + "acl.addresses.ipv4", + "acl.addresses.ipv6", + "acl.revision-id", + ] + + assert_headers_in_lines(headers, acl.splitlines()) + + assert "203.0.113.1" in acl + assert "2001:db8:1234:abcd::/64" in acl + + +def test_cluster_acl_delete(test_lke_cluster_acl): + cluster_id = test_lke_cluster_acl + + retry_exec_test_command_with_delay( + BASE_CMD + ["cluster-acl-delete", cluster_id] + ) + + # Verify the deletion + acl = ( + exec_test_command( + BASE_CMD + + [ + "cluster-acl-view", + cluster_id, + "--text", + "--format=acl.enabled", + "--text", + ] + ) + .stdout.decode() + .strip() + ) + + assert "False" in acl diff --git a/tests/integration/lke/test_lke_enterprise.py b/tests/integration/lke/test_lke_enterprise.py new file mode 100644 index 000000000..713831189 --- /dev/null +++ b/tests/integration/lke/test_lke_enterprise.py @@ -0,0 +1,84 @@ +from pytest import MonkeyPatch + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_cluster_id, + get_random_region_with_caps, + get_random_text, +) + +BASE_CMD = ["linode-cli", "lke"] + + +def test_enterprise_tier_available_in_types(monkeypatch: MonkeyPatch): + monkeypatch.setenv("LINODE_CLI_API_VERSION", "v4beta") + lke_types = ( + exec_test_command( + BASE_CMD + + [ + "types", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + assert "lke-e" in lke_types + assert "LKE Enterprise" in lke_types + assert "price.monthly" in lke_types + + +def test_create_lke_enterprise(monkeypatch: MonkeyPatch): + monkeypatch.setenv("LINODE_CLI_API_VERSION", "v4beta") + label = get_random_text(8) + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Kubernetes Enterprise"] + ) + + output = ( + exec_test_command( + BASE_CMD + + [ + "cluster-create", + "--label", + label, + "--tier", + "enterprise", + "--k8s_version", + "v1.31.1+lke1", + "--node_pools.type", + "g6-standard-6", + "--node_pools.count", + "3", + "--region", + test_region, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = [ + "id", + "label", + "region", + "k8s_version", + "control_plane.high_availability", + "tier", + ] + + assert_headers_in_lines(headers, output.splitlines()) + + assert label in output + assert "v1.31.1+lke1" in output + assert "enterprise" in output + + delete_target_id( + target="lke", + id=get_cluster_id(label=label), + delete_command="cluster-delete", + ) diff --git a/tests/integration/obj/test_obj_plugin.py b/tests/integration/obj/test_obj_plugin.py index 0bcaf88da..04930cfe9 100644 --- a/tests/integration/obj/test_obj_plugin.py +++ b/tests/integration/obj/test_obj_plugin.py @@ -191,6 +191,52 @@ def test_obj_single_file_single_bucket_with_prefix( assert f1.read() == f2.read() +def test_obj_single_file_single_bucket_with_prefix_ltrim( + create_bucket: Callable[[Optional[str]], str], + generate_test_files: GetTestFilesType, + keys: Keys, + monkeypatch: MonkeyPatch, +): + patch_keys(keys, monkeypatch) + file_path = generate_test_files()[0] + bucket_name = create_bucket() + # using 'bk' in prefix to test out ltrim behaviour (bucket contains 'bk') + exec_test_command( + BASE_CMD + ["put", str(file_path), f"{bucket_name}/bkprefix"] + ) + process = exec_test_command(BASE_CMD + ["la"]) + output = process.stdout.decode() + + assert f"{bucket_name}/bkprefix/{file_path.name}" in output + + file_size = file_path.stat().st_size + assert str(file_size) in output + + process = exec_test_command(BASE_CMD + ["ls"]) + output = process.stdout.decode() + assert bucket_name in output + assert file_path.name not in output + + process = exec_test_command(BASE_CMD + ["ls", bucket_name]) + output = process.stdout.decode() + assert bucket_name not in output + assert "bkprefix" in output + + downloaded_file_path = file_path.parent / f"downloaded_{file_path.name}" + process = exec_test_command( + BASE_CMD + + [ + "get", + bucket_name, + "bkprefix/" + file_path.name, + str(downloaded_file_path), + ] + ) + output = process.stdout.decode() + with open(downloaded_file_path) as f2, open(file_path) as f1: + assert f1.read() == f2.read() + + def test_multi_files_multi_bucket( create_bucket: Callable[[Optional[str]], str], generate_test_files: GetTestFilesType, diff --git a/tests/integration/users/test_profile.py b/tests/integration/users/test_profile.py index 3c18c08de..3d725f460 100644 --- a/tests/integration/users/test_profile.py +++ b/tests/integration/users/test_profile.py @@ -116,3 +116,22 @@ def test_profile_token_list(): lines = res.splitlines() headers = ["label", "scopes", "token"] assert_headers_in_lines(headers, lines) + + +def test_sshkeys_list(): + res = ( + exec_test_command( + [ + "linode-cli", + "sshkeys", + "list", + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + headers = ["label", "ssh_key"] + assert_headers_in_lines(headers, lines) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index d97b1df60..adc5007ad 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -342,6 +342,25 @@ def get_operation_for_subtable_test(): return make_test_operation(command, operation, method, path.parameters) +@pytest.fixture +def post_operation_with_one_ofs() -> OpenAPIOperation: + """ + Creates a new OpenAPI operation that makes heavy use of oneOfs and anyOfs. + """ + + spec = _get_parsed_spec("operation_with_one_ofs.yaml") + + # Get parameters for OpenAPIOperation() from yaml fixture + path = list(spec.paths.values())[0] + + return make_test_operation( + path.extensions.get("linode-cli-command", "default"), + getattr(path, "post"), + "post", + path.parameters, + ) + + @pytest.fixture def get_openapi_for_api_components_tests() -> OpenAPI: """ diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 6b99c941f..970f0c48a 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -28,6 +28,7 @@ def test_response_debug_info(self): status_code=200, reason="OK", headers={"cool": "test"}, + content=b"cool body", ) with contextlib.redirect_stderr(stderr_buf): @@ -36,6 +37,9 @@ def test_response_debug_info(self): 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 def test_request_debug_info(self): stderr_buf = io.StringIO() @@ -204,11 +208,11 @@ def test_build_filter_header(self, list_operation): assert ( json.dumps( { + "filterable_result": "bar", "+and": [ - {"filterable_result": "bar"}, {"filterable_list_result": "foo"}, {"filterable_list_result": "bar"}, - ] + ], } ) == result @@ -267,8 +271,8 @@ def test_build_filter_header_order_by(self, list_operation): assert ( json.dumps( { + "filterable_result": "bar", "+and": [ - {"filterable_result": "bar"}, {"filterable_list_result": "foo"}, {"filterable_list_result": "bar"}, ], @@ -293,8 +297,8 @@ def test_build_filter_header_order(self, list_operation): assert ( json.dumps( { + "filterable_result": "bar", "+and": [ - {"filterable_result": "bar"}, {"filterable_list_result": "foo"}, {"filterable_list_result": "bar"}, ], @@ -331,11 +335,11 @@ def validate_http_request(url, headers=None, data=None, **kwargs): assert url == "http://localhost/v4/foo/bar?page=1&page_size=100" assert headers["X-Filter"] == json.dumps( { + "filterable_result": "cool", "+and": [ - {"filterable_result": "cool"}, {"filterable_list_result": "foo"}, {"filterable_list_result": "bar"}, - ] + ], } ) assert headers["User-Agent"] == mock_cli.user_agent @@ -447,8 +451,9 @@ def json_func(): status_code=200, reason="OK", headers={"X-Spec-Version": "1.1.0"} ) - with contextlib.redirect_stderr(stderr_buf), patch( - "linodecli.api_request.requests.get", mock_http_response + with ( + contextlib.redirect_stderr(stderr_buf), + patch("linodecli.api_request.requests.get", mock_http_response), ): api_request._attempt_warn_old_version(mock_cli, mock_response) @@ -491,8 +496,9 @@ def json_func(): status_code=200, reason="OK", headers={"X-Spec-Version": "1.1.0"} ) - with contextlib.redirect_stderr(stderr_buf), patch( - "linodecli.api_request.requests.get", mock_http_response + with ( + contextlib.redirect_stderr(stderr_buf), + patch("linodecli.api_request.requests.get", mock_http_response), ): api_request._attempt_warn_old_version(mock_cli, mock_response) @@ -525,8 +531,9 @@ def json_func(): status_code=200, reason="OK", headers={"X-Spec-Version": "1.0.0"} ) - with contextlib.redirect_stderr(stderr_buf), patch( - "linodecli.api_request.requests.get", mock_http_response + with ( + contextlib.redirect_stderr(stderr_buf), + patch("linodecli.api_request.requests.get", mock_http_response), ): api_request._attempt_warn_old_version(mock_cli, mock_response) diff --git a/tests/unit/test_help_pages.py b/tests/unit/test_help_pages.py index 8709bfd3c..ddb1a4bb7 100644 --- a/tests/unit/test_help_pages.py +++ b/tests/unit/test_help_pages.py @@ -236,3 +236,30 @@ def test_help_command_actions(self, mocker): "Test summary 2.", ], ) + + def test_action_help_post_method_routed( + self, capsys, mocker, mock_cli, post_operation_with_one_ofs + ): + mock_cli.find_operation = mocker.Mock( + return_value=post_operation_with_one_ofs + ) + + help_pages.print_help_action(mock_cli, "command", "action") + captured = capsys.readouterr().out + + print(captured) + + assert "linode-cli command action" in captured + assert "Do something." in captured + + assert "Arguments (Usage 1):" in captured + assert "--foobar (required): Some foobar." in captured + assert "--foofoo (required): Some foofoo." in captured + + assert "Arguments (Usage 2):" in captured + assert "--foobar (required): Some foobar." in captured + assert "--foofoo (required): Some foofoo." in captured + + assert "--barbar.bar: Some bar." in captured + assert "--barbar.baz: Some baz." in captured + assert "--barbar.foo: Some foo." in captured diff --git a/tests/unit/test_overrides.py b/tests/unit/test_overrides.py index 01e3584d2..f5533b6b6 100644 --- a/tests/unit/test_overrides.py +++ b/tests/unit/test_overrides.py @@ -35,10 +35,13 @@ def patch_func(*a): OUTPUT_OVERRIDES[override_signature](*a) return True - with patch( - "linodecli.baked.operation.OUTPUT_OVERRIDES", - {override_signature: patch_func}, - ), patch.object(mock_cli.output_handler, "print") as p: + with ( + patch( + "linodecli.baked.operation.OUTPUT_OVERRIDES", + {override_signature: patch_func}, + ), + patch.object(mock_cli.output_handler, "print") as p, + ): list_operation_for_overrides_test.process_response_json( response_json, mock_cli.output_handler ) diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py new file mode 100644 index 000000000..b1b4b1926 --- /dev/null +++ b/tests/unit/test_request.py @@ -0,0 +1,23 @@ +class TestRequest: + """ + Unit tests for baked requests. + """ + + def test_handle_one_ofs(self, post_operation_with_one_ofs): + args = post_operation_with_one_ofs.args + + arg_map = {arg.path: arg for arg in args} + + expected = { + "foobar": ("string", "Some foobar.", True), + "barfoo": ("integer", "Some barfoo.", False), + "foofoo": ("boolean", "Some foofoo.", False), + "barbar.foo": ("string", "Some foo.", False), + "barbar.bar": ("integer", "Some bar.", False), + "barbar.baz": ("boolean", "Some baz.", False), + } + + for k, v in expected.items(): + assert arg_map[k].datatype == v[0] + assert arg_map[k].description == v[1] + assert arg_map[k].required == v[2] diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index fb10b3ba0..b032e00b2 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -1,6 +1,6 @@ -class TestOutputHandler: +class TestResponse: """ - Unit tests for linodecli.response + Unit tests for baked responses. """ def test_model_fix_json_rows(self, list_operation_for_response_test): @@ -22,7 +22,7 @@ def test_model_fix_json_nested(self, list_operation_for_response_test): ] def test_attr_get_value(self, list_operation_for_response_test): - model = {"foo": {"bar": "cool"}} + model = {"data": {"foo": {"bar": "cool"}}} attr = list_operation_for_response_test.response_model.attrs[0] result = attr._get_value(model) @@ -30,7 +30,7 @@ def test_attr_get_value(self, list_operation_for_response_test): assert result == "cool" def test_attr_get_string(self, list_operation_for_response_test): - model = {"foo": {"bar": ["cool1", "cool2"]}} + model = {"data": {"foo": {"bar": ["cool1", "cool2"]}}} attr = list_operation_for_response_test.response_model.attrs[0] result = attr.get_string(model) @@ -38,10 +38,87 @@ def test_attr_get_string(self, list_operation_for_response_test): assert result == "cool1 cool2" def test_attr_render_value(self, list_operation_for_response_test): - model = {"foo": {"bar": ["cool1", "cool2"]}} + model = {"data": {"foo": {"bar": ["cool1", "cool2"]}}} attr = list_operation_for_response_test.response_model.attrs[0] attr.color_map = {"default_": "yellow"} result = attr.render_value(model) assert result == "[yellow]cool1, cool2[/]" + + def test_handle_one_ofs(self, post_operation_with_one_ofs): + model = post_operation_with_one_ofs.response_model + + attr_map = {attr.path: attr for attr in model.attrs} + + expected = { + "foobar": ("string", "Some foobar"), + "barfoo": ("integer", "Some barfoo"), + "foofoo": ("boolean", "Some foofoo"), + "barbar.foo": ("string", "Some foo"), + "barbar.bar": ("integer", "Some bar"), + "barbar.baz": ("boolean", "Some baz"), + } + + for k, v in expected.items(): + assert attr_map[k].datatype == v[0] + assert attr_map[k].description == v[1] + + def test_fix_json_string_type(self, list_operation_for_response_test): + model = list_operation_for_response_test.response_model + model.rows = ["foo.bar", "type"] + + input_json = {"foo": {"bar": "string_value"}, "type": "example_type"} + result = model.fix_json(input_json) + + assert result == ["string_value", "example_type"] + + def test_fix_json_integer_type(self, list_operation_for_response_test): + model = list_operation_for_response_test.response_model + model.rows = ["size", "id"] + + input_json = {"size": 42, "id": 123} + result = model.fix_json(input_json) + + assert result == [42, 123] + + def test_dictionary_like_property(self, list_operation_for_response_test): + model = list_operation_for_response_test.response_model + + model.rows = ["dictLike"] + + input_data = {"dictLike": {"keyA": "valueA", "keyB": "valueB"}} + + result = model.fix_json(input_data) + assert result == [{"keyA": "valueA", "keyB": "valueB"}] + + def test_standard_object_property(self, list_operation_for_response_test): + model = list_operation_for_response_test.response_model + + # Set rows to include the standard object + model.rows = ["standard"] + + # Simulate input data + input_data = {"standard": {"key1": "test", "key2": 42}} + + result = model.fix_json(input_data) + assert result == [{"key1": "test", "key2": 42}] + + def test_array_of_objects_property(self, list_operation_for_response_test): + model = list_operation_for_response_test.response_model + + model.rows = ["objectArray"] + + # Simulate input data + input_data = { + "objectArray": [ + {"subkey1": "item1", "subkey2": True}, + {"subkey1": "item2", "subkey2": False}, + ] + } + + result = model.fix_json(input_data) + assert result == [ + {"subkey1": "item1", "subkey2": True}, + {"subkey1": "item2", "subkey2": False}, + ]