Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Added
paths to sub-config files, instead of this only being supported for the value
of an entire argument (`#940
<https://github.com/mauvilsa/jsonargparse/pull/940>`__).
- ``shtab`` completion scripts now include file and directory completions for
arguments typed as pydantic's ``FilePath`` and ``DirectoryPath`` (`#943
<https://github.com/mauvilsa/jsonargparse/pull/943>`__).

Fixed
^^^^^
Expand Down
5 changes: 5 additions & 0 deletions DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion jsonargparse/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions jsonargparse/_optionals.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,14 @@
from pydantic import TypeAdapter

return TypeAdapter(typehint).validate_python(value)


def get_pydantic_path_type(typehint) -> Union[str, None]:

Check warning on line 458 in jsonargparse/_optionals.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a union type expression for this type hint.

See more on https://sonarcloud.io/project/issues?id=mauvilsa_jsonargparse&issues=AZ_LBrpMSSHY8RHORVdW&open=AZ_LBrpMSSHY8RHORVdW&pullRequest=943
"""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
72 changes: 72 additions & 0 deletions jsonargparse_tests/test_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dataclasses
import json
import pathlib
from copy import deepcopy
from typing import Dict, List, Literal, Optional, Union

Expand All @@ -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,
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
44 changes: 44 additions & 0 deletions jsonargparse_tests/test_shtab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down