This code checks the type, length and depth (highest number of nested containers) of the container as well as the type of its sub-containers and its elements. There are also two simple methods for type checking non-nested list / tuples. I mainly want a code review on readability and the quality of the docstrings.
from inspect import isclass
from itertools import islice
from typing import Any, Optional, Union
from collections.abc import Iterable, Sized, Container as GenericContainer
from typing import List, Tuple, Callable
def is_n_container(
obj: Any,
matching_type: Union[type, List[type], Tuple[type, ...], None] = None,
container_type: Union[type, List[type], Tuple[type, ...], None] = None,
length: Union[int, Callable[[Any], bool], None] = None,
depth: Union[int, Callable[[Any], bool], None] = None
) -> Optional[bool]:
"""Checks if `obj` is a container with expected properties.
Verifies that container `obj` and its sub-containers are of a
type specified in `container_type` and all non-container elements
of `obj` are of a type specified in `matching_type`.
The length of the container `obj` and its maximum depth
is confirmed. More complex requirements can be satisfied
by providing a function for its length and depth (see notes).
`None` acts as a "wildcard" for the parameters if the type
of non-container elements, the type of the container `obj` and
its sub-containers, the length of container `obj` or
the depth of container `obj` doesn't matter.
Parameters
----------
obj : any
Object that is checked to be a container
with specified properties.
matching_type : type or list/tuple of types, optional
Allowed types for non-container elements of container 'obj'.
container_type : type or list/tuple of types, optional
Allowed types for container 'obj' and its sub-containers.
length : int or function, optional
Number of elements of depth zero in container 'obj'.
depth : int or function, optional
Maximum depth of nested containers.
Returns
-------
bool, optional
Returns 'None' if a parameter has an incorrect format,
'True' if 'obj' is container of parameter-defined properties
or 'False' otherwise.
Raises
------
ValueError
If 'length' or 'depth' is smaller than zero.
TypeError
If 'length' is specified but 'obj' has no length attribute
accessible through 'len()' or by iterating over it.
Warnings
--------
**Warning**: If a container type is included in the `matching_type`
parameter, checking will stop once the container type is found
without analysing its internal structure.
**Warning**: If iterating over the container changes it as a side
effect, the behaviour of `is_n_container` becomes undefined.
Notes
-----
A function used as argument for parameter `length` or
parameter `depth` should take an object (`obj`) as argument and
return `True` if the object has the required length/depth or
`False` otherwise.
Variable length containers also include empty containers.
If empty containers should be excluded, check
for empty containers separately or use
`not is_n_container(obj, length=0, **kwargs) and
is_n_container(obj, length=None, **kwargs)`.
Even when a sub-container is empty it adds another level of depth
(e.g. ([], 1, 2, 3) has depth 1), unless the sub-container is a
type in parameter `matching_type` (e.g. with
`matching_type=(int, list)` ([], 1, 2, 3) has depth 0).
Examples
--------
Types of elements: int, float, str \n
Types of containers: list, tuple \n
Depth 0: [(I), 2, 3, (II), (III), 2.3] - Length: 6 \n
Depth 1: I - 'a', 1, 'b' | II - 4, 5 | III - (IV), 9 \n
Depth 2: IV - (V), 8 \n
Depth 3: V - 6, 7
>>> container = [['a', 1, 'b'], 2, 3, (4, 5), (((6, 7), 8), 9), 2.3]
Use all parameters and `bool` as an unused `matching_type` parameter
>>> is_n_container(container, matching_type=(int, str, float, bool),
... container_type=(tuple, list), length=6, depth=3)
True
Wrong length
>>> is_n_container(container, matching_type=(int, str, float, bool),
... container_type=(tuple, list), length=5, depth=3)
False
Allow more depth than needed
>>> is_n_container(container, matching_type=(int, str, float),
... container_type=(tuple, list), depth=5)
True
Expect a too shallow structure
>>> is_n_container(container, matching_type=(int, str, float),
... container_type=(tuple, list), length=6, depth=2)
False
Missing `matching_type` arguemnt `float`
>>> is_n_container(container, matching_type=(int, str),
... container_type=(tuple, list))
False
Missing 'container_type' argument 'list'
>>> is_n_container(tuple(container),
... matching_type=(int, str, float),
... container_type=tuple)
False
Container `obj` is still a list
>>> is_n_container(container[1:-1], matching_type=int,
... container_type=tuple)
False
Now all containers are of type `tuple`
>>> is_n_container(tuple(container[1:-1]), matching_type=int,
... container_type=tuple)
True
Using `None` for an argument allows for a wide variety of
acceptable containers, but not non-container objects
>>> containers = [[1, 2, 3], ('a', 'b', 'c'), {1.5, 2.4, 3.3}, True]
>>> for i in (0, 1, 2, 3):
... is_n_container(containers[i], depth=1)
True
True
True
False
Even if the container is empty it adds another level of depth
>>> container = [(), ([], 1), 2]
>>> for i in (1, 2):
... is_n_container(container, matching_type=int, depth=i)
False
True
Unless the container is a type in parameter `matching_type`
>>> is_n_container(container, matching_type=(int, list), depth=1)
True
"""
if _check_parameters(matching_type, container_type, length, depth):
_matching_type = (matching_type if not isinstance(matching_type, list)
else tuple(matching_type))
_container_type = (container_type
if not isinstance(container_type, list)
else tuple(container_type))
if (not isinstance(obj, GenericContainer)
or not (_container_type is None
or isinstance(obj, _container_type))
or (isinstance(length, Callable) and not length(obj))
or (isinstance(depth, Callable) and not depth(obj))):
return False
_length = length if isinstance(length, int) else None
_depth = depth if isinstance(depth, int) else float('inf')
if _length is not None:
if _length < 0:
raise ValueError("length of the container must be positive," +
f" not {_length}")
if not (isinstance(obj, Sized) or isinstance(obj, Iterable)
or hasattr(obj, '__getitem__')):
raise TypeError(f"object of type '{type(obj).__name__}'" +
" has no attribute len()")
if ((isinstance(obj, Sized) and _length != len(obj)) or (
(isinstance(obj, Iterable) or hasattr(obj, '__getitem__'))
and _length != sum(1 for _ in islice(obj, _length + 1)))):
return False
if _depth < 0:
raise ValueError("depth of the container must be positive," +
f" not {_depth}")
if (matching_type is None or not (isinstance(obj, Iterable)
or hasattr(obj, '__getitem__'))):
return (_container_type is None
or isinstance(obj, _container_type))
return _is_container(obj, _matching_type, _container_type, _depth)
return None
def _check_parameters(matching_type, container_type, length, depth) -> bool:
"""Returns `True` if all arguments for `is_n_container`
have the correct format."""
return (True # Added for better readability
and (matching_type is None
or isinstance(matching_type, type)
or is_n_list(matching_type, None, type)
or is_n_tuple(matching_type, None, type))
and (container_type is None
or (isclass(container_type)
and issubclass(container_type, GenericContainer))
or (isinstance(container_type, list)
and all((isclass(c_type)
and issubclass(c_type, GenericContainer))
for c_type in container_type))
or (isinstance(container_type, tuple)
and all((isclass(c_type)
and issubclass(c_type, GenericContainer))
for c_type in container_type)))
and (length is None
or isinstance(length, int)
or isinstance(length, Callable))
and (depth is None
or isinstance(depth, int)
or isinstance(depth, Callable)))
def _is_container(obj: Iterable,
matching_type: Union[type, Tuple[type, ...]],
container_type: Union[type, Tuple[type, ...], None],
depth: Union[int, float]) -> bool:
"""Checks properties of container `obj` recursively.
Used in `is_n_container`."""
return depth >= 0 and all(
isinstance(element, matching_type)
or ((isinstance(element, Iterable) or hasattr(element, '__getitem__'))
and (container_type is None or isinstance(element, container_type))
and _is_container(element, matching_type, container_type, depth-1))
for element in obj
)
def is_n_list(obj: Any, length: Optional[int],
matching_type: Union[type, Tuple[type, ...]]) -> bool:
"""Checks if `obj` is a list with expected properties.
Parameters
----------
obj : any
Object that is checked to be a list
with specified properties.
matching_type : type or tuple of types, optional
Allowed types for non-container elements of container 'obj'.
length : int, optional
Number of elements in container 'obj'.
Returns
-------
bool
Returns 'True' if 'obj' is a list and all its elements are
of a type defined in 'matching_type'.
See Also
--------
`is_n_container`: Checks if `obj` is a container with
expected properties.
"""
return (isinstance(obj, list) and (length is None or length == len(obj))
and all(isinstance(element, matching_type) for element in obj))
def is_n_tuple(obj: Any, length: Optional[int],
matching_type: Union[type, Tuple[type, ...]]) -> bool:
"""Checks if `obj` is a tuple with expected properties.
Parameters
----------
obj : any
Object that is checked to be a tuple
with specified properties.
matching_type : type or tuple of types, optional
Allowed types for non-container elements of container 'obj'.
length : int, optional
Number of elements in container 'obj'.
Returns
-------
bool
Returns 'True' if 'obj' is a tuple and all its elements are
of a type defined in 'matching_type'.
See Also
--------
`is_n_container`: Checks if `obj` is a container with
expected properties.
"""
return (isinstance(obj, tuple) and (length is None or length == len(obj))
and all(isinstance(element, matching_type) for element in obj))
Additional specific questions:
- Should I scrap the whole thing from a Python standpoint, since its follows more of a LBYL principle than a EAFP principle (LBYL/EAFP)?
- If not the whole thing, should I get rid of the _check_parameters method?
- Are there enough / too many examples for is_n_container in the docstring and are these examples helpful or should they be put in a example context?
- PEP8 suggests to use blank lines in functions sparingly. Should I use some in is_n_container? And if yes, where?
- Is the iteration warning about a possible changed container necessary / helpful?
- Is it ok to use three different indentations for the function parameters (*see remarks)?
Some remarks:
I use python 3.8.8 and pycharm 2020.3.4 . Since the sections 'Parameters', 'Returns' and 'Raises' won't format single back-ticks (``) in the docstring correctly (for me), I used single quotation marks ('') instead. I imported List, Tuple and Callable from typing seperatly, because in python 3.9 List and Tuple should be replaced with the generic list and tuple and Callable should be imported from collections.abc . I don't use any type hinting in _check_parameters as they all have type Any (i want to accept any type and return if they are of the desired type).
*I use three different indentations because I usually use:
If I can get it into two lines
def func(var1: type, var2: type, var3: type, var4: type) -> type
Else if I can get the type hint of the result into the line with the last variable
def name_too_long_to_get_parameters_in_two_lines(long_var_name1: type, long_var_name2: type, long_var_name3: type) -> type
Else
def name_and_result_type_hinting_too_long( var1: type, var2: type, var3: type ) -> really_long_result_type