Skip to content

Commit

Permalink
- Callable type now supports callable classes #110.
Browse files Browse the repository at this point in the history
- Fixed bug in check for class_path, init_args dicts.
- Fixed module mocks in cli_tests.py.
- Callable no longer a simple registered type.
- Import paths are now serialized as its shortest form.
- Some related code cleanup and some minor unrelated fixes.
  • Loading branch information
mauvilsa committed Mar 22, 2022
1 parent 00f42e0 commit 9c9dcd2
Show file tree
Hide file tree
Showing 11 changed files with 252 additions and 122 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,19 @@ Added
- ``capture_parser`` function to get the parser object from a cli function.
- ``dump_header`` property to set header for yaml/jsonnet dumpers `#79
<https://github.com/omni-us/jsonargparse/issues/79>`__.
- ``Callable`` type now supports callable classes `#110
<https://github.com/omni-us/jsonargparse/issues/110>`__.

Fixed
^^^^^
- Bug in check for ``class_path``, ``init_args`` dicts.
- Module mocks in cli_tests.py.

Changed
^^^^^^^
- Moved argcomplete code from core to optionals module.
- ``Callable`` no longer a simple registered type.
- Import paths are now serialized as its shortest form.

Deprecated
^^^^^^^^^^
Expand Down
16 changes: 13 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ without doing any parsing. For instance `sphinx-argparse
<https://sphinx-argparse.readthedocs.io/en/stable/>`__ can be used to include
the help of CLIs in automatically generated documentation of a package. To use
sphinx-argparse it is necessary to have a function that returns the parser.
Having a CLI function this could be easily implemented as follows:
Having a CLI function this could be easily implemented with
:func:`.capture_parser` as follows:

.. testcode::

Expand All @@ -315,6 +316,11 @@ Having a CLI function this could be easily implemented as follows:
def get_parser():
return capture_parser(main_cli)

.. note::

The official way to obtain the parser for command line tool based on
:func:`.CLI` is by using :func:`.capture_parser`.


.. _nested-namespaces:

Expand Down Expand Up @@ -852,8 +858,12 @@ Some notes about this support are:
default :code:`Optional[str] = None` would be shown in the help as
:code:`type: Union[str, null], default: null`.

- :code:`Callable` has an experimental partial implementation and not officially
supported yet.
- :code:`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 specifying a class that once instantiated is callable. Running
:py:meth:`.ArgumentParser.instantiate_classes` will instantiate the callable
classes. Currently the callable's arguments and return types are ignored and
not validated.


.. _restricted-numbers:
Expand Down
3 changes: 2 additions & 1 deletion jsonargparse/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
from .namespace import is_empty_namespace, Namespace, split_key, split_key_leaf, split_key_root
from .optionals import FilesCompleterMethod, get_config_read_mode
from .type_checking import ArgumentParser, _ArgumentGroup
from .typing import get_import_path, path_type
from .typing import path_type
from .util import (
default_config_option_help,
DirectedGraph,
get_import_path,
ParserError,
import_object,
change_to_path_dir,
Expand Down
4 changes: 2 additions & 2 deletions jsonargparse/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from .actions import _ActionConfigLoad, _ActionHelpClass, _ActionHelpClassPath
from .namespace import Namespace
from .typehints import ActionTypeHint, ClassType, is_optional
from .typing import get_import_path, is_final_class
from .util import _issubclass
from .typing import is_final_class
from .util import get_import_path, _issubclass
from .optionals import (
dataclasses_support,
docstring_parser_support,
Expand Down
90 changes: 64 additions & 26 deletions jsonargparse/typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,14 @@
import re
import warnings
from argparse import Action
from collections.abc import Iterable as abcIterable
from collections.abc import Mapping as abcMapping
from collections.abc import MutableMapping as abcMutableMapping
from collections.abc import Set as abcSet
from collections.abc import MutableSet as abcMutableSet
from collections.abc import Sequence as abcSequence
from collections.abc import MutableSequence as abcMutableSequence
from collections import abc
from contextlib import contextmanager
from contextvars import ContextVar
from enum import Enum
from functools import partial
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Expand All @@ -37,14 +32,16 @@
from .actions import _find_action, _is_action_value_list
from .loaders_dumpers import get_loader_exceptions, load_value
from .namespace import is_empty_namespace, Namespace
from .typing import get_import_path, is_final_class, object_path_serializer, registered_types
from .typing import is_final_class, registered_types
from .optionals import (
argcomplete_warn_redraw_prompt,
files_completer,
)
from .util import (
change_to_path_dir,
get_import_path,
import_object,
object_path_serializer,
ParserError,
Path,
NoneType,
Expand All @@ -71,10 +68,11 @@
Literal,
Type, type,
Union,
List, list, Iterable, Sequence, MutableSequence, abcIterable, abcSequence, abcMutableSequence,
List, list, Iterable, Sequence, MutableSequence, abc.Iterable, abc.Sequence, abc.MutableSequence,
Tuple, tuple,
Set, set, frozenset, MutableSet, abcMutableSet,
Dict, dict, Mapping, MutableMapping, abcMapping, abcMutableMapping,
Set, set, frozenset, MutableSet, abc.MutableSet,
Dict, dict, Mapping, MutableMapping, abc.Mapping, abc.MutableMapping,
Callable, abc.Callable,
}

leaf_types = {
Expand All @@ -88,10 +86,11 @@
not_subclass_types: Set = set(k for k in registered_types.keys() if not isinstance(k, tuple))
not_subclass_types = not_subclass_types.union(leaf_types).union(root_types)

tuple_set_origin_types = {Tuple, tuple, Set, set, frozenset, MutableSet, abcSet, abcMutableSet}
sequence_origin_types = {List, list, Iterable, Sequence, MutableSequence, abcIterable, abcSequence,
abcMutableSequence}
mapping_origin_types = {Dict, dict, Mapping, MutableMapping, abcMapping, abcMutableMapping}
tuple_set_origin_types = {Tuple, tuple, Set, set, frozenset, MutableSet, abc.Set, abc.MutableSet}
sequence_origin_types = {List, list, Iterable, Sequence, MutableSequence, abc.Iterable, abc.Sequence,
abc.MutableSequence}
mapping_origin_types = {Dict, dict, Mapping, MutableMapping, abc.Mapping, abc.MutableMapping}
callable_origin_types = {Callable, abc.Callable}


subclass_arg_parser: ContextVar = ContextVar('subclass_arg_parser')
Expand Down Expand Up @@ -508,6 +507,41 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, s
kwargs = adapt_kwargs
val[k] = adapt_typehints(v, subtypehints[1], **kwargs)

# Callable
elif typehint_origin in callable_origin_types or typehint in callable_origin_types:
if serialize:
if is_class_object(val):
class_path = val['class_path']
init_args = val.get('init_args', Namespace())
val['init_args'] = adapt_class_type(class_path, init_args, True, False, sub_add_kwargs)
else:
val = object_path_serializer(val)
else:
try:
if isinstance(val, str):
val_obj = import_object(val)
if inspect.isclass(val_obj):
val = {'class_path': val}
elif callable(val_obj):
val = val_obj
else:
raise ImportError(f'Unexpected import object {val_obj}')
if isinstance(val, (dict, Namespace)):
if not is_class_object(val):
raise ImportError(f'Dict must include a class_path and optionally init_args, but got {val}')
val = Namespace(val)
val_class = import_object(val.class_path)
if not (inspect.isclass(val_class) and callable(lazy_instance(val_class))): # TODO: how to check callable without instance?
raise ImportError(f'"{val.class_path}" is not a callable class.')
init_args = val.get('init_args', Namespace())
adapted = adapt_class_type(val_class, init_args, False, instantiate_classes, sub_add_kwargs)
if instantiate_classes and sub_add_kwargs.get('instantiate', True):
val = adapted
elif adapted is not None and not is_empty_namespace(adapted):
val['init_args'] = adapted
except (ImportError, AttributeError, ParserError) as ex:
raise ValueError(f'Type {typehint} expects a function or a callable class: {ex}')

# Final class
elif is_final_class(typehint):
if isinstance(val, dict):
Expand All @@ -520,12 +554,7 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, s
elif not hasattr(typehint, '__origin__') and inspect.isclass(typehint):
if isinstance(val, typehint):
if serialize:
val = str(val)
warning(f"""
Not possible to serialize an instance of {typehint}. It will
be represented as the string {val}. If this was set as a
default, consider using lazy_instance.
""")
val = serialize_class_instance(val)
return val
if serialize and isinstance(val, str):
return val
Expand All @@ -537,8 +566,6 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, s
elif isinstance(val, dict):
val = Namespace(val)
val_class = import_object(val['class_path'])
if isinstance(val.get('init_args'), dict):
val['init_args'] = Namespace(val['init_args'])
if not _issubclass(val_class, typehint):
raise ValueError(f'"{val["class_path"]}" is not a subclass of {typehint.__name__}')
init_args = val.get('init_args', Namespace())
Expand All @@ -556,7 +583,7 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, s


def is_class_object(val):
is_class = isinstance(val, (dict, Namespace)) and 'class_path'
is_class = isinstance(val, (dict, Namespace)) and 'class_path' in val
if is_class:
keys = getattr(val, '__dict__', val).keys()
is_class = len(set(keys)-{'class_path', 'init_args', '__path__'}) == 0
Expand All @@ -573,8 +600,8 @@ def dump_kwargs_context(kwargs):


def adapt_class_type(val_class, init_args, serialize, instantiate_classes, sub_add_kwargs):
if not isinstance(init_args, Namespace):
raise ValueError(f'Unexpected init_args value: "{init_args}".')
if isinstance(init_args, dict):
init_args = Namespace(init_args)
parser = ActionTypeHint.get_class_parser(val_class, sub_add_kwargs)

# No need to re-create the linked arg but just "inform" the corresponding parser actions that it exists upstream.
Expand Down Expand Up @@ -647,6 +674,17 @@ def typehint_metavar(typehint):
return metavar


def serialize_class_instance(val):
type_val = type(val)
val = str(val)
warning(f"""
Not possible to serialize an instance of {type_val}. It will be
represented as the string {val}. If this was set as a default, consider
using lazy_instance.
""")
return val


def check_lazy_kwargs(class_type: Type, lazy_kwargs: dict):
if lazy_kwargs:
from .core import ArgumentParser
Expand Down
25 changes: 1 addition & 24 deletions jsonargparse/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import uuid
from datetime import timedelta
from typing import Any, Callable, Dict, List, Optional, Pattern, Tuple, Type, Union
from .util import import_object, Path
from .util import Path


__all__ = [
Expand Down Expand Up @@ -338,29 +338,6 @@ def is_final_class(cls):
register_type(uuid.UUID)


def get_import_path(value):
return value.__module__+'.'+value.__qualname__


def object_path_serializer(value):
try:
path = get_import_path(value)
reimported = import_object(path)
if value is not reimported:
raise ValueError
return path
except Exception as ex:
raise ValueError(f'Only possible to serialize an importable object, given {value}: {ex}') from ex


register_type(
Callable,
type_check=lambda v, t: callable(v),
serializer=object_path_serializer,
deserializer=lambda x: x if callable(x) else import_object(x),
)


def timedelta_deserializer(value):
def raise_error():
raise ValueError(f'Expected a string with form "h:m:s" or "d days, h:m:s" but got "{value}"')
Expand Down
46 changes: 44 additions & 2 deletions jsonargparse/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ def _parse_value_or_config(value: Any, enable_path: bool = True) -> Tuple[Any, O
def usage_and_exit_error_handler(parser: 'ArgumentParser', message: str) -> None:
"""Error handler that prints the usage and exits with error code 2 (same behavior as argparse).
If the JSONARGPARSE_DEBUG environment variable is set, exit is skipped,
allowing ParserError to be raised instead.
If the JSONARGPARSE_DEBUG environment variable is set, instead of exit, a
DebugException is raised.
Args:
parser: The parser object.
Expand Down Expand Up @@ -176,6 +176,48 @@ def import_object(name: str):
return getattr(parent, name_object)


def import_module_leaf(name: str):
"""Similar to __import__(name) but returns the leaf module instead of the root."""
if '.' in name:
name_parent, name_leaf = name.rsplit('.', 1)
parent = __import__(name_parent, fromlist=[name_leaf])
module = getattr(parent, name_leaf)
else:
module = __import__(name)
return module


def get_import_path(value):
"""Returns the shortest dot import path for the given object."""
path = value.__module__ + '.' + value.__qualname__
if '.' in value.__module__:
module_parts = value.__module__.split('.')
for num in range(len(module_parts)):
module_path = '.'.join(module_parts[:num+1])
module = import_module_leaf(module_path)
if '.' in value.__qualname__:
obj_name, attr = value.__qualname__.rsplit('.', 1)
obj = getattr(module, obj_name, None)
if getattr(obj, attr, None) is value:
path = module_path + '.' + value.__qualname__
break
elif getattr(module, value.__qualname__, None) is value:
path = module_path + '.' + value.__qualname__
break
return path


def object_path_serializer(value):
try:
path = get_import_path(value)
reimported = import_object(path)
if value is not reimported:
raise ValueError
return path
except Exception as ex:
raise ValueError(f'Only possible to serialize an importable object, given {value}: {ex}') from ex


lenient_check: ContextVar = ContextVar('lenient_check', default=False)


Expand Down

0 comments on commit 9c9dcd2

Please sign in to comment.