diff --git a/linodecli/api_request.py b/linodecli/api_request.py index aa04a924b..89b922f53 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -18,8 +18,8 @@ from linodecli.helpers import API_CA_PATH, API_VERSION_OVERRIDE from .baked.operation import ( - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, OpenAPIOperation, ) @@ -314,14 +314,14 @@ def _traverse_request_body(o: Any) -> Any: result[k] = [] continue - if isinstance(v, ExplicitEmptyDictValue): - result[k] = {} - continue - if isinstance(v, ExplicitNullValue): result[k] = None continue + if isinstance(v, ExplicitJsonValue): + result[k] = v.json_value + continue + value = _traverse_request_body(v) # We should exclude implicit empty lists diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index cf93279a1..336fc25e3 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -10,9 +10,11 @@ import re import sys from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass from getpass import getpass from os import environ, path -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse import openapi3.paths @@ -49,46 +51,12 @@ def parse_boolean(value: str) -> bool: raise argparse.ArgumentTypeError("Expected a boolean value") -def parse_dict( - value: str, -) -> Union[Dict[str, Any], "ExplicitEmptyDictValue", "ExplicitEmptyListValue"]: - """ - A helper function to decode incoming JSON data as python dicts. This is - intended to be passed to the `type=` kwarg for ArgumentParaser.add_argument. - - :param value: The json string to be parsed into dict. - :type value: str - - :returns: The dict value of the input. - :rtype: dict, ExplicitEmptyDictValue, or ExplicitEmptyListValue - """ - if not isinstance(value, str): - raise argparse.ArgumentTypeError("Expected a JSON string") - - try: - result = json.loads(value) - except Exception as e: - raise argparse.ArgumentTypeError("Expected a JSON string") from e - - # This is necessary because empty dicts and lists are excluded from requests - # by default, but we still want to support user-defined empty dict - # strings. This is particularly helpful when updating LKE node pool - # labels and taints. - if isinstance(result, dict) and result == {}: - return ExplicitEmptyDictValue() - - if isinstance(result, list) and result == []: - return ExplicitEmptyListValue() - - return result - - TYPES = { "string": str, "integer": int, "boolean": parse_boolean, "array": list, - "object": parse_dict, + "object": lambda value: ExplicitJsonValue(json_value=json.loads(value)), "number": float, } @@ -106,13 +74,16 @@ class ExplicitEmptyListValue: """ -class ExplicitEmptyDictValue: +@dataclass +class ExplicitJsonValue: """ - A special type used to explicitly pass empty dictionaries to the API. + A special type used to explicitly pass raw JSON from user input as is. """ + json_value: Any + -def wrap_parse_nullable_value(arg_type: str) -> TYPES: +def wrap_parse_nullable_value(arg_type: str) -> Callable[[Any], Any]: """ A helper function to parse `null` as None for nullable CLI args. This is intended to be called and passed to the `type=` kwarg for ArgumentParser.add_argument. diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 253f0385b..a222d60db 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -14,8 +14,8 @@ from linodecli import ExitCodes, api_request from linodecli.baked.operation import ( - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, ) @@ -667,7 +667,7 @@ def test_traverse_request_body(self): "baz": ExplicitNullValue(), }, "cool": [], - "pretty_cool": ExplicitEmptyDictValue(), + "pretty_cool": ExplicitJsonValue(json_value={}), "cooler": ExplicitEmptyListValue(), "coolest": ExplicitNullValue(), } diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 960502e24..1e3118e2b 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -6,8 +6,8 @@ from linodecli.baked import operation from linodecli.baked.operation import ( TYPES, - ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, OpenAPIOperation, ) @@ -195,7 +195,7 @@ def test_parse_args_object_list(self, create_operation): "field_string": "test1", "field_int": 123, "field_dict": {"nested_string": "test2", "nested_int": 789}, - "field_array": ["foo", "bar"], + "field_array": ExplicitJsonValue(json_value=["foo", "bar"]), "nullable_string": None, # We expect this to be filtered out later }, {"field_int": 456, "field_dict": {"nested_string": "test3"}}, @@ -216,7 +216,7 @@ def test_parse_args_object_list_json(self, create_operation): ["--object_list", json.dumps(expected)] ) - assert result.object_list == expected + assert result.object_list.json_value == expected def test_parse_args_conflicting_parent_child(self, create_operation): stderr_buf = io.StringIO() @@ -296,19 +296,27 @@ def test_object_arg_action_basic(self): # User specifies a normal object (dict) result = parser.parse_args(["--foo", '{"test-key": "test-value"}']) - assert getattr(result, "foo") == {"test-key": "test-value"} + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == {"test-key": "test-value"} # User specifies a normal object (list) result = parser.parse_args(["--foo", '[{"test-key": "test-value"}]']) - assert getattr(result, "foo") == [{"test-key": "test-value"}] + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == [{"test-key": "test-value"}] # User wants an explicitly empty object (dict) result = parser.parse_args(["--foo", "{}"]) - assert isinstance(getattr(result, "foo"), ExplicitEmptyDictValue) + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == {} # User wants an explicitly empty object (list) result = parser.parse_args(["--foo", "[]"]) - assert isinstance(getattr(result, "foo"), ExplicitEmptyListValue) + foo = getattr(result, "foo") + assert isinstance(foo, ExplicitJsonValue) + assert foo.json_value == [] # User doesn't specify the list result = parser.parse_args([])