From 5f1bc0b26993be45bd50e08156bb14db41eefc2c Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:56:17 +0530 Subject: [PATCH] fix(cli): support unparameterized list/dict annotations in the CLI parser parse_list and parse_dict unconditionally indexed get_args(annotation), so any unparameterized collection annotation crashed the CLI parser: - def f(tags: list) + tags=[1, 2, 3] raised ListParseError ('tuple index out of range') - def f(mapping: dict) + mapping={'a': 1} raised DictParseError ('not enough values to unpack') - Optional[list]/Optional[dict] failed the same way through parse_union - Union[list, str] silently fell through to str and returned the raw string '[1, 2]' instead of a list - bare typing.List/typing.Dict additionally crashed in _maybe_resolve_annotation when rebuilding the generic from an empty args tuple Unparameterized annotations now fall back to untyped literal parsing, and _maybe_resolve_annotation returns them unchanged. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- nemo_run/cli/cli_parser.py | 28 +++++++++++++++++++------- test/cli/test_cli_parser.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/nemo_run/cli/cli_parser.py b/nemo_run/cli/cli_parser.py index a68348e4..bd22a2e6 100644 --- a/nemo_run/cli/cli_parser.py +++ b/nemo_run/cli/cli_parser.py @@ -869,8 +869,13 @@ def parse_list(self, value: str, annotation: Type[List]) -> List: parsed = ast.literal_eval(value) if not isinstance(parsed, list): raise ValueError("Not a list") - elem_type = get_args(annotation)[0] - return [self.parse(str(item), elem_type) for item in parsed] + type_args = get_args(annotation) + if type_args: + elem_type = type_args[0] + return [self.parse(str(item), elem_type) for item in parsed] + # Unparameterized annotations (e.g. `list`, `List`, `Optional[list]` + # resolved to a bare list) have no element type to coerce to. + return parsed except Exception as e: raise ListParseError(value, List, f"Invalid list: {str(e)}") @@ -891,11 +896,16 @@ def parse_dict(self, value: str, annotation: Type[Dict]) -> Dict: parsed = ast.literal_eval(value) if not isinstance(parsed, dict): raise ValueError("Not a dict") - key_type, val_type = get_args(annotation) - return { - self.parse(str(k), key_type): self.parse(str(v), val_type) - for k, v in parsed.items() - } + type_args = get_args(annotation) + if type_args: + key_type, val_type = type_args + return { + self.parse(str(k), key_type): self.parse(str(v), val_type) + for k, v in parsed.items() + } + # Unparameterized annotations (e.g. `dict`, `Dict`, `Optional[dict]` + # resolved to a bare dict) have no key/value types to coerce to. + return parsed except Exception as e: raise DictParseError(value, Dict, f"Invalid dict: {str(e)}") @@ -1469,6 +1479,10 @@ def _maybe_resolve_annotation(fn: Callable, arg_name: str, annotation: Any) -> A # Case 3: Annotation is a generic type (e.g., Optional, List, Union) elif (origin := get_origin(annotation)) is not None: args = get_args(annotation) + if not args: + # Unparameterized generics (e.g. bare `List`, `Dict`) have no + # arguments to resolve and are handled downstream as-is. + return annotation resolved_args = tuple(_maybe_resolve_annotation(fn, arg_name, arg) for arg in args) if origin is list: return List[resolved_args[0]] diff --git a/test/cli/test_cli_parser.py b/test/cli/test_cli_parser.py index c3fb9a54..19aade5b 100644 --- a/test/cli/test_cli_parser.py +++ b/test/cli/test_cli_parser.py @@ -122,6 +122,33 @@ def func(a: List[List[int]]): assert parse_cli_args(func, ["a=[[1, 2], [3, 4]]"]).a == [[1, 2], [3, 4]] + def test_unparameterized_list_parsing(self): + def func(a: list, b: List = None, c: Optional[list] = None): + pass + + assert parse_cli_args(func, ["a=[1, 2, 3]"]).a == [1, 2, 3] + assert parse_cli_args(func, ["a=[]"]).a == [] + assert parse_cli_args(func, ["b=[1, 2]"]).b == [1, 2] + assert parse_cli_args(func, ["c=[1, 2]"]).c == [1, 2] + + def test_unparameterized_dict_parsing(self): + def func(a: dict, b: Dict = None, c: Optional[dict] = None): + pass + + assert parse_cli_args(func, ["a={'x': 1}"]).a == {"x": 1} + assert parse_cli_args(func, ["a={}"]).a == {} + assert parse_cli_args(func, ["b={'x': 1}"]).b == {"x": 1} + assert parse_cli_args(func, ["c={'x': 1}"]).c == {"x": 1} + + def test_union_with_list_not_misparsed_as_string(self): + def func(a: Union[list, str] = None, b: Union[dict, str] = None): + pass + + assert parse_cli_args(func, ["a=[1, 2]"]).a == [1, 2] + assert parse_cli_args(func, ["a=hello"]).a == "hello" + assert parse_cli_args(func, ["b={'x': 1}"]).b == {"x": 1} + assert parse_cli_args(func, ["b=hello"]).b == "hello" + def test_dict_parsing(self): def func(a: Dict[str, int]): pass @@ -506,6 +533,18 @@ def test_parse_dict(self): with pytest.raises(ParseError, match="Failed to parse"): parse_value('{"a": 1, "b": "two"}', Dict[str, int]) + def test_parse_unparameterized_list(self): + assert parse_value("[1, 2, 3]", list) == [1, 2, 3] + assert parse_value("[1, 2, 3]", List) == [1, 2, 3] + assert parse_value("[1, 2, 3]", Optional[list]) == [1, 2, 3] + assert parse_value("None", Optional[list]) is None + + def test_parse_unparameterized_dict(self): + assert parse_value('{"a": 1}', dict) == {"a": 1} + assert parse_value('{"a": 1}', Dict) == {"a": 1} + assert parse_value('{"a": 1}', Optional[dict]) == {"a": 1} + assert parse_value("None", Optional[dict]) is None + def test_parse_union(self): assert parse_value("123", Union[int, str]) == 123 assert parse_value("hello", Union[int, str]) == "hello"