From aac5c70d2308e1e1063378abe42139e5090d584b Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:38:01 -0500 Subject: [PATCH] List of paths types improvements (#802) --- CHANGELOG.rst | 14 ++++++++++- jsonargparse/_signatures.py | 5 +++- jsonargparse/_typehints.py | 14 +++++++++-- jsonargparse_tests/test_dataclasses.py | 2 +- jsonargparse_tests/test_paths.py | 33 +++++++++++++++++++++++--- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fbbe2c13..91ca9fc6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,9 +12,15 @@ The semantic versioning only considers the public API as described in paths are considered internals and can change in minor and patch releases. -v4.44.1 (unreleased) +v4.45.0 (unreleased) -------------------- +Added +^^^^^ +- Signature methods now when given ``sub_configs=True``, list of paths types can + now receive a file containing a list of paths (`#816 + `__). + Fixed ^^^^^ - Evaluation of postponed annotations for dataclass inheritance across modules @@ -23,6 +29,12 @@ Fixed - Getting parameter descriptions from docstrings not working for dataclass inheritance (`#815 `__). +Changed +^^^^^^^ +- List of paths types now show in the help the supported options for providing + paths like ``'["PATH1",...]' | LIST_OF_PATHS_FILE | -`` (`#816 + `__). + v4.44.0 (2025-11-25) -------------------- diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index 12ab3a81..f7361374 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -24,6 +24,7 @@ LazyInitBaseClass, callable_instances, get_subclass_names, + is_list_pathlike, is_optional, not_required_types, sequence_origin_types, @@ -419,7 +420,9 @@ def _add_signature_parameter( else: register_pydantic_type(annotation) enable_path = sub_configs and ( - is_subclass_typehint or ActionTypeHint.is_return_subclass_typehint(annotation) + is_subclass_typehint + or ActionTypeHint.is_return_subclass_typehint(annotation) + or is_list_pathlike(annotation) ) args = ActionTypeHint.prepare_add_argument( args=args, diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index d429d210..039a27b3 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -592,7 +592,7 @@ def _check_type(self, value, append=False, cfg=None, mode=None): ): ex = None elif self._enable_path and config_path is None and isinstance(orig_val, str): - msg = f"\n- Expected a config path but {orig_val} either not accessible or invalid\n- " + msg = f"\n- Expected a path but {orig_val} either not accessible or invalid\n- " raise type(ex)(msg + str(ex)) from ex if ex: raise ex @@ -713,6 +713,14 @@ def is_pathlike(typehint) -> bool: return is_subclass(typehint, os.PathLike) +def is_list_pathlike(typehint) -> bool: + typehint_origin = get_typehint_origin(typehint) + if typehint_origin in sequence_origin_types: + subtype = typehint.__args__[0] + return is_pathlike(subtype) + return False + + def raise_unexpected_value(message: str, val: Any = inspect._empty, exception: Optional[Exception] = None) -> NoReturn: if val is not inspect._empty: message += f". Got value: {val}" @@ -1643,7 +1651,9 @@ def typehint_metavar(typehint): elif is_optional(typehint, Enum): enum = typehint.__args__[0] metavar = iter_to_set_str(list(enum.__members__) + ["null"]) - elif typehint_origin in tuple_set_origin_types: + elif is_list_pathlike(typehint): + metavar = "'[\"PATH1\",...]' | LIST_OF_PATHS_FILE | -" + elif typehint_origin in tuple_set_origin_types or typehint_origin in sequence_origin_types: metavar = "[ITEM,...]" return metavar diff --git a/jsonargparse_tests/test_dataclasses.py b/jsonargparse_tests/test_dataclasses.py index 67f88dcd..3823ac00 100644 --- a/jsonargparse_tests/test_dataclasses.py +++ b/jsonargparse_tests/test_dataclasses.py @@ -626,7 +626,7 @@ def __init__(self, prm_1: float, prm_2: bool): ], ) def test_class_path_union_mixture_dataclass_and_class(parser, union_type): - parser.add_argument("--union", type=union_type, enable_path=True) + parser.add_argument("--union", type=union_type) value = {"class_path": f"{__name__}.UnionData", "init_args": {"data_a": 2, "data_b": "x"}} cfg = parser.parse_args([f"--union={json.dumps(value)}"]) diff --git a/jsonargparse_tests/test_paths.py b/jsonargparse_tests/test_paths.py index bfbe000e..f2650ab3 100644 --- a/jsonargparse_tests/test_paths.py +++ b/jsonargparse_tests/test_paths.py @@ -537,7 +537,7 @@ def test_enable_path_dict(parser, tmp_cwd): assert data == cfg["data"] with pytest.raises(ArgumentError) as ctx: parser.parse_args(["--data=does-not-exist.yaml"]) - ctx.match("does-not-exist.yaml either not accessible or invalid") + ctx.match("Expected a path but does-not-exist.yaml either not accessible or invalid") def test_enable_path_subclass(parser, tmp_cwd): @@ -616,8 +616,10 @@ def test_enable_path_list_path_fr(parser, tmp_cwd, mock_stdin, subtests): with subtests.test("paths list nargs='+' path not exist"): pytest.raises(ArgumentError, lambda: parser.parse_args(["--lists", str(list_file4)])) - with subtests.test("paths list nargs='+' list not exist"): - pytest.raises(ArgumentError, lambda: parser.parse_args(["--lists", "no-such-file"])) + with subtests.test("paths list nargs='+' list not exist"): # TODO: check error message + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--lists", "no-such-file"]) + ctx.match("Expected a path but no-such-file either not accessible or invalid") def test_enable_path_list_path_fr_default_stdin(parser, tmp_cwd, mock_stdin, subtests): @@ -643,6 +645,31 @@ def test_enable_path_list_path_fr_default_stdin(parser, tmp_cwd, mock_stdin, sub assert all(isinstance(x, Path_fr) for x in cfg.list) assert ["file1", "file2"] == [str(x) for x in cfg.list] + with subtests.test("help"): + help_str = get_parser_help(parser) + assert "'[\"PATH1\",...]' | LIST_OF_PATHS_FILE | -" in help_str + + +class ClassListPath: + def __init__(self, files: list[Path_fr]): + self.files = files + + +def test_add_class_list_path(parser, tmp_cwd): + (tmp_cwd / "file1").touch() + (tmp_cwd / "file2").touch() + list_file1 = tmp_cwd / "files.lst" + list_file1.write_text("file1\nfile2\n") + + parser.add_class_arguments(ClassListPath, "cls", sub_configs=True) + + cfg = parser.parse_args([f"--cls.files={list_file1}"]) + assert all(isinstance(x, Path_fr) for x in cfg.cls.files) + assert ["file1", "file2"] == [str(x) for x in cfg.cls.files] + + help_str = get_parser_help(parser) + assert "'[\"PATH1\",...]' | LIST_OF_PATHS_FILE | -" in help_str + class DataOptionalPath: def __init__(self, path: Optional[os.PathLike] = None):