Skip to content

Commit

Permalink
v5.49.0 (#584)
Browse files Browse the repository at this point in the history
Co-authored-by: Lena Garber <[email protected]>
Co-authored-by: Zhiwei Liang <[email protected]>
  • Loading branch information
3 people authored Mar 5, 2024
1 parent 72c723f commit 592ddb3
Show file tree
Hide file tree
Showing 17 changed files with 298 additions and 72 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ install: check-prerequisites requirements build

.PHONY: bake
bake: clean
ifeq ($(SKIP_BAKE), 1)
@echo Skipping bake stage
else
python3 -m linodecli bake ${SPEC} --skip-config
cp data-3 linodecli/
endif

.PHONY: build
build: clean bake
Expand Down
10 changes: 5 additions & 5 deletions linodecli/api_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import json
import sys
import time
from sys import version_info
from typing import Any, Iterable, List, Optional

import requests
Expand Down Expand Up @@ -69,10 +68,7 @@ def do_request(
headers = {
"Authorization": f"Bearer {ctx.config.get_token()}",
"Content-Type": "application/json",
"User-Agent": (
f"linode-cli:{ctx.version} "
f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}"
),
"User-Agent": ctx.user_agent,
}

parsed_args = operation.parse_args(args)
Expand Down Expand Up @@ -257,11 +253,15 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:

# expand paths
for k, v in vars(parsed_args).items():
if v is None:
continue

cur = expanded_json
for part in k.split(".")[:-1]:
if part not in cur:
cur[part] = {}
cur = cur[part]

cur[k.split(".")[-1]] = v

return json.dumps(_traverse_request_body(expanded_json))
Expand Down
21 changes: 17 additions & 4 deletions linodecli/arg_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def help_with_ops(ops, config):
)


def action_help(cli, command, action):
def action_help(cli, command, action): # pylint: disable=too-many-branches
"""
Prints help relevant to the command and action
"""
Expand Down Expand Up @@ -398,11 +398,24 @@ def action_help(cli, command, action):
if op.method in {"post", "put"} and arg.required
else ""
)
nullable_fmt = " (nullable)" if arg.nullable else ""
print(
f" --{arg.path}: {is_required}{arg.description}{nullable_fmt}"

extensions = []

if arg.format == "json":
extensions.append("JSON")

if arg.nullable:
extensions.append("nullable")

if arg.is_parent:
extensions.append("conflicts with children")

suffix = (
f" ({', '.join(extensions)})" if len(extensions) > 0 else ""
)

print(f" --{arg.path}: {is_required}{arg.description}{suffix}")


def bake_command(cli, spec_loc):
"""
Expand Down
59 changes: 53 additions & 6 deletions linodecli/baked/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import platform
import re
import sys
from collections import defaultdict
from getpass import getpass
from os import environ, path
from typing import List, Tuple
from typing import Any, List, Tuple

from openapi3.paths import Operation

Expand Down Expand Up @@ -427,14 +428,14 @@ def _add_args_post_put(self, parser) -> List[Tuple[str, str]]:
action=ArrayAction,
type=arg_type_handler,
)
elif arg.list_item:
elif arg.is_child:
parser.add_argument(
"--" + arg.path,
metavar=arg.name,
action=ListArgumentAction,
type=arg_type_handler,
)
list_items.append((arg.path, arg.list_parent))
list_items.append((arg.path, arg.parent))
else:
if arg.datatype == "string" and arg.format == "password":
# special case - password input
Expand Down Expand Up @@ -463,10 +464,51 @@ def _add_args_post_put(self, parser) -> List[Tuple[str, str]]:

return list_items

def _validate_parent_child_conflicts(self, parsed: argparse.Namespace):
"""
This method validates that no child arguments (e.g. --interfaces.purpose) are
specified alongside their parent (e.g. --interfaces).
"""
conflicts = defaultdict(list)

for arg in self.args:
parent = arg.parent
arg_value = getattr(parsed, arg.path, None)

if parent is None or arg_value is None:
continue

# Special case to ignore child arguments that are not specified
# but are implicitly populated by ListArgumentAction.
if isinstance(arg_value, list) and arg_value.count(None) == len(
arg_value
):
continue

# If the parent isn't defined, we can
# skip this one
if getattr(parsed, parent) is None:
continue

# We found a conflict
conflicts[parent].append(arg)

# No conflicts found
if len(conflicts) < 1:
return

for parent, args in conflicts.items():
arg_format = ", ".join([f"--{v.path}" for v in args])
print(
f"Argument(s) {arg_format} cannot be specified when --{parent} is specified.",
file=sys.stderr,
)

sys.exit(2)

@staticmethod
def _handle_list_items(
list_items,
parsed,
list_items: List[Tuple[str, str]], parsed: Any
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements
lists = {}

Expand Down Expand Up @@ -563,4 +605,9 @@ def parse_args(self, args):
elif self.method in ("post", "put"):
list_items = self._add_args_post_put(parser)

return self._handle_list_items(list_items, parser.parse_args(args))
parsed = parser.parse_args(args)

if self.method in ("post", "put"):
self._validate_parent_child_conflicts(parsed)

return self._handle_list_items(list_items, parsed)
50 changes: 41 additions & 9 deletions linodecli/baked/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ class OpenAPIRequestArg:
"""

def __init__(
self, name, schema, required, prefix=None, list_parent=None
self,
name,
schema,
required,
prefix=None,
is_parent=False,
parent=None,
): # pylint: disable=too-many-arguments
"""
Parses a single Schema node into a argument the CLI can use when making
Expand All @@ -23,6 +29,10 @@ def __init__(
:param prefix: The prefix for this arg's path, used in the actual argument
to the CLI to ensure unique arg names
:type prefix: str
:param is_parent: Whether this argument is a parent to child fields.
:type is_parent: bool
:param parent: If applicable, the path to the parent list for this argument.
:type parent: Optional[str]
"""
#: The name of this argument, mostly used for display and docs
self.name = name
Expand Down Expand Up @@ -50,6 +60,11 @@ def __init__(
schema.extensions.get("linode-cli-format") or schema.format or None
)

# If this is a deeply nested array we should treat it as JSON.
# This allows users to specify fields like --interfaces.ip_ranges.
if is_parent or (schema.type == "array" and parent is not None):
self.format = "json"

#: The type accepted for this argument. This will ultimately determine what
#: we accept in the ArgumentParser
self.datatype = (
Expand All @@ -59,13 +74,16 @@ def __init__(
#: The type of item accepted in this list; if None, this is not a list
self.item_type = None

#: Whether the argument is a field in a nested list.
self.list_item = list_parent is not None
#: Whether the argument is a parent to child fields.
self.is_parent = is_parent

#: Whether the argument is a nested field.
self.is_child = parent is not None

#: The name of the list this argument falls under.
#: This allows nested dictionaries to be specified in lists of objects.
#: e.g. --interfaces.ipv4.nat_1_1
self.list_parent = list_parent
self.parent = parent

#: The path of the path element in the schema.
self.prefix = prefix
Expand All @@ -85,7 +103,7 @@ def __init__(
)


def _parse_request_model(schema, prefix=None, list_parent=None):
def _parse_request_model(schema, prefix=None, parent=None):
"""
Parses a schema into a list of OpenAPIRequest objects
:param schema: The schema to parse as a request model
Expand All @@ -107,8 +125,11 @@ def _parse_request_model(schema, prefix=None, list_parent=None):
if v.type == "object" and not v.readOnly and v.properties:
# nested objects receive a prefix and are otherwise parsed normally
pref = prefix + "." + k if prefix else k

args += _parse_request_model(
v, prefix=pref, list_parent=list_parent
v,
prefix=pref,
parent=parent,
)
elif (
v.type == "array"
Expand All @@ -119,9 +140,20 @@ def _parse_request_model(schema, prefix=None, list_parent=None):
# handle lists of objects as a special case, where each property
# of the object in the list is its own argument
pref = prefix + "." + k if prefix else k
args += _parse_request_model(
v.items, prefix=pref, list_parent=pref

# Support specifying this list as JSON
args.append(
OpenAPIRequestArg(
k,
v.items,
False,
prefix=prefix,
is_parent=True,
parent=parent,
)
)

args += _parse_request_model(v.items, prefix=pref, parent=pref)
else:
# required fields are defined in the schema above the property, so
# we have to check here if required fields are defined/if this key
Expand All @@ -131,7 +163,7 @@ def _parse_request_model(schema, prefix=None, list_parent=None):
required = k in schema.required
args.append(
OpenAPIRequestArg(
k, v, required, prefix=prefix, list_parent=list_parent
k, v, required, prefix=prefix, parent=parent
)
)

Expand Down
11 changes: 11 additions & 0 deletions linodecli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,14 @@ def find_operation(self, command, action):

# Fail if no matching alias was found
raise ValueError(f"No action {action} for command {command}")

@property
def user_agent(self) -> str:
"""
Returns the User-Agent to use when making API requests.
"""
return (
f"linode-cli/{self.version} "
f"linode-api-docs/{self.spec_version} "
f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}"
)
17 changes: 11 additions & 6 deletions linodecli/plugins/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ def print_ssh_keys_table(data):
"""
table = Table(show_lines=True)

table.add_column("ssh keys")
table.add_column("user")
table.add_column("ssh key")

if data.users.root is not None:
for key in data.users.root:
table.add_row(key)
for name, keys in data.users.items():
# Keys will be None if no keys are configured for the user
if keys is None:
continue

for key in keys:
table.add_row(name, key)

rprint(table)

Expand Down Expand Up @@ -189,7 +194,7 @@ def get_metadata_parser():
return parser


def call(args, _):
def call(args, context):
"""
The entrypoint for this plugin
"""
Expand All @@ -204,7 +209,7 @@ def call(args, _):
# make a client, but only if we weren't printing help and endpoint is valid
if "--help" not in args:
try:
client = MetadataClient()
client = MetadataClient(user_agent=context.client.user_agent)
except ConnectTimeout as exc:
raise ConnectionError(
"Can't access Metadata service. Please verify that you are inside a Linode."
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/api_request_test_foobar_post.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ components:
nested_int:
type: number
description: A deeply nested integer.
field_array:
type: array
description: An arbitrary deeply nested array.
items:
type: string
field_string:
type: string
description: An arbitrary field.
Expand Down
Loading

0 comments on commit 592ddb3

Please sign in to comment.