"""
Module which defines decorators.
"""
import functools
import textwrap
import weakref
from functools import wraps
from time import time
from types import FunctionType
from typing import Callable, Optional, Union
from MDMC.common.time_keeper import TimeKeeper
from MDMC.common.units import UnitFloat, unit_array
[docs]
def unit_decorator(unit: Union[str, None]) -> Callable:
"""
Decorate ``property.setter`` methods to add units.
Adds units to the values passed to ``property.setter`` methods. These units
are displayed when either ``repr`` or ``str`` is called for the
corresponding ``property.getter`` method.
Suitable for use with setter methods that either take `float` (or objects
that can be cast to `float`), or NumPy `array` (or objects that can be cast
to NumPy `array`).
Parameters
----------
unit : str or None
The ``unit`` applied to the property. If `None` then ``self.unit`` is
used, which enables classes to have properties with units defined at
runtime.
Returns
-------
`function`
A ``property.setter`` `function` with a ``value`` parameter which has a
``unit`` (e.g. :any:`UnitFloat` or :any:`UnitNDArray`).
Examples
--------
Add a ``unit_decorator`` to the ``position`` ``property``::
>>> Class Atom(Structure):
...
... @property
... def position(self):
... return self._position
...
... @position.setter
... @unit_decorator(unit=Unit('Ang'))
... def position(self, value):
... self._position = value
"""
# Ignore pylint warning for decorator inner function docstrings
# pylint: disable=missing-docstring
def decorator(func):
def unit_creator(self, value, unit):
try:
return func(self, UnitFloat(value, unit))
except TypeError:
return func(self, unit_array(value, unit))
def wrapper(self, value):
# If decorator doesn't have a unit passed to it, use the unit of the
# object to which the method belongs. If this unit is None then the
# property is unitless, so return the setter applied to the value
# (i.e. don't apply setter to a type with a unit attribute.) This is
# necessary for cases where a property can either have a unit or be
# unitless.
if unit is None:
if self.unit:
return unit_creator(self, value, self.unit)
return func(self, value)
return unit_creator(self, value, unit)
return wrapper
return decorator
[docs]
def unit_decorator_getter(unit: Union[str, None]) -> Callable:
"""
Decorate ``property.getter`` methods to add units.
Adds units to the return values of ``property.getter`` methods. These units
are displayed when either ``repr`` or ``str`` is called.
Suitable for use with setter methods that either take `float` (or objects
that can be cast to `float`), or NumPy `array` (or objects that can be cast
to NumPy `array`). This method exists for properties which have no setter
method.
Parameters
----------
unit : str or None
The ``unit`` applied to the property. If `None` then ``self.unit`` is
used, which enables classes to have properties with units defined at
runtime.
Returns
-------
`function`
A ``property.getter`` `function` with a return type which has a unit
(e.g. :any:`UnitFloat` or :any:`UnitNDArray`).
Examples
--------
Add a ``unit_decorator_getter`` to the ``volume`` property::
>>> Class Universe:
...
... @property
... @unit_decorator_getter(unit=Unit('Ang') ^ 3)
... def volume(self):
... return self.dimensions ** 3
"""
# Ignore pylint warning for decorator inner function docstrings
# pylint: disable=missing-docstring
def decorator(func):
def unit_creator(self, unit):
try:
return UnitFloat(func(self), unit)
except TypeError:
return unit_array(func(self), unit)
def wrapper(self):
# If decorator doesn't have a unit passed to it, use the unit of the
# object to which the method belongs (`self`). If `self.unit` is also `None`, simply
# call the getter without any unit attached.
if unit is None:
if self.unit:
return unit_creator(self, self.unit)
return func(self)
return unit_creator(self, unit)
return wrapper
return decorator
[docs]
def set_docstring(docstring: str) -> Callable:
"""
Decorator for setting the docstring of a function, method, class or property.
The new docstring is text wrapped to ensure that the line length is valid.
It is assumed that the specified docstring has the correct indentations.
Parameters
----------
docstring : str
The new docstring for the function, method, class or property.
Returns
-------
`function`
A decorator which sets the docstring of a function, method, class or
property.
Raises
------
TypeError
If ``set_docstring`` is applied to an object which is not a function,
method, class, or property.
Examples
--------
To dynamically set the docstring of a function:
.. highlight:: python
.. code-block:: python
@set_docstring("This is the new docstring")
def function():
\"\"\"
This docstring will be replaced
\"\"\"
To dynamically set the docstring of a class:
.. highlight:: python
.. code-block:: python
@set_docstring("This is the new docstring")
class DocClass():
\"\"\"
This is a class level docstring. This docstring will be
replaced.
\"\"\"
To dynamically set the docstring of a property:
.. highlight:: python
.. code-block:: python
@property
@set_docstring("This is the new docstring")
def prop():
\"\"\"
This docstring will be replaced
\"\"\"
"""
# Ignore pylint warning for decorator inner function docstrings
#pylint: disable=missing-docstring
def decorator(doc_object):
# docstring must be set outside of wrapper. This means that
# functools.wraps can be used to preserve the docstring of a function,
# after it has been wrapped.
doc_object.__doc__ = wrap_docstring(docstring, 80)
# Decoration of function, method or property requires returning wrapper
if isinstance(doc_object, FunctionType):
@wraps(doc_object)
def wrapper(*args, **settings):
return doc_object(*args, **settings)
return wrapper
# Decoration of classes
if isinstance(doc_object, type):
return doc_object
raise TypeError('set_docstring cannot be applied to this type')
return decorator
[docs]
def mod_docstring(replacements: dict[str, str]) -> Callable:
"""
Decorator for modifying the docstring of a function, method, class or property.
This is done by replacing specified substrings. After replacement the
docstring is text wrapped to ensure that line length and indentations are
preserved.
While this can be used for replacements in equations, care must be taken
to ensure that wrapping does not cause line breaks in invalid places in the
LaTeX.
Parameters
----------
replacements : dict[str, str]
{old: new} pairs where old is a `str` in the docstring which will be
replaced, and new is the `str` it should be replaced with.
Returns
-------
`function`
A decorator which modifies the docstring of a function, method, class or
property.
Raises
------
TypeError
If ``mod_docstring`` is applied to an object which is not a function,
method, class, or property.
Examples
--------
To dynamically modify the docstring of a function so "this" is replaced
with "that":
.. highlight:: python
.. code-block:: python
@mod_docstring({'this': 'that'})
def function():
\"\"\"
The word this will be replaced
\"\"\"
To dynamically modify the docstring of a class so "this" is replaced
with "that":
.. highlight:: python
.. code-block:: python
@mod_docstring({'this': 'that'})
class DocClass():
\"\"\"
This is the class level docstring. The word this will be
replaced.
\"\"\"
To dynamically modify the docstring of a property so "this" is replaced
with "that":
.. highlight:: python
.. code-block:: python
@property
@mod_docstring({'this': 'that'})
def prop():
\"\"\"
The word this will be replaced.
\"\"\"
"""
# Ignore pylint warning for decorator inner function docstrings
#pylint: disable=missing-docstring
def decorator(doc_object):
# docstring must be modified outside of wrapper. This means that
# functools.wraps can be used to preserve the docstring of a function,
# after it has been wrapped.
for old, new in replacements.items():
doc_object.__doc__ = doc_object.__doc__.replace(old, new)
doc_object.__doc__ = wrap_docstring(doc_object.__doc__, 80)
# Decoration of functions, methods or properties requires returning
# wrapper
# Don't type check for properties as this decorator must precede
# property decorator
if isinstance(doc_object, FunctionType):
@wraps(doc_object)
def wrapper(*args, **settings):
return doc_object(*args, **settings)
return wrapper
# Decoration of classes
if isinstance(doc_object, type):
return doc_object
raise TypeError('mod_docstring cannot be applied to this type')
return decorator
[docs]
def wrap_docstring(docstring: str, line_length: int) -> str:
"""
Wrap a docstring to a specific line length.
This maintains any indentation which exists at the start of a line. While
equations should not be affected by this wrapping, it is recommended that
docstrings with .. math:: are visually checked after wrapping.
Parameters
----------
docstring : str
The docstring to be wrapped.
line_length : int
The maximum line length of the docstring before it is wrapped.
Returns
-------
str
The wrapped docstring.
Raises
------
ValueError
If any indent has more characters than the ``line_length``, as the
wrapping cannot then preserve the correct indent
"""
wrapped = []
prev_line = None
prev_indent = None
for line in docstring.split('\n'):
# Get indent of right length for line
indent = (len(line) - len(line.strip())) * ' '
if len(indent) >= line_length:
raise ValueError('The line length is shorter than one or more'
' indents')
# If previous line was wrapped and has same length of indent, then
# prepend it to this line
if prev_line is not None:
if prev_indent == indent and '.. math::' not in line:
line = prev_line + ' ' + textwrap.dedent(line)
else:
wrapped.append('\n' + prev_line)
# Wrap line if the length is greater than the line length
if len(line) > line_length:
wrap = textwrap.wrap(line, line_length, subsequent_indent=indent)
prev_line = wrap[-1]
wrap = ['\n' + element for element in wrap[:-1]]
wrapped += wrap
prev_indent = indent
else:
prev_line = None
wrapped.append('\n' + line)
# If last line in docstring was wrapped, append this to the array
if prev_line is not None:
wrapped.append('\n' + prev_line)
# Accounting for case of docstring starting on line after """
if wrapped[0] == '\n':
del wrapped[0]
# Accounting for case of docstring starting on same line as """
elif wrapped[0][0] == '\n':
wrapped[0] = wrapped[0][1:]
return ''.join(wrapped)
[docs]
def repr_decorator(attribute: str, *attributes: Optional[str]):
"""
Implement ``__repr__`` for a class using passed attributes (including properties).
The first element of all ``__repr__`` returns is always the name of the
class.
.. warning::
Testing for ``repr_decorator`` is restricted to testing the decorator
outputs the correct format, not whether each occurence it is used is
valid. It is strongly recommended that classes decorated with
``repr_decorator`` are tested to ensure that ``repr(class)`` is valid,
for instance whether the class actually has all of the attributes passed
as `str` to ``repr_decorator``.
Parameters
----------
attribute : str
The name of an attribute of the class being decorated. This attribute
(or property) will be included in the ``__repr__`` of the class.
*attributes : str
Zero or more `str` with the name of an attribute (or property) of the
class being decorated. These attributes will be included in the
``__repr__`` of the class.
Returns
-------
class
A class with ``__repr__`` implemented such that ``attribute`` and
``attributes`` are printed.
Examples
--------
Add a ``repr_decorator`` to the ``Atom`` class to include the ``name``
attribute and the ``position`` property::
>>> @repr_decorator('name', 'position')
... Class Atom(Structure):
...
... def __init__(self, element, name, position):
... self.element = element
... self.name = name
... self.position = position
...
... @property
... def position(self):
... return self._position
...
... atom = Atom('H', 'Hydrogen', [1., 1., 1.])
... atom
< Atom
{name: 'Hydrogen',
position: [1., 1., 1.]}>
"""
# Ignore pylint warning for decorator inner function docstrings
# pylint: disable=missing-docstring
def decorator(cls):
def __repr__(self):
attrs = (attribute,) + attributes
# Using getattr rather than __dict__ avoids problems with __slots__
# and properties
repr_dict = {attr: getattr(self, attr) for attr in attrs}
attributes_str = ''.join([key + ': ' + repr(value) + ',\n '
for key, value in repr_dict.items()])
attributes_str = attributes_str.strip(',\n ')
return (f'<{self.__class__.__name__}\n'
f' {{{attributes_str}}}>')
cls.__repr__ = __repr__
return cls
return decorator
[docs]
def weakref_cache(maxsize: int = 128) -> Callable:
"""
Weakref LRU cache to avoid memory leaks.
Caches on instance methods store `self`, which can
lead to excess memory use. This avoids it
by only holding a weakref to the instance.
Parameters
----------
maxsize : int, optional, default=128
Max size of LRU cache.
Returns
-------
`function`
Decorated function with LRU cache.
"""
def wrapper(func):
@functools.lru_cache(maxsize) # create a 'semi-static' cached version of the method
def _func(_self, *args, **kwargs):
return func(_self(), *args, **kwargs)
@functools.wraps(func) # call the 'semi-static' method with weakref
def inner(self, *args, **kwargs):
return _func(weakref.ref(self), *args, **kwargs)
return inner
return wrapper
[docs]
def time_function_execution(func: Callable) -> Callable:
"""
Time a given function.
Create an instance of the :any:`TimeKeeper` and stores the
function execution time in `TimeKeeper`'s class attributes.
This decorator is meant to be used on functions that are
likely to be using up too much CPU time, so the scale
of the problem can be assessed.
Parameters
----------
func : Callable
Function to time.
Returns
-------
`function`
Decorated function with attached timer.
"""
def decorated_func(*args, **kwargs):
tk = TimeKeeper()
fname = " ".join(str(x) for x in [func.__name__, 'in', func.__module__])
# print(fname)
# print(args)
# print(kwargs)
tk.function_called(fname)
start_time = time()
results = func(*args, **kwargs)
end_time = time()
tk.time_passed(fname, abs(end_time-start_time))
return results
return decorated_func