From e1b4b8f08d8bd611cdbb26f3169938e6646e8cd7 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Fri, 8 Aug 2025 01:35:33 -0400 Subject: [PATCH 1/3] Pass JSON value parameter as is to the API call --- linodecli/api_request.py | 5 ++++ linodecli/baked/operation.py | 51 ++++++++++-------------------------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index aa04a924b..742b86e81 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -20,6 +20,7 @@ from .baked.operation import ( ExplicitEmptyDictValue, ExplicitEmptyListValue, + ExplicitJsonValue, ExplicitNullValue, OpenAPIOperation, ) @@ -322,6 +323,10 @@ def _traverse_request_body(o: Any) -> Any: 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..c09a9afcd 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, } @@ -112,7 +80,16 @@ class ExplicitEmptyDictValue: """ -def wrap_parse_nullable_value(arg_type: str) -> TYPES: +@dataclass +class ExplicitJsonValue: + """ + 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) -> 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. From 90515ebd4afaa9662800d2dfab995d6b847cb2ea Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Fri, 8 Aug 2025 02:13:55 -0400 Subject: [PATCH 2/3] Fix tests --- tests/unit/test_operation.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) 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([]) From e0ac2955e18c6bd337ba3b00760fb10c911fdc7e Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Fri, 8 Aug 2025 02:19:35 -0400 Subject: [PATCH 3/3] Remove `ExplicitEmptyDictValue` --- linodecli/api_request.py | 5 ----- linodecli/baked/operation.py | 6 ------ tests/unit/test_api_request.py | 4 ++-- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index 742b86e81..89b922f53 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -18,7 +18,6 @@ from linodecli.helpers import API_CA_PATH, API_VERSION_OVERRIDE from .baked.operation import ( - ExplicitEmptyDictValue, ExplicitEmptyListValue, ExplicitJsonValue, ExplicitNullValue, @@ -315,10 +314,6 @@ 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 diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index c09a9afcd..336fc25e3 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -74,12 +74,6 @@ class ExplicitEmptyListValue: """ -class ExplicitEmptyDictValue: - """ - A special type used to explicitly pass empty dictionaries to the API. - """ - - @dataclass class ExplicitJsonValue: """ 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(), }