"""A module containing the actual :class:`AssertionManager` class.
Index
-----
.. currentmodule:: assertionlib.manager
.. autosummary::
assertion
AssertionManager
AssertionManager.assert_
AssertionManager.__call__
AssertionManager.add_to_instance
Assertions based on the builtin :mod:`operator` module.
.. autosummary::
:nosignatures:
AssertionManager.abs
AssertionManager.add
AssertionManager.and_
AssertionManager.concat
AssertionManager.contains
AssertionManager.countOf
AssertionManager.eq
AssertionManager.floordiv
AssertionManager.ge
AssertionManager.getitem
AssertionManager.gt
AssertionManager.index
AssertionManager.indexOf
AssertionManager.inv
AssertionManager.invert
AssertionManager.is_
AssertionManager.is_not
AssertionManager.le
AssertionManager.lshift
AssertionManager.lt
AssertionManager.matmul
AssertionManager.mod
AssertionManager.mul
AssertionManager.ne
AssertionManager.neg
AssertionManager.not_
AssertionManager.or_
AssertionManager.pos
AssertionManager.pow
AssertionManager.rshift
AssertionManager.sub
AssertionManager.truediv
AssertionManager.truth
AssertionManager.length_hint
Assertions based on the builtin :mod:`os.path` module.
.. autosummary::
:nosignatures:
AssertionManager.isabs
AssertionManager.isdir
AssertionManager.isfile
AssertionManager.islink
AssertionManager.ismount
Assertions based on the builtin :mod:`math` module.
.. autosummary::
:nosignatures:
AssertionManager.allclose
AssertionManager.isclose
AssertionManager.isfinite
AssertionManager.isinf
AssertionManager.isnan
Assertions based on the builtin :mod:`builtins` module.
.. autosummary::
:nosignatures:
AssertionManager.callable
AssertionManager.hasattr
AssertionManager.isinstance
AssertionManager.issubclass
AssertionManager.len
AssertionManager.any
AssertionManager.all
AssertionManager.isdisjoint
AssertionManager.issuperset
AssertionManager.issubset
AssertionManager.round
Miscellaneous assertions.
.. autosummary::
:nosignatures:
AssertionManager.len_eq
AssertionManager.str_eq
AssertionManager.shape_eq
AssertionManager.function_eq
API
---
.. autodata:: assertion
:annotation: : AssertionManager
.. autoclass:: AssertionManager
.. automethod:: AssertionManager.assert_
.. automethod:: AssertionManager.__call__
.. automethod:: AssertionManager.add_to_instance
Assertions based on the builtin :mod:`operator` module
------------------------------------------------------
.. automethod:: AssertionManager.abs
.. automethod:: AssertionManager.add
.. automethod:: AssertionManager.and_
.. automethod:: AssertionManager.concat
.. automethod:: AssertionManager.contains
.. automethod:: AssertionManager.countOf
.. automethod:: AssertionManager.eq
.. automethod:: AssertionManager.floordiv
.. automethod:: AssertionManager.ge
.. automethod:: AssertionManager.getitem
.. automethod:: AssertionManager.gt
.. automethod:: AssertionManager.index
.. automethod:: AssertionManager.indexOf
.. automethod:: AssertionManager.inv
.. automethod:: AssertionManager.invert
.. automethod:: AssertionManager.is_
.. automethod:: AssertionManager.is_not
.. automethod:: AssertionManager.le
.. automethod:: AssertionManager.lshift
.. automethod:: AssertionManager.lt
.. automethod:: AssertionManager.matmul
.. automethod:: AssertionManager.mod
.. automethod:: AssertionManager.mul
.. automethod:: AssertionManager.ne
.. automethod:: AssertionManager.neg
.. automethod:: AssertionManager.not_
.. automethod:: AssertionManager.or_
.. automethod:: AssertionManager.pos
.. automethod:: AssertionManager.pow
.. automethod:: AssertionManager.rshift
.. automethod:: AssertionManager.sub
.. automethod:: AssertionManager.truediv
.. automethod:: AssertionManager.truth
.. automethod:: AssertionManager.length_hint
Assertions based on the builtin :mod:`os.path` module
-----------------------------------------------------
.. automethod:: AssertionManager.isabs
.. automethod:: AssertionManager.isdir
.. automethod:: AssertionManager.isfile
.. automethod:: AssertionManager.islink
.. automethod:: AssertionManager.ismount
Assertions based on the builtin :mod:`math` module
--------------------------------------------------
.. automethod:: AssertionManager.allclose
.. automethod:: AssertionManager.isclose
.. automethod:: AssertionManager.isfinite
.. automethod:: AssertionManager.isinf
.. automethod:: AssertionManager.isnan
Assertions based on the builtin :mod:`builtins` module
------------------------------------------------------
.. automethod:: AssertionManager.callable
.. automethod:: AssertionManager.hasattr
.. automethod:: AssertionManager.isinstance
.. automethod:: AssertionManager.issubclass
.. automethod:: AssertionManager.len
.. automethod:: AssertionManager.any
.. automethod:: AssertionManager.all
.. automethod:: AssertionManager.isdisjoint
.. automethod:: AssertionManager.issuperset
.. automethod:: AssertionManager.issubset
.. automethod:: AssertionManager.round
Miscellaneous assertions
------------------------
.. automethod:: AssertionManager.len_eq
.. automethod:: AssertionManager.str_eq
.. automethod:: AssertionManager.shape_eq
.. automethod:: AssertionManager.function_eq
"""
import os
import sys
import math
import inspect
import reprlib
import builtins
import textwrap
import operator
import functools
from types import MappingProxyType
from string import ascii_lowercase
from inspect import Parameter
from typing import (
Callable, Any, Type, Set, Optional, Mapping, Sequence, Iterable,
FrozenSet, TypeVar, NoReturn, Union
)
from .ndrepr import aNDRepr
from .functions import bind_callable
from .dataclass import AbstractDataClass, _MetaADC
from .assertion_functions import (
len_eq, str_eq, shape_eq, function_eq, isdisjoint, issuperset, issubset
)
if sys.version_info < (3, 7):
COMMA = ','
else:
COMMA = ''
__all__ = ['AssertionManager', 'assertion']
T = TypeVar('T')
def _return_value(value: T) -> T:
"""Return the supplied **value** in unaltered form."""
return value
_return_value.__name__ = _return_value.__qualname__ = ''
class _MetaAM(_MetaADC):
"""The meta-class of :class:`AssertionManager`.
The :meth:`_MetaAM.__new__` method iterates over (almost) all functions in the :mod:`operator`
module and binds a matching assertion method to the :class:`AssertionManager` class.
"""
#: A :class:`frozenset` of to-be ignored functions in :mod:`operator`.
EXCLUDE: FrozenSet[str] = frozenset({
'setitem', 'delitem', 'attrgetter', 'methodcaller', 'itemgetter'
})
#: A :class:`frozenset` of callables which need an assertion function.
INCLUDE: FrozenSet[Callable] = frozenset({
os.path.isfile, os.path.isdir, os.path.isabs, os.path.islink, os.path.ismount,
math.isclose, math.isfinite, math.isinf, math.isnan,
isinstance, issubclass, callable, hasattr, len, any, all, round,
len_eq, str_eq, shape_eq, function_eq, isdisjoint, issubset, issuperset
})
def __new__(mcls, name, bases, namespace) -> '_MetaAM': # noqa: N804
cls = super().__new__(mcls, name, bases, namespace)
operator_set: Set[str] = set(operator.__all__) - mcls.EXCLUDE # type: ignore
# Iterature over the __all__ attribute of the operator builtin module
for name in operator_set:
func: Callable = getattr(operator, name)
bind_callable(cls, func, name)
# Iterate over all remaining callables
for func in mcls.INCLUDE:
bind_callable(cls, func)
cls.allclose = cls.isclose # type: ignore
# On windows os.path.isdir is an alias for the ._isdir function
if hasattr(cls, '_isdir'):
cls.isdir = isdir = cls._isdir # type: ignore
isdir.__name__ = 'isdir'
isdir.__qualname__ = f'{cls.__name__}.isdir'
isdir.__doc__ = isdir.__doc__.replace('_isdir', 'isdir')
del cls._isdir # type: ignore
return cls
class _Str:
def __init__(self, value: str) -> None:
self.value = value
def __repr__(self) -> str:
return str(self.value)
class _NoneException(BaseException):
"""An empty exception used by :meth:`AssertionManager.assert_` incase the **exception** parameter is ``None``.""" # noqa
def __init__(self, *args: Any) -> NoReturn: # type: ignore
raise TypeError(f"{self.__class__.__name__!r} cannot not be initiated")
ExcType = Union[Type[_NoneException], Type[Exception]]
[docs]class AssertionManager(AbstractDataClass, metaclass=_MetaAM):
"""A class for performing assertions and providing informative exception messages.
A number of usage examples are provided in the the documentation_.
.. _documentation: https://assertionlib.readthedocs.io/en/latest/includeme.html#usage
Parameters
----------
repr_instance : :class:`reprlib.Repr`, optional
An instance of :class:`reprlib.Repr` for formatting Exception messages.
The passed instance should have access to a bound callable by the name of ``repr``,
which in turn should produce a string representation of any passed objects.
If ``None``, default the builtin :func:`repr` function.
See also :attr:`AssertionManager.repr_instance`.
Attributes
----------
repr_instance : :class:`reprlib.Repr`, optional
An instance of :class:`reprlib.Repr` for formatting Exception messages.
The passed instance should have access to a bound callable by the name of ``repr``,
which in turn should produce a string representation of passed objects.
If ``None``, default the builtin :func:`repr` function.
repr_fallback : :class:`Callable[[Any], str]<collections.abc.Callable>`
A fallback value in case :attr:`AssertionManager.repr_instance` is ``None``.
maxstring_fallback : :class:`int`
A fallback value in case :attr:`AssertionManager.repr_instance` is ``None``.
"""
_PRIVATE_ATTR: Set[str] = frozenset({'repr_fallback', 'maxstring_fallback'}) # type: ignore
def __init__(self, repr_instance: Optional[reprlib.Repr] = aNDRepr) -> None:
"""Initialize an :class:`AssertionManager` instance."""
super().__init__()
self.repr_instance = repr_instance
# Backup values for AssertionManager.repr and AssertionManager.maxstring
# Used when repr_instance is None
self.repr_fallback: Callable[[Any], str] = builtins.repr
self.maxstring_fallback = 80
@property
def repr(self) -> Callable[[Any], str]:
"""Return the :meth:`~reprlib.Repr.repr` method of :attr:`AssertionManager.repr_instance`.""" # noqa
try:
return self.repr_instance.repr # type: ignore
except AttributeError: # If self.repr_instance is None
return self.repr_fallback
@property
def maxstring(self) -> int:
"""Return the :attr:`~reprlib.Repr.maxstring` attribute of :attr:`AssertionManager.repr_instance`.""" # noqa
try:
return self.repr_instance.maxstring # type: ignore
except AttributeError: # If self.repr_instance is None
return self.maxstring_fallback
# Public methods
[docs] def assert_(self, func: Callable[..., T], *args: Any, invert: bool = False,
exception: Optional[Type[Exception]] = None,
post_process: Optional[Callable[[T], Any]] = None,
message: Optional[str] = None,
**kwargs: Any) -> None:
r"""Perform the following assertion: :code:`assert func(*args, **kwargs)`.
Examples
--------
For example :code:`assert 5 == 5` is equivalent to
:code:`AssertionManager().assert_(operator.eq, 5, 5)`.
Parameters
----------
func : :class:`Callable[..., T]<collections.abc.Callable>`
The callable whose output will be evaluated.
\*args : :data:`~typing.Any`
Positional arguments for **func**.
Keyword Arguments
-----------------
invert : :class:`bool`, optional
If :data:`True`, invert the output of the assertion:
:code:`assert not func(*args, **kwargs)`.
exception : :class:`type` [:exc:`Exception`], optional
Assert that **exception** is raised during/before the assertion operation.
The only dissalowed value is :exc:`AssertionError`.
post_process : :data:`Callable[[T], bool]<typing.Callable>`, optional
Apply post-processing to the to-be asserted data before asserting aforementioned data.
Example functions would be the likes of :func:`any()<python:any>` and
:func:`all()<python:all>`.
message : :class:`str`, optional
A custom error message to-be passed to the ``assert`` statement.
\**kwargs : :data:`~typing.Any`, optional
Keyword arguments for **func**.
:rtype: :data:`None`
See Also
--------
:meth:`AssertionManager.__call__`
Equivalent to :code:`assert value`.
"""
__tracebackhide__ = True
# Set exception to _NoneException
if exception is None:
exc_type: ExcType = _NoneException
else:
if not (isinstance(exception, type) and issubclass(exception, Exception)):
raise TypeError(f"{'exception'!r} expected {None!r} or an Exception type; "
f"observed {self.repr(exception)} "
f"of type {exception.__class__.__name__!r}")
elif exception is AssertionError:
raise ValueError(f"{exception.__name__!r} is not allowed as value "
f"for the {'exception'!r} parameter")
exc_type = exception
output: Any = None
try:
if invert:
output = not func(*args, **kwargs)
else:
output = func(*args, **kwargs)
if post_process is None:
assert output, message
else:
assert post_process(output), message
if exc_type is not _NoneException: # i.e. the exception parameter is not None
exc_msg = f"Failed to raise {exc_type.__name__!r}"
if message is not None:
exc_msg += f'; {message}'
raise AssertionError(exc_msg)
except exc_type: # This is the expected exception
pass # Not relevant if the exception parameter is None
except Exception as ex: # This is an unexpected exception
exc = AssertionError(self._get_exc_message(
ex, func, *args, output=output, invert=invert,
post_process=post_process, **kwargs
))
if type(ex) is AssertionError:
exc.__cause__ = None
raise exc
else:
raise exc from ex
[docs] def __call__(self, value: T, *, invert: bool = False,
post_process: Optional[Callable[[T], Any]] = None,
message: Optional[str] = None) -> None:
"""Equivalent to :code:`assert value`.
Examples
--------
.. code:: python
>>> from assertionlib import assertion
>>> assertion(5 == 5)
>>> assertion(5 == 6)
Traceback (most recent call last):
...
AssertionError: output = (value); assert output
<BLANKLINE>
exception: AssertionError = 'None'
<BLANKLINE>
output: bool = False
value: bool = False
Parameters
----------
value : :class:`T<typing.TypeVar>`
The to-be asserted value.
Keyword Arguments
-----------------
invert : :class:`bool`
If :data:`True`, invert the output of the assertion:
:code:`assert not value`.
post_process : :data:`Callable[[T], bool]<typing.Callable>`, optional
Apply post-processing to the to-be asserted data before asserting aforementioned data.
Example functions would be the likes of :func:`any()<python:any>` and
:func:`all()<python:all>`.
message : :class:`str`, optional
A custom error message to-be passed to the ``assert`` statement.
:rtype: :data:`None`
"""
return self.assert_(_return_value, value, invert=invert,
post_process=post_process, message=message)
[docs] def add_to_instance(self, func: Callable, name: Optional[str] = None,
override_attr: bool = False) -> None:
"""Add a new custom assertion method to this instance.
The new method name is added to :attr:`AssertionManager._PRIVATE_ATTR`.
Parameters
----------
func : :class:`~collections.abc.Callable`
The callable whose output will be asserted in the to-be created method.
Keyword Arguments
-----------------
name : :class:`str`, optional
The name of the new method.
If ``None``, use the name of **func**.
override_attr : :class:`bool`
If :data:`False`, raise an :exc:`AttributeError` if a method with the same name already
exists in this instance.
:rtype: :data:`None`
Raises
------
AttributeError
Raised if ``override_attr=False`` and a method with the same name already
exists in this instance.
"""
name = name if name is not None else func.__name__
if not override_attr and hasattr(self, name):
raise AttributeError(f"The {self.__class__.__name__!r} instance has a pre-existing "
f"attribute by the name of {name!r}")
bind_callable(self, func, name)
# Add the name as private attribute
self._PRIVATE_ATTR.add(name)
# Private methods
def _get_exc_message(self, ex: Exception, func: Callable[..., T], *args: Any,
invert: bool = False, output: Any = None,
post_process: Optional[Callable[[T], Any]] = None,
**kwargs: Any) -> str:
r"""Return a formatted exception message for failed assertions.
Examples
--------
.. code:: python
>>> def contains(a, b):
... return b in a
>>> ex = TypeError('Fancy custom exception')
>>> a = [1, 2, 3, 4]
>>> b = 5
>>> msg = assertion._get_exc_message(ex, contains, a, b)
>>> raise AssertionError(msg)
Traceback (most recent call last):
...
AssertionError: output = contains(a, b); assert output
<BLANKLINE>
exception: TypeError = 'Fancy custom exception'
<BLANKLINE>
output: NoneType = None
a: list = [1, 2, 3, 4]
b: int = 5
Parameters
----------
ex : :class:`Exception`
The exception raised by :meth:`AssertionManager.assert_`.
func : :class:`~collections.abc.Callable`
The callable whose output has been evaluated.
\\*args : :data:`~typing.Any`
Positional arguments supplied to **func**.
Keyword Arguments
-----------------
invert : :class:`bool`
If :data:`True`, invert the output of the assertion: :code:`not func(a, b, **kwargs)`.
output : :data:`~typing.Any`, optional
The output value of :code:`func(*args, **kwargs)` or :code:`not func(*args, **kwargs)`.
post_process : :class:`~collections.abc.Callable`, optional
Apply post-processing to the to-be asserted data before asserting aforementioned data.
Example functions would be the likes of :func:`~builtins.any` and :func:`~builtins.all`.
\\**kwargs : :data:`~typing.Any`, optional
Further optional keyword arguments supplied to **func**.
Returns
-------
:class:`str`
A newly-formatted exception message to-be raised by :meth:`AssertionManager.assert_`.
"""
__tracebackhide__ = True
# Construct a string-reprensentation of the to-be assert function
try:
name: str = getattr(func, '__qualname__', func.__name__)
except AttributeError:
if not callable(func):
raise TypeError(f"{'func'!r} expected a callable object; observed type: "
f"{func.__class__.__name__!r}")
# func could potentially be a functools.partial object
if isinstance(func, functools.partial):
name = repr(func)
else:
name = func.__class__.__name__.lower()
# Construct a signature of the to-be asserted function
try:
_signature = inspect.signature(func)
signature: Any = self._get_exc_signature(_signature, args, kwargs)
parameters: Iterable = signature.parameters
except ValueError: # Not all callables have a signature
signature = '(...)'
parameters = (f'_{i}' for i in ascii_lowercase)
not_ = '' if not invert else ' not'
out = 'post_process(output)' if post_process is not None else 'output'
ret = f'output ={not_} {name}{signature}; assert {out}'
# Create a description of the exception
ret += '\n\n' + self._get_prm_description('exception', ex)
# Create a description of the to-be returned value
ret += '\n\n' + self._get_prm_description('output', output)
# Create a description of positional arguments
for key, value in zip(parameters, args):
ret += '\n' + self._get_prm_description(key, value)
# Create a description of keyword arguments
for key, value in kwargs.items():
ret += '\n' + self._get_prm_description(key, value)
if post_process is not None:
ret += '\n' + self._get_prm_description('post_process', post_process)
return ret
@staticmethod
def _get_exc_signature(signature: inspect.Signature, args: Sequence[str],
kwargs: Mapping[str, Any] = MappingProxyType({})) -> inspect.Signature:
"""Create a new signature for the callable supplied to :meth:`AssertionManager._get_exc_message`.
The return signature consists of two components:
* The original positional/keyword arguments of the callables' signature (**signature**).
* Extra positional/keyword arguments supplied to the callable.
""" # noqa
__tracebackhide__ = True
# Unpack parameters
empty = inspect._empty # type: ignore
parameters = signature.parameters
kind = Parameter.POSITIONAL_OR_KEYWORD
# Create iterators over arguments
prm_iter_args = (k for k, v in parameters.items() if v.default is empty)
prm_iter_kwargs = ((k, v) for k, v in parameters.items() if v.default is not empty)
args_iter = iter(args)
# Add positional arguments
param = [Parameter(name=k, kind=kind) for k, v in zip(prm_iter_args, args_iter)]
param += [Parameter(name=f'_{k}', kind=kind) for k, _ in zip(ascii_lowercase, args_iter)]
# Add keyword arguments
param += [Parameter(name=k, kind=kind, default=v.default) for k, v in prm_iter_kwargs
if k not in kwargs]
param += [Parameter(name=k, kind=kind, default=_Str(k)) for k in kwargs]
return inspect.Signature(parameters=param, return_annotation=empty)
def _get_prm_description(self, key: str, value: Any) -> str:
"""Construct a string representation of **key**/**value** pairs in :meth:`AssertionManager._get_exc_message`.
The value will be placed on a newline of its string representation contains a
newline character or is longer than :attr:`AssertionManager.maxstring`.
""" # noqa
__tracebackhide__ = True
_value_str = f'{self.repr(value)}' if not isinstance(value, Exception) else repr(str(value))
key_str = f'{key}: {value.__class__.__name__} ='
# Put the value on a newline if it is too long or contains a newline character
condition = '\n' in _value_str or len(key_str) + len(_value_str) > self.maxstring
if condition: # Value is too long, put it on the next line
indent = 4 * ' '
value_str = f'\n{textwrap.indent(_value_str, indent)}'
else:
value_str = f' {_value_str}'
return f'{key_str}{value_str}'
#: An instance of :class:`AssertionManager`.
assertion = AssertionManager() # Type: Final[AssertionManager]