diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 09bdad9f..8dfe29a8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,9 @@ Added paths to sub-config files, instead of this only being supported for the value of an entire argument (`#940 `__). +- ``shtab`` completion scripts now include file and directory completions for + arguments typed as pydantic's ``FilePath`` and ``DirectoryPath`` (`#943 + `__). Fixed ^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 8d8d4b9b..fbcbb8ea 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -577,6 +577,11 @@ Some notes about this support are: serializing the actual value. There is also ``jsonargparse.typing.SecretStr`` to support the same behavior without the need of a dependency. +- ``pydantic.FilePath`` and ``pydantic.DirectoryPath`` types are supported, + running the corresponding pydantic validation when parsing. Arguments with + these types also get file and directory tab completions, see + :ref:`tab-completion`. + - ``Callable`` is supported by either giving a dot import path to a callable object or by giving a dict with a ``class_path`` and optionally ``init_args`` entries. The specified class must either instantiate into a callable or be a diff --git a/jsonargparse/_completions.py b/jsonargparse/_completions.py index f618962e..94ec8c51 100644 --- a/jsonargparse/_completions.py +++ b/jsonargparse/_completions.py @@ -14,6 +14,7 @@ from ._actions import ActionConfigFile, ActionFail, _ActionConfigLoad, _ActionHelpClassPath, remove_actions from ._common import NonParsingAction, get_optionals_as_positionals_actions, get_parsing_setting +from ._optionals import get_pydantic_path_type from ._parameter_resolvers import get_signature_parameters from ._typehints import ( ActionTypeHint, @@ -209,7 +210,10 @@ def shtab_prepare_action(action, parser) -> None: subtypes = [s for s in typehint.__args__ if s not in {NoneType, str, dict, list, tuple, bytes}] if len(subtypes) == 1: typehint = subtypes[0] - if is_subclass(typehint, Path): + pydantic_path_type = get_pydantic_path_type(typehint) + if pydantic_path_type: + complete = shtab.DIRECTORY if pydantic_path_type == "dir" else shtab.FILE + elif is_subclass(typehint, Path): assert hasattr(typehint, "_mode") if "f" in typehint._mode: complete = shtab.FILE diff --git a/jsonargparse/_optionals.py b/jsonargparse/_optionals.py index 9b645812..c57f88dc 100644 --- a/jsonargparse/_optionals.py +++ b/jsonargparse/_optionals.py @@ -453,3 +453,14 @@ def validate_annotated(value, typehint: type): from pydantic import TypeAdapter return TypeAdapter(typehint).validate_python(value) + + +def get_pydantic_path_type(typehint) -> Union[str, None]: + """Returns the path type of pydantic's ``FilePath`` ("file"), ``DirectoryPath`` ("dir") and + ``NewPath`` ("new") types, or None if the typehint is not one of them.""" + if is_annotated_validator(typehint): + for metadata in typehint.__metadata__: + metadata_class = type(metadata) + if metadata_class.__module__ == "pydantic.types" and metadata_class.__name__ == "PathType": + return metadata.path_type + return None diff --git a/jsonargparse_tests/test_pydantic.py b/jsonargparse_tests/test_pydantic.py index f42a7ff3..66c3bb55 100644 --- a/jsonargparse_tests/test_pydantic.py +++ b/jsonargparse_tests/test_pydantic.py @@ -2,6 +2,7 @@ import dataclasses import json +import pathlib from copy import deepcopy from typing import Dict, List, Literal, Optional, Union @@ -10,6 +11,7 @@ from jsonargparse import ArgumentError, ArgumentParser, Namespace, set_parsing_settings from jsonargparse._optionals import ( docstring_parser_support, + get_pydantic_path_type, pydantic_support, pydantic_supports_field_init, typing_extensions_import, @@ -31,6 +33,11 @@ reason="Not supported for pydantic.v1", ) +skip_if_pydantic_v1 = pytest.mark.skipif( + pydantic_support < 2 or pydantic is getattr(__import__("pydantic"), "v1", None), + reason="Not supported for pydantic v1", +) + @pytest.fixture(autouse=True, scope="module") def missing_pydantic(): @@ -192,6 +199,10 @@ class NestedModel(pydantic.BaseModel): class PydanticNestedDict(pydantic.BaseModel): nested: Optional[Dict[str, NestedModel]] = None + class PydanticPaths(pydantic.BaseModel): + file: pydantic.FilePath + dir: pydantic.DirectoryPath + def none(x): return x @@ -333,6 +344,67 @@ def test_nested_dict(self, parser): assert isinstance(init.model.nested["key"], NestedModel) +@skip_if_pydantic_v1 +class TestPydanticPathTypes: + def test_get_pydantic_path_type(self): + assert get_pydantic_path_type(pydantic.FilePath) == "file" + assert get_pydantic_path_type(pydantic.DirectoryPath) == "dir" + assert get_pydantic_path_type(pathlib.Path) is None + assert get_pydantic_path_type(str) is None + + def test_file_path(self, parser, file_r): + parser.add_argument("--path", type=pydantic.FilePath) + cfg = parser.parse_args([f"--path={file_r}"]) + assert cfg.path == pathlib.Path(file_r) + assert json_or_yaml_load(parser.dump(cfg)) == {"path": file_r} + + def test_file_path_not_exists(self, parser, tmp_cwd): + parser.add_argument("--path", type=pydantic.FilePath) + with pytest.raises(ArgumentError, match='Parser key "path"'): + parser.parse_args(["--path=not_exist"]) + + def test_file_path_is_directory(self, parser, tmp_cwd): + parser.add_argument("--path", type=pydantic.FilePath) + pathlib.Path("sub_dir").mkdir() + with pytest.raises(ArgumentError, match='Parser key "path"'): + parser.parse_args(["--path=sub_dir"]) + + def test_directory_path(self, parser, tmp_cwd): + parser.add_argument("--path", type=pydantic.DirectoryPath) + pathlib.Path("sub_dir").mkdir() + cfg = parser.parse_args(["--path=sub_dir"]) + assert cfg.path == pathlib.Path("sub_dir") + assert json_or_yaml_load(parser.dump(cfg)) == {"path": "sub_dir"} + + def test_directory_path_not_exists(self, parser, tmp_cwd): + parser.add_argument("--path", type=pydantic.DirectoryPath) + with pytest.raises(ArgumentError, match='Parser key "path"'): + parser.parse_args(["--path=not_exist"]) + + def test_directory_path_is_file(self, parser, file_r): + parser.add_argument("--path", type=pydantic.DirectoryPath) + with pytest.raises(ArgumentError, match='Parser key "path"'): + parser.parse_args([f"--path={file_r}"]) + + def test_optional_file_path(self, parser, file_r): + parser.add_argument("--path", type=Optional[pydantic.FilePath]) + assert parser.parse_args([f"--path={file_r}"]).path == pathlib.Path(file_r) + assert parser.parse_args(["--path=null"]).path is None + with pytest.raises(ArgumentError, match='Parser key "path"'): + parser.parse_args(["--path=not_exist"]) + + +@skip_if_pydantic_v1_on_v2 +def test_pydantic_model_path_fields(parser, file_r): + parser.add_argument("--model", type=PydanticPaths) + cfg = parser.parse_args([f"--model.file={file_r}", "--model.dir=."]) + assert cfg.model == Namespace(file=pathlib.Path(file_r), dir=pathlib.Path(".")) + with pytest.raises(ArgumentError, match='Parser key "model.file"'): + parser.parse_args(["--model.file=not_exist", "--model.dir=."]) + with pytest.raises(ArgumentError, match='Parser key "model.dir"'): + parser.parse_args([f"--model.file={file_r}", "--model.dir=not_exist"]) + + if pydantic_support: class Pet(pydantic.BaseModel): diff --git a/jsonargparse_tests/test_shtab.py b/jsonargparse_tests/test_shtab.py index 9b65e69c..bdd87e4b 100644 --- a/jsonargparse_tests/test_shtab.py +++ b/jsonargparse_tests/test_shtab.py @@ -12,11 +12,15 @@ from jsonargparse import ArgumentError, ArgumentParser, set_parsing_settings from jsonargparse._completions import get_shtab_script, norm_name +from jsonargparse._optionals import pydantic_support from jsonargparse._parameter_resolvers import get_signature_parameters from jsonargparse._typehints import type_to_str from jsonargparse.typing import Path_drw, Path_fr from jsonargparse_tests.conftest import capture_logs, get_parse_args_stdout +if pydantic_support: + import pydantic + @pytest.fixture(autouse=True, scope="module") def skip_if_no_shtab(): @@ -289,6 +293,46 @@ def test_bash_optional_file(parser, path_type): assert "_path_COMPGEN=_shtab_compgen_files" in shtab_script +@pytest.mark.skipif(pydantic_support < 2, reason="pydantic>=2 is required") +def test_bash_pydantic_file_path(parser): + parser.add_argument("--path", type=pydantic.FilePath) + shtab_script = get_shtab_script(parser, "bash") + assert "_path_COMPGEN=_shtab_compgen_files" in shtab_script + + +@pytest.mark.skipif(pydantic_support < 2, reason="pydantic>=2 is required") +def test_bash_pydantic_directory_path(parser): + parser.add_argument("--path", type=pydantic.DirectoryPath) + shtab_script = get_shtab_script(parser, "bash") + assert "_path_COMPGEN=_shtab_compgen_dirs" in shtab_script + + +@pytest.mark.skipif(pydantic_support < 2, reason="pydantic>=2 is required") +def test_bash_optional_pydantic_directory_path(parser): + parser.add_argument("--path", type=Optional[pydantic.DirectoryPath]) + shtab_script = get_shtab_script(parser, "bash") + assert "_path_COMPGEN=_shtab_compgen_dirs" in shtab_script + + +@pytest.mark.skipif(pydantic_support < 2, reason="pydantic>=2 is required") +def test_bash_pydantic_new_path(parser): + parser.add_argument("--path", type=pydantic.NewPath) + shtab_script = get_shtab_script(parser, "bash") + assert "_path_COMPGEN=_shtab_compgen_files" in shtab_script + + +@pytest.mark.skipif(pydantic_support < 2, reason="pydantic>=2 is required") +def test_bash_pydantic_model_path_fields(parser): + class Model(pydantic.BaseModel): + file: pydantic.FilePath + dir: pydantic.DirectoryPath + + parser.add_argument("--model", type=Model) + shtab_script = get_shtab_script(parser, "bash") + assert "_model_file_COMPGEN=_shtab_compgen_files" in shtab_script + assert "_model_dir_COMPGEN=_shtab_compgen_dirs" in shtab_script + + class Base: def __init__(self, p1: int): pass # pragma: no cover