Skip to content

Commit 533e9ce

Browse files
Improve maintainability of api_request.py (#748)
1 parent 679561f commit 533e9ce

File tree

1 file changed

+146
-35
lines changed

1 file changed

+146
-35
lines changed

linodecli/api_request.py

Lines changed: 146 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os
88
import sys
99
import time
10-
from typing import Any, Iterable, List, Optional
10+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
1111

1212
import requests
1313
from packaging import version
@@ -23,13 +23,22 @@
2323
)
2424
from .helpers import handle_url_overrides
2525

26+
if TYPE_CHECKING:
27+
from linodecli.cli import CLI
2628

27-
def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]):
29+
30+
def get_all_pages(
31+
ctx: "CLI", operation: OpenAPIOperation, args: List[str]
32+
) -> Dict[str, Any]:
2833
"""
29-
Receive all pages of a resource from multiple
30-
API responses then merge into one page.
34+
Retrieves all pages of a resource from multiple API responses
35+
and merges them into a single page.
36+
37+
:param ctx: The main CLI object that maintains API request state.
38+
:param operation: The OpenAPI operation to be executed.
39+
:param args: A list of arguments passed to the API request.
3140
32-
:param ctx: The main CLI object
41+
:return: A dictionary containing the merged results from all pages.
3342
"""
3443

3544
ctx.page_size = 500
@@ -38,6 +47,7 @@ def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]):
3847

3948
total_pages = result.get("pages")
4049

50+
# If multiple pages exist, generate results for all additional pages
4151
if total_pages and total_pages > 1:
4252
pages_needed = range(2, total_pages + 1)
4353

@@ -51,19 +61,25 @@ def get_all_pages(ctx, operation: OpenAPIOperation, args: List[str]):
5161

5262

5363
def do_request(
54-
ctx,
55-
operation,
56-
args,
57-
filter_header=None,
58-
skip_error_handling=False,
64+
ctx: "CLI",
65+
operation: OpenAPIOperation,
66+
args: List[str],
67+
filter_header: Optional[dict] = None,
68+
skip_error_handling: bool = False,
5969
) -> (
6070
Response
6171
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements
6272
"""
63-
Makes a request to an operation's URL and returns the resulting JSON, or
64-
prints and error if a non-200 comes back
73+
Makes an HTTP request to an API operation's URL and returns the resulting response.
74+
Optionally retries the request if specified, handles errors, and supports debugging.
75+
76+
:param ctx: The main CLI object that maintains API request state.
77+
:param operation: The OpenAPI operation to be executed.
78+
:param args: A list of arguments passed to the API request.
79+
:param filter_header: Optional filter header to be included in the request (default: None).
80+
:param skip_error_handling: Whether to skip error handling (default: False).
6581
66-
:param ctx: The main CLI object
82+
:return: The `Response` object returned from the HTTP request.
6783
"""
6884
# TODO: Revisit using pre-built calls from OpenAPI
6985
method = getattr(requests, operation.method)
@@ -95,31 +111,45 @@ def do_request(
95111
if ctx.debug_request:
96112
_print_response_debug_info(result)
97113

114+
# Retry the request if necessary
98115
while _check_retry(result) and not ctx.no_retry and ctx.retry_count < 3:
99116
time.sleep(_get_retry_after(result.headers))
100117
ctx.retry_count += 1
101118
result = method(url, headers=headers, data=body, verify=API_CA_PATH)
102119

103120
_attempt_warn_old_version(ctx, result)
104121

122+
# If the response is an error and we're not skipping error handling, raise an error
105123
if not 199 < result.status_code < 399 and not skip_error_handling:
106124
_handle_error(ctx, result)
107125

108126
return result
109127

110128

111-
def _merge_results_data(results: Iterable[dict]):
112-
"""Merge multiple json response into one"""
129+
def _merge_results_data(results: Iterable[dict]) -> Optional[Dict[str, Any]]:
130+
"""
131+
Merges multiple JSON responses into one, combining their 'data' fields
132+
and setting 'pages' and 'page' to 1 if they exist.
133+
134+
:param results: An iterable of dictionaries containing JSON response data.
135+
136+
:return: A merged dictionary containing the combined data or None if no results are provided.
137+
"""
113138

114139
iterator = iter(results)
115140
merged_result = next(iterator, None)
141+
142+
# If there are no results to merge, return None
116143
if not merged_result:
117144
return None
118145

146+
# Set 'pages' and 'page' to 1 if they exist in the first result
119147
if "pages" in merged_result:
120148
merged_result["pages"] = 1
121149
if "page" in merged_result:
122150
merged_result["page"] = 1
151+
152+
# Merge the 'data' fields by combining the 'data' from all results
123153
if "data" in merged_result:
124154
merged_result["data"] += list(
125155
itertools.chain.from_iterable(r["data"] for r in iterator)
@@ -128,22 +158,43 @@ def _merge_results_data(results: Iterable[dict]):
128158

129159

130160
def _generate_all_pages_results(
131-
ctx,
161+
ctx: "CLI",
132162
operation: OpenAPIOperation,
133163
args: List[str],
134164
pages_needed: Iterable[int],
135-
):
165+
) -> Iterable[dict]:
136166
"""
137-
:param ctx: The main CLI object
167+
Generates results from multiple pages by iterating through the specified page numbers
168+
and yielding the JSON response for each page.e.
169+
170+
:param ctx: The main CLI object that maintains API request state.
171+
:param operation: The OpenAPI operation to be executed.
172+
:param args: A list of arguments passed to the API request.
173+
:param pages_needed: An iterable of page numbers to request.
174+
175+
:yield: The JSON response (as a dictionary) for each requested page.
138176
"""
139177
for p in pages_needed:
140178
ctx.page = p
141179
yield do_request(ctx, operation, args).json()
142180

143181

144182
def _build_filter_header(
145-
operation, parsed_args, filter_header=None
183+
operation: OpenAPIOperation,
184+
parsed_args: Any,
185+
filter_header: Optional[dict] = None,
146186
) -> Optional[str]:
187+
"""
188+
Builds a filter header for a request based on the parsed
189+
arguments. This is used for GET requests to filter results according
190+
to the specified arguments. If no filter is provided, returns None.
191+
192+
:param operation: The OpenAPI operation to be executed.
193+
:param parsed_args: The parsed arguments from the CLI or request
194+
:param filter_header: Optional filter header to be included in the request (default: None).
195+
196+
:return: A JSON string representing the filter header, or None if no filters are applied.
197+
"""
147198
if operation.method != "get":
148199
# Non-GET operations don't support filters
149200
return None
@@ -188,7 +239,19 @@ def _build_filter_header(
188239
return json.dumps(result) if len(result) > 0 else None
189240

190241

191-
def _build_request_url(ctx, operation, parsed_args) -> str:
242+
def _build_request_url(
243+
ctx: "CLI", operation: OpenAPIOperation, parsed_args: Any
244+
) -> str:
245+
"""
246+
Constructs the full request URL for an API operation,
247+
incorporating user-defined API host and scheme overrides.
248+
249+
:param ctx: The main CLI object that maintains API request state.
250+
:param operation: The OpenAPI operation to be executed.
251+
:param parsed_args: The parsed arguments from the CLI or request.
252+
253+
:return: The fully constructed request URL as a string.
254+
"""
192255
url_base = handle_url_overrides(
193256
operation.url_base,
194257
host=ctx.config.get_value("api_host"),
@@ -206,6 +269,7 @@ def _build_request_url(ctx, operation, parsed_args) -> str:
206269
**vars(parsed_args),
207270
)
208271

272+
# Append pagination parameters for GET requests
209273
if operation.method == "get":
210274
result += f"?page={ctx.page}&page_size={ctx.page_size}"
211275

@@ -214,10 +278,15 @@ def _build_request_url(ctx, operation, parsed_args) -> str:
214278

215279
def _traverse_request_body(o: Any) -> Any:
216280
"""
217-
This function traverses is intended to be called immediately before
218-
request body serialization and contains special handling for dropping
219-
keys with null values and translating ExplicitNullValue instances into
220-
serializable null values.
281+
Traverses a request body before serialization, handling special cases:
282+
- Drops keys with `None` values (implicit null values).
283+
- Converts `ExplicitEmptyListValue` instances to empty lists.
284+
- Converts `ExplicitNullValue` instances to `None`.
285+
- Recursively processes nested dictionaries and lists.
286+
287+
:param o: The request body object to process.
288+
289+
:return: A modified version of `o` with appropriate transformations applied.
221290
"""
222291
if isinstance(o, dict):
223292
result = {}
@@ -253,7 +322,18 @@ def _traverse_request_body(o: Any) -> Any:
253322
return o
254323

255324

256-
def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
325+
def _build_request_body(
326+
ctx: "CLI", operation: OpenAPIOperation, parsed_args: Any
327+
) -> Optional[str]:
328+
"""
329+
Builds the request body for API calls, handling default values and nested structures.
330+
331+
:param ctx: The main CLI object that maintains API request state.
332+
:param operation: The OpenAPI operation to be executed.
333+
:param parsed_args: The parsed arguments from the CLI or request.
334+
335+
:return: A JSON string representing the request body, or None if not applicable.
336+
"""
257337
if operation.method == "get":
258338
# Get operations don't have a body
259339
return None
@@ -266,7 +346,7 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
266346

267347
expanded_json = {}
268348

269-
# expand paths
349+
# Expand dotted keys into nested dictionaries
270350
for k, v in vars(parsed_args).items():
271351
if v is None or k in param_names:
272352
continue
@@ -282,9 +362,17 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]:
282362
return json.dumps(_traverse_request_body(expanded_json))
283363

284364

285-
def _print_request_debug_info(method, url, headers, body):
365+
def _print_request_debug_info(
366+
method: Any, url: str, headers: Dict[str, Any], body: Optional[str]
367+
) -> None:
286368
"""
287-
Prints debug info for an HTTP request
369+
Prints debug info for an HTTP request.
370+
371+
:param method: An object with a `__name__` attribute representing
372+
the HTTP method (e.g., "get", "post").
373+
:param url: The full request URL.
374+
:param headers: A dictionary of request headers.
375+
:param body: The request body as a string, or None if no body exists.
288376
"""
289377
print(f"> {method.__name__.upper()} {url}", file=sys.stderr)
290378
for k, v in headers.items():
@@ -297,9 +385,11 @@ def _print_request_debug_info(method, url, headers, body):
297385
print("> ", file=sys.stderr)
298386

299387

300-
def _print_response_debug_info(response):
388+
def _print_response_debug_info(response: Any) -> None:
301389
"""
302-
Prints debug info for a response from requests
390+
Prints debug info for a response from requests.
391+
392+
:param response: The response object returned by a `requests` call.
303393
"""
304394
# these come back as ints, convert to HTTP version
305395
http_version = response.raw.version / 10
@@ -316,7 +406,14 @@ def _print_response_debug_info(response):
316406
print("< ", file=sys.stderr)
317407

318408

319-
def _attempt_warn_old_version(ctx, result):
409+
def _attempt_warn_old_version(ctx: "CLI", result: Any) -> None:
410+
"""
411+
Checks if the API version is newer than the CLI version and
412+
warns the user if an upgrade is available.
413+
414+
:param ctx: The main CLI object that maintains API request state.
415+
:param result: The HTTP response object from the API request.
416+
"""
320417
if ctx.suppress_warnings:
321418
return
322419

@@ -398,9 +495,13 @@ def _attempt_warn_old_version(ctx, result):
398495
)
399496

400497

401-
def _handle_error(ctx, response):
498+
def _handle_error(ctx: "CLI", response: Any) -> None:
402499
"""
403-
Given an error message, properly displays the error to the user and exits.
500+
Handles API error responses by displaying a formatted error message
501+
and exiting with the appropriate error code.
502+
503+
:param ctx: The main CLI object that maintains API request state.
504+
:param response: The HTTP response object from the API request.
404505
"""
405506
print(f"Request failed: {response.status_code}", file=sys.stderr)
406507

@@ -422,7 +523,9 @@ def _handle_error(ctx, response):
422523

423524
def _check_retry(response):
424525
"""
425-
Check for valid retry scenario, returns true if retry is valid
526+
Check for valid retry scenario, returns true if retry is valid.
527+
528+
:param response: The HTTP response object from the API request.
426529
"""
427530
if response.status_code in (408, 429):
428531
# request timed out or rate limit exceeded
@@ -436,6 +539,14 @@ def _check_retry(response):
436539
)
437540

438541

439-
def _get_retry_after(headers):
542+
def _get_retry_after(headers: Dict[str, str]) -> int:
543+
"""
544+
Extracts the "Retry-After" value from the response headers and returns it
545+
as an integer representing the number of seconds to wait before retrying.
546+
547+
:param headers: The HTTP response headers as a dictionary.
548+
549+
:return: The number of seconds to wait before retrying, or 0 if not specified.
550+
"""
440551
retry_str = headers.get("Retry-After", "")
441552
return int(retry_str) if retry_str else 0

0 commit comments

Comments
 (0)