Source code for utilipy.decorators.baseclass
# -*- coding: utf-8 -*-
# Docstring and Metadata
"""Base class for decorators."""
__author__ = "Nathaniel Starkman"
__all__ = [
# decorators
"DecoratorBaseMeta",
"DecoratorBaseClass",
"classy_decorator",
# utility
"_placeholders",
]
##############################################################################
# IMPORTS
# GENERAL
import copy
from abc import abstractmethod
from collections import namedtuple
from typing import Any, Optional, Callable
# PROJECT-SPECIFIC
from ..utils import (
functools,
inspect,
)
from ..utils.string import FormatTemplate
##############################################################################
# Parameters
_placeholders: tuple = (inspect._empty, inspect._void, inspect._placehold)
class kwargs: # FIXME: temporary fix for Sphinx
pass
##############################################################################
# CODE
##############################################################################
# ----------------------------------------------------------------------------
# MetaClass
[docs]class DecoratorBaseMeta(type):
"""Meta-class for decorators.
This metaclass is pretty specific for DecoratorBaseClass and should
probably not be used in other contexts.
This metaclass
- creates `__kwdefaults__`, copying from `__base_kwdefaults__`.
- makes a named-tuple class for read-only versions of `__kwdefaults__`
- stores the original class and call docs
- formats the class docstrings with the `__kwdefaults__`
"""
def __new__(cls: type, name: str, bases: tuple, dct: dict):
"""Set properties for new decorator class.
define an init method and store original docs
Parameters
----------
cls : type
name : str
bases : tuple
dct : dictionary
"""
# ------------------------
# __base_kwdefaults__ -> __kwdefaults__
dct["__kwdefaults__"] = dct["__base_kwdefaults__"].copy()
dct["_kwd_namedtuple"] = namedtuple(
"kwdefaults", dct["__kwdefaults__"].keys()
)
# --------------------------------------------------
# store original docs
# with None -> ''
dct["_orig_classdoc_"]: FormatTemplate = FormatTemplate(
copy.copy(dct.get("__doc__", None)) or ""
)
dct["_orig_calldoc_"]: FormatTemplate = FormatTemplate(
copy.copy(dct["__call__"].__doc__) or ""
)
# formatting class docstring
# needs to be done here for the first class declaration
# later edits are handled in DecoratorBaseClass by `__new__`
dct["__doc__"]: str = dct["_orig_classdoc_"].safe_substitute(
**dct["__kwdefaults__"]
)
# ------------------------
# 1) change call's signature
# - add a variable positional argument (\*args)
# - promote any positional arguments with defaults
# (except *function*) to keyword only arguments
# - substitue the values of `__kwdefaults__` as the default values
# for key-word arguments with a placehold value
# (_empty, _void, _placehold)
callsig: inspect.FullerSignature
callsig = inspect.fuller_signature(dct["__call__"])
# store whether has kwargs, for use in ``callwrapper``
has_kwargs = callsig.index_var_keyword
# next check if have a var_positional argument
# promoting default-valued positional arguments to kwargs
callsig = callsig.add_var_positional_parameter(index=2)
callsig = callsig.add_var_keyword_parameter(name="kwargs")
# replacing defaults if in __base_kwdefaults__
i: int
name: str
param: inspect.Parameter
for i, (name, param) in enumerate(callsig.parameters.items()):
# only key-word only args (most since added var_positional)
if (name in dct["__kwdefaults__"]) and ( # yup
param.kind == inspect.KEYWORD_ONLY
): # key-word
callsig = callsig.modify_parameter(
i, default=dct["__kwdefaults__"][name]
)
# updating __call__ from callsig
attr: str
for attr in functools.SIGNATURE_ASSIGNMENTS:
try:
value = getattr(callsig, attr)
except AttributeError:
pass
else:
setattr(dct["__call__"], attr, value)
# ------------------------
# 2) Wrap __call__ so can use without parenthesis
# - store __call__
# - change function default to None
# store original call function # TODO fix copy_function
dct["__orig_call__"]: Callable = functools.copy_function(
dct["__call__"]
)
dct["__orig_call__"].__kwdefaults__: dict = dct[
"__call__"
].__kwdefaults__
# dct["__orig_call__"].__signature__ = callsig.signature
# change function default to None
# not doing it before to preserve __orig_call__
try:
callsig = callsig.modify_parameter(
1, default=None, kind=inspect.POSITIONAL_OR_KEYWORD
)
except ValueError: # already has a default
pass
dct["_callsig"]: inspect.Signature = callsig
# make wrapper
def callwrapper(
self, function: Callable = None, *args: Any, **kwargs: Any
):
self = self.new(
**kwargs
) # TODO extract __kwdefaults__ dict from this
# update kwargs to carry over to __orig_call__
if has_kwargs is False: # no kwargs, can only use existing
nkw: dict = (self._callsig.__kwdefaults__ or {}).copy()
else: # can pass everything along
nkw: dict = (self.__kwdefaults__ or {}).copy()
nkw.update(kwargs)
# allow calling without parenthesis
if function is None:
return functools.partial(self.__orig_call__, **nkw)
else:
return self.__orig_call__(function, *args, **nkw)
# /def
callwrapper.__kwdefaults__: dict = dct["__kwdefaults__"].copy()
# /def
# overwrite __call__ with wrapped function
# TODO fix update_wrapper
dct["__call__"]: Callable = functools.update_wrapper(
callwrapper,
dct["__orig_call__"],
signature=callsig,
docstring=dct["_orig_calldoc_"].safe_substitute(
**dct["__kwdefaults__"]
),
)
dct["__call__"].__kwdefaults__: dict = dct["__kwdefaults__"].copy()
# ------------------------
# create and return object
return type.__new__(cls, name, bases, dct)
# /def
def __init__(cls: type, name: str, bases: tuple, dct: dict):
"""__init__ method for MetaClass.
Sets up docstring inheritance.
References
----------
The docstring inheritance method is modified from
astropy's InheritDocstrings metaclass
TODO
----
replace with better doc_inheritance from custom_inherit
"""
# ---------------------
# docstring inheritance
# find public methods
def is_public_member(key: str):
return (
key.startswith("__") and key.endswith("__") and len(key) > 4
) or not key.startswith("_")
# /def
# add docstring to methods
key: str
val: Any
for key, val in dct.items():
# check is public function without a docstring
if (
(inspect.isfunction(val) or inspect.isdatadescriptor(val))
and is_public_member(key)
and val.__doc__ is None
):
# traverse MRO
for base in cls.__mro__[1:]:
super_method = getattr(base, key, None)
if super_method is not None:
val.__doc__ = super_method.__doc__
break
# /for
# /for
# ---------------------
super().__init__(name, bases, dct)
return
# /def
# /class
# ----------------------------------------------------------------------------
# Base Class
[docs]class DecoratorBaseClass(metaclass=DecoratorBaseMeta):
"""Base Class for Decorators.
Parameters
----------
function : Callable, optional
kwargs : dict, optional
attributes
Returns
-------
type
function if called with the function (Decorator(function))
type if no function provided, i.e. pie syntax
Raises
------
ValueError
if a kwarg not supported, i.e. not in __kwdefaults__
"""
__base_kwdefaults__: dict = {}
@property
def kwdefaults(self: type):
"""__kwdefaults__ named tuple."""
return self._kwd_namedtuple(**self.__kwdefaults__)
# /def
def __new__(cls: type, function: Optional[Callable] = None, **kwargs: Any):
"""Make new DecoratorBaseClass.
Parameters
----------
function : Collable, optional
kwargs : dict
attributes
Returns
-------
type
function if called with the function (Decorator(function))
type if no function provided, i.e. pie syntax
Raises
------
ValueError
if a kwarg not supported, i.e. not in __kwdefaults__
"""
self = super().__new__(cls) # make instance
# --------------------
# assign to class
attr_keys = self.__kwdefaults__.keys() # get defaults
# check keys are valid
key: str
for key in kwargs.keys():
if key not in attr_keys:
raise ValueError("")
# update __kwdefaults__ from kwargs
self.__kwdefaults__ = self.__base_kwdefaults__.copy()
self.__kwdefaults__.update(kwargs)
# --------------------
# format docstrings
# done here for all the non-first class creation (see metaclass)
self.__doc__: str = self._orig_classdoc_.safe_substitute(
**self.__kwdefaults__
)
self.__call__.__func__.__doc__: str
self.__call__.__func__.__doc__ = self._orig_calldoc_.safe_substitute(
**self.__kwdefaults__
)
# --------------------
# format __call__ signature
# replacing defaults if in __kwdefaults__
for i, (name, param) in enumerate(self._callsig.parameters.items()):
# only key-word only args (most since added var_positional)
if (
(name in self.__kwdefaults__) # yup
and (param.kind == inspect.KEYWORD_ONLY) # key-word
# and (param.default in _placeholders) # no default!
):
self._callsig = self._callsig.modify_parameter(
i, default=self.__kwdefaults__[name]
)
# /for
# set signature and defaults
self.__call__.__func__.__signature__ = self._callsig.signature
self.__call__.__func__.__kwdefaults__ = self.__kwdefaults__
# print(self._callsig.parameters.values())
# print(self.__call__.__func__.__signature__)
# print(self.__call__.__func__.__kwdefaults__)
# --------------------
if function is not None: # this will return a wrapped function
return self(function)
else: # this will return a function wrapper
return self
# /def
def __getitem__(self: type, name: str):
"""Get item from kwdefaults."""
return self.__kwdefaults__[name]
# /def
def __setitem__(self: type, name: str, value: Any):
"""Set kwdefaults item."""
self.__kwdefaults__[name] = value
# /def
def as_decorator(
self: type,
wrapped_function: Optional[Callable] = None,
**kwdefaults: Any
):
"""Make decorator.
Parameters
----------
wrapped_function : Callable
kwdefaults
update the decorator default values
Returns
-------
Callable or type
function if called with the function (Decorator(function))
type if no function provided, i.e. pie syntax
"""
decorator: type = self.new(**kwdefaults)
if wrapped_function is not None: # return a wrapped function
return decorator(wrapped_function)
# else return a function wrapper
return decorator
# /def
def new(self: type, **kw):
"""Make a new decorator from current decorator.
Inherits properties from current decorator.
Can be overwritten.
Parameters
----------
kw: dict
key, value pairs for the kwdefaults
Returns
-------
type
new decorator with updated properties
TODO
----
figure out how help(self) can correctly display __kwdefaults__
"""
# get and update defaults
kwdefaults: dict = self.__kwdefaults__.copy()
kwdefaults.update(**kw)
# make new class, updating defaults
# TODO use new so override class docstring and recall metaclass
return self.__class__(function=None, **kwdefaults)
# /def
@abstractmethod
def __call__(self: type, function: Callable):
@functools.wraps(function)
def wrapper(*func_args: Any, **func_kwargs: Any):
return function(*func_args, **func_kwargs)
# /def
return wrapper
# /def
# /class
# ----------------------------------------------------------------------------
# Turn a Function into a Decorator
[docs]def classy_decorator(decorator_function: Callable = None):
"""Convert decorated functions to classes.
Parameters
----------
decorator_function: Callable
The decorator function
Returns
-------
Decorator: DecoratorBaseClass
class version of `decorator_function`, using the `DecoratorBaseClass`
"""
if decorator_function is None: # allowing for optional arguments
return functools.partial(classy_decorator)
signature: inspect.Signature
signature = inspect.fuller_signature(decorator_function)
signature = signature.prepend_parameter( # add in self
inspect.Parameter("self", inspect._POSITIONAL_ONLY)
)
# key-word defaults from function
kwd: dict = inspect.getfullerargspec(decorator_function).kwonlydefaults
def mycall(
self: type, wrapped_function: Callable, *args: Any, **kwargs: Any
):
return decorator_function(wrapped_function, *args, **kwargs)
# /def
# TODO rename decorator as function name
class Decorator(DecoratorBaseClass):
__base_kwdefaults__: dict = kwd
# /def
__call__: Callable = functools.makeFunction(
mycall.__code__,
mycall.__globals__,
name="__call__",
signature=signature,
docstring=decorator_function.__doc__,
closure=mycall.__closure__,
)
# /class
# set docstring
Decorator.__doc__: str = FormatTemplate(
(decorator_function.__doc__ or "")
).safe_substitute(**kwd)
Decorator.__name__ = decorator_function.__name__
return Decorator()
# /def
##############################################################################
# END