Skip to content

Commit f2fe95f

Browse files
Merge pull request #507 from linode/dev
Release v5.42.0
2 parents 5f3e3d4 + 76fe691 commit f2fe95f

36 files changed

+2149
-1279
lines changed

.pylintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ disable=raw-checker-failed,
422422
invalid-name,
423423
fixme,
424424
duplicate-code,
425+
too-few-public-methods,
426+
too-many-instance-attributes,
425427
use-symbolic-message-instead
426428

427429
# Enable the message, report, category or checker with the given id(s). You can

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ This can be done using the following environment variables to override certain s
266266

267267
* ``LINODE_CLI_API_SCHEME`` - The request scheme to use (e.g. ``https``)
268268

269+
Alternatively, these values can be configured per-user using the ``linode-cli configure`` command.
270+
269271
Multiple Users
270272
^^^^^^^^^^^^^^
271273

linodecli/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
from .completion import bake_completions, get_completions
2727
from .configuration import ENV_TOKEN_NAME
2828
from .helpers import handle_url_overrides
29-
from .operation import CLIArg, CLIOperation, URLParam
3029
from .output import OutputMode
31-
from .response import ModelAttr, ResponseModel
3230

3331
# this might not be installed at the time of building
3432
try:
@@ -102,6 +100,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
102100

103101
cli.output_handler.suppress_warnings = parsed.suppress_warnings
104102
cli.output_handler.disable_truncation = parsed.no_truncation
103+
cli.output_handler.column_width = parsed.column_width
105104

106105
if parsed.as_user and not skip_config:
107106
cli.config.set_user(parsed.as_user)

linodecli/api_request.py

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,47 @@
22
This module is responsible for handling HTTP requests to the Linode API.
33
"""
44

5+
import itertools
56
import json
67
import sys
78
from sys import version_info
8-
from typing import Optional
9+
from typing import Iterable, List, Optional
910

1011
import requests
1112
from packaging import version
1213
from requests import Response
1314

1415
from linodecli.helpers import API_CA_PATH
1516

17+
from .baked.operation import ExplicitNullValue, OpenAPIOperation
18+
from .helpers import handle_url_overrides
19+
20+
21+
def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]):
22+
"""
23+
Receive all pages of a resource from multiple
24+
API responses then merge into one page.
25+
26+
:param ctx: The main CLI object
27+
"""
28+
29+
ctx.page_size = 500
30+
ctx.page = 1
31+
result = do_request(ctx, operation, args).json()
32+
33+
total_pages = result.get("pages")
34+
35+
if total_pages and total_pages > 1:
36+
pages_needed = range(2, total_pages + 1)
37+
38+
result = _merge_results_data(
39+
itertools.chain(
40+
(result,),
41+
_generate_all_pages_results(ctx, operation, args, pages_needed),
42+
)
43+
)
44+
return result
45+
1646

1747
def do_request(
1848
ctx,
@@ -26,7 +56,10 @@ def do_request(
2656
"""
2757
Makes a request to an operation's URL and returns the resulting JSON, or
2858
prints and error if a non-200 comes back
59+
60+
:param ctx: The main CLI object
2961
"""
62+
# TODO: Revisit using pre-built calls from OpenAPI
3063
method = getattr(requests, operation.method)
3164
headers = {
3265
"Authorization": f"Bearer {ctx.config.get_token()}",
@@ -67,6 +100,39 @@ def do_request(
67100
return result
68101

69102

103+
def _merge_results_data(results: Iterable[dict]):
104+
"""Merge multiple json response into one"""
105+
106+
iterator = iter(results)
107+
merged_result = next(iterator, None)
108+
if not merged_result:
109+
return None
110+
111+
if "pages" in merged_result:
112+
merged_result["pages"] = 1
113+
if "page" in merged_result:
114+
merged_result["page"] = 1
115+
if "data" in merged_result:
116+
merged_result["data"] += list(
117+
itertools.chain.from_iterable(r["data"] for r in iterator)
118+
)
119+
return merged_result
120+
121+
122+
def _generate_all_pages_results(
123+
ctx,
124+
operation: OpenAPIOperation,
125+
args: List[str],
126+
pages_needed: Iterable[int],
127+
):
128+
"""
129+
:param ctx: The main CLI object
130+
"""
131+
for p in pages_needed:
132+
ctx.page = p
133+
yield do_request(ctx, operation, args).json()
134+
135+
70136
def _build_filter_header(
71137
operation, parsed_args, filter_header=None
72138
) -> Optional[str]:
@@ -84,6 +150,10 @@ def _build_filter_header(
84150
if p.name in parsed_args_dict:
85151
del parsed_args_dict[p.name]
86152

153+
# check for order-by and order
154+
order_by = parsed_args_dict.pop("order_by")
155+
order = parsed_args_dict.pop("order") or "asc"
156+
87157
# The "+and" list to be used in the filter header
88158
filter_list = []
89159

@@ -95,14 +165,27 @@ def _build_filter_header(
95165
new_filters = [{k: j} for j in v] if isinstance(v, list) else [{k: v}]
96166
filter_list.extend(new_filters)
97167

168+
result = {}
98169
if len(filter_list) > 0:
99-
return json.dumps({"+and": filter_list})
100-
101-
return None
170+
if len(filter_list) == 1:
171+
result = filter_list[0]
172+
else:
173+
result["+and"] = filter_list
174+
if order_by is not None:
175+
result["+order_by"] = order_by
176+
result["+order"] = order
177+
return json.dumps(result) if len(result) > 0 else None
102178

103179

104180
def _build_request_url(ctx, operation, parsed_args) -> str:
105-
result = operation.url.format(**vars(parsed_args))
181+
target_server = handle_url_overrides(
182+
operation.url_base,
183+
host=ctx.config.get_value("api_host"),
184+
version=ctx.config.get_value("api_version"),
185+
scheme=ctx.config.get_value("api_scheme"),
186+
)
187+
188+
result = f"{target_server}{operation.url_path}".format(**vars(parsed_args))
106189

107190
if operation.method == "get":
108191
result += f"?page={ctx.page}&page_size={ctx.page_size}"
@@ -121,7 +204,19 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
121204
parsed_args, operation.allowed_defaults, operation.action
122205
)
123206

124-
to_json = {k: v for k, v in vars(parsed_args).items() if v is not None}
207+
to_json = {}
208+
209+
for k, v in vars(parsed_args).items():
210+
# Skip null values
211+
if v is None:
212+
continue
213+
214+
# Explicitly include ExplicitNullValues
215+
if isinstance(v, ExplicitNullValue):
216+
to_json[k] = None
217+
continue
218+
219+
to_json[k] = v
125220

126221
expanded_json = {}
127222

linodecli/arg_helpers.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ def register_args(parser):
126126
default=False,
127127
help="Prevent the truncation of long values in command outputs.",
128128
)
129+
parser.add_argument(
130+
"--column-width",
131+
type=int,
132+
default=None,
133+
help="Sets the maximum width of each column in outputted tables. "
134+
"By default, columns are dynamically sized to fit the terminal.",
135+
)
129136
parser.add_argument(
130137
"--version",
131138
"-v",
@@ -341,35 +348,37 @@ def action_help(cli, command, action):
341348
return
342349
print(f"linode-cli {command} {action}", end="")
343350
for param in op.params:
344-
# clean up parameter names - we add an '_' at the end of them
345-
# during baking if it conflicts with the name of an argument.
346351
pname = param.name.upper()
347-
if pname[-1] == "_":
348-
pname = pname[:-1]
349352
print(f" [{pname}]", end="")
350353
print()
351354
print(op.summary)
352355
if op.docs_url:
353356
print(f"API Documentation: {op.docs_url}")
354357
print()
358+
if op.method == "get" and op.action == "list":
359+
filterable_attrs = [
360+
attr for attr in op.response_model.attrs if attr.filterable
361+
]
362+
363+
if filterable_attrs:
364+
print("You may filter results with:")
365+
for attr in filterable_attrs:
366+
print(f" --{attr.name}")
367+
print(
368+
"Additionally, you may order results using --order-by and --order."
369+
)
370+
return
355371
if op.args:
356372
print("Arguments:")
357373
for arg in sorted(op.args, key=lambda s: not s.required):
374+
if arg.read_only:
375+
continue
358376
is_required = (
359377
"(required) "
360378
if op.method in {"post", "put"} and arg.required
361379
else ""
362380
)
363381
print(f" --{arg.path}: {is_required}{arg.description}")
364-
elif op.method == "get" and op.action == "list":
365-
filterable_attrs = [
366-
attr for attr in op.response_model.attrs if attr.filterable
367-
]
368-
369-
if filterable_attrs:
370-
print("You may filter results with:")
371-
for attr in filterable_attrs:
372-
print(f" --{attr.name}")
373382

374383

375384
def bake_command(cli, spec_loc):

linodecli/baked/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""
2+
Collection of classes for handling the parsed OpenAPI Spec for the CLI
3+
"""
4+
from .operation import OpenAPIOperation

linodecli/baked/colors.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Applies shell color escapes for pretty printing
3+
"""
4+
import os
5+
import platform
6+
7+
DO_COLORS = True
8+
# !! Windows compatibility for ANSI color codes !!
9+
#
10+
# If we're running on windows, we need to run the "color" command to enable
11+
# ANSI color code support.
12+
if platform.system() == "Windows":
13+
ver = platform.version()
14+
15+
if "." in ver:
16+
ver = ver.split(".", 1)[0]
17+
18+
try:
19+
verNum = int(ver)
20+
except ValueError:
21+
DO_COLORS = False
22+
23+
# windows 10+ supports ANSI color codes after running the 'color' command to
24+
# properly set up the command prompt. Older versions of windows do not, and
25+
# we should not attempt to use them there.
26+
if verNum >= 10:
27+
os.system("color")
28+
else:
29+
DO_COLORS = False
30+
31+
32+
CLEAR_COLOR = "\x1b[0m"
33+
COLOR_CODE_MAP = {
34+
"red": "\x1b[31m",
35+
"green": "\x1b[32m",
36+
"yellow": "\x1b[33m",
37+
"black": "\x1b[30m",
38+
"white": "\x1b[40m",
39+
}
40+
41+
42+
def colorize_string(string, color):
43+
"""
44+
Returns the requested string, wrapped in ANSI color codes to colorize it as
45+
requested. On platforms where colors are not supported, this just returns
46+
the string passed into it.
47+
"""
48+
if not DO_COLORS:
49+
return string
50+
51+
col = COLOR_CODE_MAP.get(color, CLEAR_COLOR)
52+
53+
return f"{col}{string}{CLEAR_COLOR}"

0 commit comments

Comments
 (0)