# -*- coding: utf-8 -*-
"""
Utilities
=========
Provides several useful utility functions.
"""
# %% IMPORTS
# Built-in imports
from ast import literal_eval
from inspect import currentframe, getouterframes, isclass, isfunction, ismethod
import logging
import logging.config
import re
import warnings
# e13Tools imports
from e13tools.core import InputError
# All declaration
__all__ = ['add_to_all', 'aux_char_set', 'check_instance', 'delist',
'docstring_append', 'docstring_copy', 'docstring_substitute',
'get_main_desc', 'get_outer_frame', 'raise_error', 'raise_warning',
'split_seq', 'unpack_str_seq']
# %% DECORATOR DEFINITIONS
# Define custom decorator for automatically appending names to __all__
[docs]def add_to_all(obj):
"""
Custom decorator that allows for the name of the provided object `obj` to
be automatically added to the `__all__` attribute of the frame this
decorator is used in. The provided `obj` must have a `__name__` attribute.
"""
# Obtain caller's frame
frame = currentframe().f_back
# Get __all__ list in caller's frame
__all__ = frame.f_globals.get('__all__')
# If __all__ does not exist yet, make a new one
if __all__ is None:
__all__ = []
frame.f_globals['__all__'] = __all__
# Append name of given obj to __all__
if hasattr(obj, '__name__'):
__all__.append(obj.__name__)
else:
raise AttributeError("Input argument 'obj' does not have attribute"
"'__name__'!")
# Return obj
return(obj)
# Define custom decorator for appending docstrings to a function's docstring
[docs]def docstring_append(addendum, join=''):
"""
Custom decorator that allows a given string `addendum` to be appended to
the docstring of the target function/class, separated by a given string
`join`.
If `addendum` is not a string, its :attr:`~object.__doc__` attribute is
used instead.
"""
# If addendum is not a string , try to use its __doc__ attribute
if not isinstance(addendum, str):
addendum = addendum.__doc__
# This function performs the docstring append on a given definition
def do_append(target):
# Perform append
if target.__doc__:
target.__doc__ = join.join([target.__doc__, addendum])
else:
target.__doc__ = addendum
# Return the target definition
return(target)
# Return decorator function
return(do_append)
# Define custom decorator for copying docstrings from one function to another
[docs]def docstring_copy(source):
"""
Custom decorator that allows the docstring of a function/class `source` to
be copied to the target function/class.
"""
# This function performs the docstring copy on a given definition
def do_copy(target):
# Check if source has a docstring
if source.__doc__:
# Perform copy
target.__doc__ = source.__doc__
# Return the target definition
return(target)
# Return decorator function
return(do_copy)
# Define custom decorator for substituting strings into a function's docstring
[docs]def docstring_substitute(*args, **kwargs):
"""
Custom decorator that allows either given positional arguments `args` or
keyword arguments `kwargs` to be substituted into the docstring of the
target function/class.
Both `%` and `.format()` string formatting styles are supported. Keep in
mind that this decorator will always attempt to do %-formatting first, and
only uses `.format()` if the first fails.
"""
# Check if solely args or kwargs were provided
if len(args) and len(kwargs):
raise InputError("Either only positional or keyword arguments are "
"allowed!")
else:
params = args or kwargs
# This function performs the docstring substitution on a given definition
def do_substitution(target):
# Check if target has a docstring that can be substituted to
if target.__doc__:
# Make a copy of the target docstring to check formatting later
doc_presub = str(target.__doc__)
# Try to use %-formatting
try:
target.__doc__ = target.__doc__ % (params)
# If that raises an error, use .format with *args
except TypeError:
target.__doc__ = target.__doc__.format(*params)
# Using **kwargs with % raises no errors if .format is required
else:
# Check if formatting was done and use .format if not
if(target.__doc__ == doc_presub):
target.__doc__ = target.__doc__.format(**params)
# Raise error if target has no docstring
else:
raise InputError("Target has no docstring available for "
"substitutions!")
# Return the target definition
return(target)
# Return decorator function
return(do_substitution)
# %% FUNCTION DEFINITIONS
# This function checks if a given instance was initialized properly
[docs]def check_instance(instance, cls):
"""
Checks if provided `instance` has been initialized from a proper `cls`
(sub)class. Raises a :class:`~TypeError` if `instance` is not an instance
of `cls`.
Parameters
----------
instance : object
Class instance that needs to be checked.
cls : class
The class which `instance` needs to be properly initialized from.
Returns
-------
result : bool
Bool indicating whether or not the provided `instance` was initialized
from a proper `cls` (sub)class.
"""
# Check if cls is a class
if not isclass(cls):
raise InputError("Input argument 'cls' must be a class!")
# Check if instance was initialized from a cls (sub)class
if not isinstance(instance, cls):
raise TypeError("Input argument 'instance' must be an instance of the "
"%s.%s class!" % (cls.__module__, cls.__name__))
# Retrieve a list of all cls attributes
class_attrs = dir(cls)
# Check if all cls attributes can be called in instance
for attr in class_attrs:
if not hasattr(instance, attr):
return(False)
else:
return(True)
# Function that returns a copy of a list with all empty lists/tuples removed
[docs]def delist(list_obj):
"""
Returns a copy of `list_obj` with all empty lists and tuples removed.
Parameters
----------
list_obj : list
A list object that requires its empty list/tuple elements to be
removed.
Returns
-------
delisted_copy : list
Copy of `list_obj` with all empty lists/tuples removed.
"""
# Check if list_obj is a list
if(type(list_obj) != list):
raise TypeError("Input argument 'list_obj' is not of type 'list'!")
# Make a copy of itself
delisted_copy = list(list_obj)
# Remove all empty lists/tuples from this copy
off_dex = len(delisted_copy)-1
for i, element in enumerate(reversed(delisted_copy)):
# Remove empty lists
if(isinstance(element, list) and element == []):
delisted_copy.pop(off_dex-i)
# Remove empty tuples
elif(isinstance(element, tuple) and element == ()):
delisted_copy.pop(off_dex-i)
# Return the copy
return(delisted_copy)
# This function retrieves the main description of an object
[docs]def get_main_desc(source):
"""
Retrieves the main description of the provided object `source` and returns
it.
The main description is defined as the first paragraph of its docstring.
Parameters
----------
source : object
The object whose main description must be retrieved.
Returns
-------
main_desc : str or None
The main description string of the provided `source` or *None* if
`source` has not docstring.
"""
# Retrieve the docstring of provided source
doc = source.__doc__
# If doc is None, return None
if doc is None:
return(None)
# Obtain the index of the last character of the first paragraph
index = doc.find('\n\n')
# If index is -1, there is only 1 paragraph
if(index == -1):
index = len(doc)
# Gather everything up to this index
doc = doc[:index]
# Replace all occurances of 2 or more whitespace characters by a space
doc = re.sub(r"\s{2,}", ' ', doc)
# Return doc
return(doc.strip())
# This function retrieves a specified outer frame of a function
[docs]def get_outer_frame(func):
"""
Checks whether or not the calling function contains an outer frame
corresponding to `func` and returns it if so. If this frame cannot be
found, returns *None* instead.
Parameters
----------
func : function
The function or method whose frame must be located in the outer frames.
Returns
-------
outer_frame : frame or None
The requested outer frame if it was found, or *None* if it was not.
"""
# If func is a function, obtain its name and module name
if isfunction(func):
name = func.__name__
module_name = func.__module__
# Else, if func is a method, obtain its name and class object
elif ismethod(func):
name = func.__name__
class_obj = func.__self__.__class__
# Else, raise error
else:
raise InputError("Input argument 'func' must be a callable function or"
" method!")
# Obtain the caller's frame
caller_frame = currentframe().f_back
# Loop over all outer frames
for frame_info in getouterframes(caller_frame):
# Check if frame has the correct name
if(frame_info.function == name):
# If func is a function, return if module name is also correct
if(isfunction(func) and
frame_info.frame.f_globals['__name__'] == module_name):
return(frame_info.frame)
# Else, return frame if class is also correct
elif(frame_info.frame.f_locals['self'].__class__ is class_obj):
return(frame_info.frame)
else:
return(None)
# This function raises a given error after logging the error
[docs]def raise_error(err_msg, err_type=Exception, logger=None, err_traceback=None):
"""
Raises a given error `err_msg` of type `err_type` and logs the error using
the provided `logger`.
Parameters
----------
err_msg : str
The message included in the error.
Optional
--------
err_type : :class:`Exception` subclass. Default: :class:`Exception`
The type of error that needs to be raised.
logger : :obj:`~logging.Logger` object or None. Default: None
The logger to which the error message must be written.
If *None*, the :obj:`~logging.RootLogger` logger is used instead.
err_traceback : traceback object or None. Default: None
The traceback object that must be used for this exception, useful for
when this function is used for reraising a caught exception.
If *None*, no additional traceback is used.
.. versionadded:: 0.6.17
See also
--------
:func:`~raise_warning`
Raises and logs a given warning.
"""
# Log the error
logger = logging.root if logger is None else logger
logger.error(err_msg)
# Create error value
err_value = err_type(err_msg)
# Raise error
if err_value.__traceback__ is not err_traceback:
raise err_value.with_traceback(err_traceback)
else:
raise err_value
# This function raises a given warning after logging the warning
[docs]def raise_warning(warn_msg, warn_type=UserWarning, logger=None, stacklevel=1):
"""
Raises/issues a given warning `warn_msg` of type `warn_type` and logs the
warning using the provided `logger`.
Parameters
----------
warn_msg : str
The message included in the warning.
Optional
--------
warn_type : :class:`Warning` subclass. Default: :class:`UserWarning`
The type of warning that needs to be raised/issued.
logger : :obj:`~logging.Logger` object or None. Default: None
The logger to which the warning message must be written.
If *None*, the :obj:`~logging.RootLogger` logger is used instead.
stacklevel : int. Default: 1
The stack level of the warning message at the location of this function
call. The actual used stack level is increased by one to account for
this function call.
See also
--------
:func:`~raise_error`
Raises and logs a given error.
"""
# Log the warning and raise it right after
logger = logging.root if logger is None else logger
logger.warning(warn_msg)
warnings.warn(warn_msg, warn_type, stacklevel=stacklevel+1)
# Function for splitting a string or sequence into a list of elements
[docs]def split_seq(*seq):
"""
Converts a provided sequence `seq` to a string, removes all auxiliary
characters from it, splits it up into individual elements and converts all
elements back to booleans; floats; integers; and/or strings.
The auxiliary characters are given by :obj:`~aux_char_set`. One can add,
change and remove characters from the set if required. If one wishes to
keep an auxiliary character that is in `seq`, it must be escaped by a
backslash (note that backslashes themselves also need to be escaped).
This function can be used to easily unpack a large sequence of nested
iterables into a single list, or to convert a formatted string to a list of
elements.
Parameters
----------
seq : str, array_like or tuple of arguments
The sequence that needs to be split into individual elements.
If array_like, `seq` is first unpacked into a string.
It is possible for `seq` to be a nested iterable.
Returns
-------
new_seq : list
A list with all individual elements converted to booleans; floats;
integers; and/or strings.
Examples
--------
The following function calls all produce the same output:
>>> split_seq('A', 1, 20.0, 'B')
['A', 1, 20.0, 'B']
>>> split_seq(['A', 1, 2e1, 'B'])
['A', 1, 20.0, 'B']
>>> split_seq("A 1 20. B")
['A', 1, 20.0, 'B']
>>> split_seq([("A", 1), (["20."], "B")])
['A', 1, 20.0, 'B']
>>> split_seq("[(A / }| ; <1{}) , ,>20.0000 !! < )?% \\B")
['A', 1, 20.0, 'B']
If one wants to keep the '?' in the last string above, it must be escaped:
>>> split_seq("[(A / }| ; <1{}) , ,>20.0000 !! < )\\?% \\B")
['A', 1, 20.0, '?', 'B']
See also
--------
:func:`~unpack_str_seq`
Unpacks a provided (nested) sequence into a single string.
"""
# Unpack the provided sequence into a list of characters
seq = list(unpack_str_seq(*seq, sep='\n'))
# Process all backslashes
for index, char in enumerate(seq):
# If char is a backslash
if(char == '\\'):
# If this backslash is escaped, skip
if(index != 0 and seq[index-1] is None):
pass
# Else, if this backslash escapes a character, replace by None
elif(index != len(seq)-1 and seq[index+1] in aux_char_set):
seq[index] = None
# Remove all unwanted characters from the string, except those escaped
for char in aux_char_set:
# Set the search index
index = 0
# Keep looking for the specified character
while True:
# Check if the character can be found in seq or break if not
try:
index = seq.index(char, index)
except ValueError:
break
# If so, remove it if it was not escaped
if(index == 0 or seq[index-1] is not None):
seq[index] = '\n'
# If it was escaped, remove None instead
else:
seq[index-1] = ''
# Increment search index by 1
index += 1
# Convert seq back to a single string
seq = ''.join(seq)
# Split sequence up into elements
seq = seq.split('\n')
# Remove all empty strings
while '' in seq:
seq.remove('')
# Loop over all elements in seq
for i, val in enumerate(seq):
# Try to convert back to bool/float/int using literal_eval
try:
seq[i] = literal_eval(val)
# If it cannot be evaluated using literal_eval, save as string
except (ValueError, SyntaxError):
seq[i] = val
# Return it
return(seq)
# List/set of auxiliary characters to be used in split_seq()
aux_char_set = set(['(', ')', '[', ']', ',', "'", '"', '|', '/', '\\', '{',
'}', '<', '>', '´', '¨', '`', '?', '!', '%', ':', ';', '=',
'$', '~', '#', '@', '^', '&', '*', '“', '’', '”', '‘',
' ', '\t'])
# Function that unpacks a provided sequence of iterables to a single string
[docs]def unpack_str_seq(*seq, sep=', '):
"""
Unpacks a provided sequence `seq` of elements and iterables, and converts
it to a single string separated by `sep`.
Use :func:`~split_seq` if it is instead required to unpack `seq` into a
single list while maintaining the types of all elements.
Parameters
----------
seq : str, array_like or tuple of arguments
The sequence that needs to be unpacked into a single string.
If `seq` contains nested iterables, this function is used recursively
to unpack them as well.
Optional
--------
sep : str. Default: ', '
The string to use for separating the elements in the unpacked string.
Returns
-------
unpacked_seq : str
A string containing all elements in `seq` unpacked and converted to a
string, separated by `sep`.
Examples
--------
The following function calls all produce the same output:
>>> unpack_str_seq('A', 1, 20.0, 'B')
'A, 1, 20.0, B'
>>> unpack_str_seq(['A', 1, 2e1, 'B'])
'A, 1, 20.0, B'
>>> unpack_str_seq("A, 1, 20.0, B")
'A, 1, 20.0, B'
>>> unpack_str_seq([("A", 1), (["20.0"], "B")])
'A, 1, 20.0, B'
See also
--------
:func:`~split_seq`
Splits up a provided (nested) sequence into a list of individual
elements.
"""
# Check if provided separator is a string
if not isinstance(sep, str):
raise TypeError("Input argument 'sep' is not of type 'str'!")
# Convert provided sequence to a list
seq = list(seq)
# Loop over all elements in seq and unpack iterables to strings as well
for i, arg in enumerate(seq):
# If arg can be iterated over and is not a string, unpack it
if isinstance(arg, (list, tuple, set)):
seq[i] = unpack_str_seq(*arg, sep=sep)
# Join entire sequence together to a single string
unpacked_seq = sep.join(map(str, seq))
# Return unpacked_seq
return(unpacked_seq)