Source code for assertionlib.functions

"""Various functions related to the :class:`.AssertionManager` class.

Index
-----
.. currentmodule:: assertionlib.functions
.. autosummary::
    get_sphinx_domain
    create_assertion_doc
    bind_callable
    to_positional

API
---
.. autofunction:: get_sphinx_domain
.. autofunction:: create_assertion_doc
.. autofunction:: bind_callable
.. autofunction:: to_positional

"""

import os
import sys
import textwrap
from types import MappingProxyType, MethodType
from typing import (
    Callable,
    Any,
    Optional,
    Union,
    Mapping,
    Type,
    TypeVar,
    Iterable,
    List,
    Tuple,
    TYPE_CHECKING
)
from inspect import (
    signature,
    Parameter,
    Signature,
    isbuiltin,
    isfunction,
    ismethod,
    ismethoddescriptor,
    isclass
)

from nanoutils import set_docstring

if sys.version_info < (3, 7):
    COMMA = ','
    SPACE = ''
else:
    COMMA = ''
    SPACE = ' '

if TYPE_CHECKING:
    from enum import IntEnum
else:
    IntEnum = 'enum.IntEnum'

__all__ = [
    'get_sphinx_domain', 'create_assertion_doc', 'bind_callable', 'to_positional'
]

T = TypeVar('T')
FT = TypeVar('FT', bound=Callable[..., Any])

PO = Parameter.POSITIONAL_ONLY
POK = Parameter.POSITIONAL_OR_KEYWORD
VP = Parameter.VAR_POSITIONAL
KO = Parameter.KEYWORD_ONLY
VK = Parameter.VAR_KEYWORD
_empty = Parameter.empty

PARAM_NAME_MAPPING: Mapping[IntEnum, str] = MappingProxyType({
    PO: 'positional-only',
    POK: 'positional or keyword',
    VP: 'variadic positional',
    KO: 'keyword-only',
    VK: 'variadic keyword'
})

DEFAULT_PRM: Tuple[Parameter, Parameter] = (
    Parameter('args', Parameter.VAR_POSITIONAL, annotation=Any),
    Parameter('kwargs', Parameter.VAR_KEYWORD, annotation=Any)
)


def _to_positional(iterable: Iterable[Parameter]) -> List[Parameter]:
    """Helper function for :func:`to_positional`; used in creating the new :class:`~inspect.Parameter` list."""  # noqa: E501
    ret = []
    for prm in iterable:
        if prm.kind is not POK:
            ret.append(prm)
        elif prm.default is _empty:
            ret.append(prm.replace(kind=PO))
        else:
            ret.append(prm.replace(kind=KO))
    return ret


[docs]@set_docstring(f"""Decorate a function's :attr:`__signature__` such that all positional-or-keyword arguments are changed to either positional- or keyword-only. Example ------- .. code:: python >>> from inspect import signature >>> from assertionlib.functions import to_positional >>> def func1(a: int, b: int = 0) -> int: ... pass >>> @to_positional ... def func2(a: int, b: int = 0) -> int: ... pass >>> print(signature(func1), signature(func2), sep='\\n') (a:{SPACE}int, b:{SPACE}int{SPACE}={SPACE}0) -> int (a:{SPACE}int, /, *, b:{SPACE}int{SPACE}={SPACE}0) -> int """) # noqa: E501 def to_positional(func: FT) -> FT: sgn = signature(func) prm_dict = sgn.parameters prm_list = _to_positional(prm_dict.values()) func.__signature__ = Signature( # type: ignore parameters=prm_list, return_annotation=sgn.return_annotation ) return func
[docs]def bind_callable(class_type: Union[type, Any], func: Callable[..., Any], name: Optional[str] = None, warn: bool = True) -> None: """Take a callable and use it to create a new assertion method for **class_type**. The created callable will have the same signature as **func** except for one additional keyword argument by the name of ``func`` (default value: :data:`False`). Setting this keyword argument to :data:`True` will invert the output of the assertion, *i.e.* it changes ``assert func(...)`` into ``assert not func(...)``. Examples -------- Supplying the builtin :func:`len` function will create (and bind) a callable which performs the :code:`assert len(obj)` assertion. Parameters ---------- class_type : :class:`type` or :data:`~typing.Any` A class (*i.e.* a :class:`type` instance) or class instance. func : :class:`~collections.abc.Callable` A callable object whose output will be asserted by the created method. name : :class:`str`, optional The name of the name of the new method. If ``None``, use the name of **func**. :rtype: :data:`None` """ # Create the new function function = create_assertion_func(func) if name is not None: function.__name__ = name # Set the new method if isinstance(class_type, type): # A class function.__qualname__ = f'{class_type.__name__}.{function.__name__}' function.__module__ = class_type.__module__ setattr(class_type, function.__name__, function) else: # A class instance function.__qualname__ = f'{class_type.__class__.__name__}.{function.__name__}' function.__module__ = class_type.__class__.__module__ method = MethodType(function, class_type) # Create a bound method setattr(class_type, function.__name__, method)
def create_assertion_func(func: Callable[..., Any]) -> Callable[..., None]: """Construct an assertion function from **func**.""" def wrapper(self: Any, *args: Any, invert: bool = False, exception: Optional[Type[Exception]] = None, post_process: Optional[Callable[[Any], Any]] = None, message: Optional[str] = None, **kwargs: Any) -> None: __tracebackhide__ = True self.assert_( func, *args, exception=exception, invert=invert, post_process=post_process, message=message, **kwargs ) # Create a new list of Parameter instances # All keyword-or-positional parameters are converted into positional- or keyword-only try: _prm_values = signature(func).parameters.values() prm_list = _to_positional(_prm_values) except ValueError: prm_list = list(DEFAULT_PRM) else: if not prm_list or prm_list[-1].kind is not VK: prm_list.append(Parameter('kwargs', kind=VK, annotation=Any)) finally: if prm_list and prm_list[0].name == 'self': prm_list[0] = Parameter('obj', kind=PO, annotation=prm_list[0].annotation) prm_list.insert(0, Parameter('self', kind=PO)) wrapper.__name__ = wrapper.__qualname__ = func.__name__ wrapper.__doc__ = create_assertion_doc(func) wrapper.__signature__ = Signature(parameters=prm_list, return_annotation=None) # type: ignore return wrapper #: A string with the (to-be formatted) docstring returned by :func:`wrap_docstring` BASE_DOCSTRING: str = r"""Perform the following assertion: :code:`assert {name}{signature}`. Parameters ---------- {parameters} Keyword Arguments ----------------- invert : :class:`bool` If :data:`True`, invert the output of the assertion: :code:`assert not {name}{signature}`. exception : :class:`type` [:exc:`Exception`], optional Assert that **exception** is raised during/before the assertion operation. post_process : :data:`Callable[[Any], bool]<typing.Callable>`, optional Apply post-processing to the to-be asserted data before asserting aforementioned data. Example values 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` See also -------- {domain} {summary} """
[docs]def create_assertion_doc(func: Callable[..., Any]) -> str: r"""Create a new NumPy style assertion docstring from the docstring of **func**. The summary of **funcs'** docstring, if available, is added to the ``"See also"`` section, in addition with an intersphinx-compatible link to **func**. Examples -------- .. code:: python >>> from assertionlib.functions import create_assertion_doc >>> docstring: str = create_assertion_doc(isinstance) >>> print(docstring) Perform the following assertion: :code:`assert isinstance(obj, class_or_tuple)`. <BLANKLINE> Parameters ---------- obj The positional-only argument ``obj`` of :func:`isinstance()<python:isinstance>`. <BLANKLINE> class_or_tuple The positional-only argument ``class_or_tuple`` of :func:`isinstance()<python:isinstance>`. <BLANKLINE> <BLANKLINE> Keyword Arguments ----------------- invert : :class:`bool` If :data:`True`, invert the output of the assertion: :code:`assert not isinstance(obj, class_or_tuple)`. <BLANKLINE> exception : :class:`type` [:exc:`Exception`], optional Assert that **exception** is raised during/before the assertion operation. <BLANKLINE> post_process : :data:`Callable[[Any], bool]<typing.Callable>`, optional Apply post-processing to the to-be asserted data before asserting aforementioned data. Example values would be the likes of :func:`any()<python:any>` and :func:`all()<python:all>`. <BLANKLINE> message : :class:`str`, optional A custom error message to-be passed to the ``assert`` statement. <BLANKLINE> <BLANKLINE> :rtype: :data:`None` <BLANKLINE> See also -------- :func:`isinstance()<python:isinstance>` Return whether an object is an instance of a class or of a subclass thereof. <BLANKLINE> A tuple, as in ``isinstance(x, (A, B, ...))``, may be given as the target to check against. This is equivalent to ``isinstance(x, A) or isinstance(x, B) or ...`` etc. <BLANKLINE> Parameters ---------- func : :class:`~collections.abc.Callable` A callable whose output is to-be asserted. Returns ------- :class:`str` A new docstring constructed from **funcs'** docstring. """ # noqa: E501 try: __sgn = signature(func) sgn = Signature(_to_positional(__sgn.parameters.values()), return_annotation=None) except ValueError: sgn = Signature(parameters=DEFAULT_PRM, return_annotation=None) sgn_str = '(*args, **kwargs)' else: kv = sgn.parameters.items() sgn_str = '(' + ', '.join((k if v.default is _empty else f'{k}={k}') for k, v in kv) + ')' indent = 4 * ' ' name = getattr(func, '__qualname__', func.__name__) domain = get_sphinx_domain(func) _summary = textwrap.dedent(indent + (func.__doc__ or 'No description.')) summary = textwrap.indent(_summary, indent) parameters = '' for k, v in sgn.parameters.items(): prm_type = PARAM_NAME_MAPPING[v.kind] parameters += f'{k}\n The {prm_type} argument ``{k}`` of {domain}.\n\n' return BASE_DOCSTRING.format( parameters=parameters, name=name, signature=sgn_str, domain=domain, summary=summary )
#: A dictionary which translates certain __module__ values to an actual valid modules MODULE_DICT = MappingProxyType({ 'genericpath': 'os.path', 'posixpath': 'os.path', '_operator': 'operator' }) def _is_builtin_func(func: Callable[..., Any]) -> bool: """Check if **func** is a builtin function.""" return isbuiltin(func) and '.' not in getattr(func, '__qualname__', '')
[docs]def get_sphinx_domain( func: Callable[..., Any], module_mapping: Mapping[str, str] = MODULE_DICT, ) -> str: """Create a Sphinx domain for **func**. Examples -------- .. code:: python >>> from collections import OrderedDict >>> from assertionlib.functions import get_sphinx_domain >>> value1: str = get_sphinx_domain(int) >>> print(value1) :class:`int<python:int>` >>> value2: str = get_sphinx_domain(list.count) >>> print(value2) :meth:`list.count()<python:list.count>` >>> value3: str = get_sphinx_domain(OrderedDict) >>> print(value3) :class:`~collections.OrderedDict` >>> value4: str = get_sphinx_domain(OrderedDict.keys) >>> print(value4) :meth:`~collections.OrderedDict.keys` Parameters ---------- func : :class:`~collections.abc.Callable` A class or (builtin) method or function. module_mapping : :class:`dict` [:class:`str`, :class:`str`] A dictionary for mapping :attr:`__module__` values to actual module names. Useful for whenever there is a discrepancy between the two, *e.g.* the `genericpath` module of :func:`os.path.join`. Returns ------- :class:`str` A string with a valid Sphinx refering to **func**. Raises ------ TypeError Raised if **func** is neither a class or a (builtin) function or method. """ try: name: str = getattr(func, '__qualname__', func.__name__) except AttributeError as ex: raise TypeError("'func' expects a callable with the '__name__' attribute; " f"observed type: {func.__class__.__name__!r}") from ex # Extract the __module__ from **func** try: _module = func.__module__ except AttributeError: # Unbound methods don't have the `__module__` attribute _module = func.__objclass__.__module__ # type: ignore # Convert the extracted __module__ into an actual valid module module = MODULE_DICT.get(_module, _module) # Identify the sphinx domain if isfunction(func) or _is_builtin_func(func): directive = 'func' elif ismethod(func) or ismethoddescriptor(func) or isbuiltin(func): directive = 'meth' elif isclass(func): directive = 'class' # Return the domain as either :func:`...`, :meth:`...` or :class:`...` if module != 'builtins': return f':{directive}:`~{module}.{name}`' else: parenthesis = '()' if directive in {'func', 'meth'} else '' return f':{directive}:`{name}{parenthesis}<python:{name}>`'
#: An immutable mapping of to-be replaced substrings and their replacements. README_MAPPING: Mapping[str, str] = MappingProxyType({ '``': '|', '()': '' }) def load_readme(readme: Union[str, bytes, int, "os.PathLike[str]", "os.PathLike[bytes]"], replace: Mapping[str, str] = README_MAPPING, **kwargs: Any) -> str: r"""Load and return the content of a readme file located in the same directory as this file. Equivalent to importing the content of ``../README.rst``. Parameters ---------- readme : :class:`str` The name of the readme file. replace : :class:`~Collections.abc.Mapping` [:class:`str`, :class:`str`] A mapping of to-be replaced substrings contained within the readme file. \**kwargs : :data:`~typing.Any` Optional keyword arguments for :func:`open`. Returns ------- :class:`str` The content of ``../README.rst``. """ with open(readme, **kwargs) as f: ret: str = f.read() for old, new in replace.items(): ret = ret.replace(old, new) return ret