Search code examples
pythondecoratorpython-decoratorstype-hinting

Using a python decorator function to modify a function's docstring depending on its typehint result


I have a python function that I am documenting with a standard docstring:

"""
Function description

Parameters
----------
p0 : numpy.ndarray, shape (...,4)
    p0 description 1
    p0 description 2
p1 : int, optional
    p1 description

Returns
-------
Return value : my.Returnclass

"""

When I inherit this function from another class and override it, I would like to still make use of the same docstring but modify the return value accordingly. I created a decorator function for this purpose that looks as following and is being used like this:

from typing import Callable, get_type_hints

def modified_docstring(f: Callable) -> Callable:
    """
    Decorator: Combine another function's docstring with a given docstring.

    Parameters
    ----------
    f : function
       Function of which the docstring is taken.
    """
    def replace_return_value(original_func, decorated_func):
        original_docstring = original_func.__doc__
        return_class_original = get_type_hints(original_func).get("return",None)
        return_class_decorated = get_type_hints(decorated_func).get("return",None)
        if return_class_decorated and return_class_original!=return_class_decorated:
            class_name = return_class_decorated.__name__
            class_module = return_class_decorated.__module__.split(".")[0]
            return _re.sub(r'(Returns([ ]*\n[ ]*)-------([ ]*\n[ ]*)[A-Za-z0-9 ]* : )([A-Za-z0-9\.]*)\n',
                           fr'\1{class_module}.{class_name}\2',original_docstring)
        else:
            return original_docstring

    def _decorator(func):
        func.__doc__ = replace_return_value(f,func)
        return func
    return _decorator

class Superclass:
  def my_super_function() -> 'Superclass':
      """
      Function description

      Returns
      -------
      Return value : test.Superclass

      """
      return Superclass()

class DerivedClass(Superclass):
  @modified_docstring(Superclass.my_super_function)
  def my_derived_function() -> 'DerivedClass':
      return DerivedClass()

However, because the typehints are not properly initiated in DerivedClass when the decorator function is being called upon module initialization, this returns the following error:

File "test.py", line 48, in <module>
  class DerivedClass(Superclass):
File "test.py", line 50, in DerivedClass
  def my_derived_function() -> 'DerivedClass':
File "test.py", line 24, in _decorator
  func.__doc__ = replace_return_value(f.__doc__,f,func)
File "test.py", line 14, in replace_return_value
  return_class_decorated = get_type_hints(decorated_func).get("return",None)
File "/usr/lib/python3.8/typing.py", line 1264, in get_type_hints
  value = _eval_type(value, globalns, localns)
File "/usr/lib/python3.8/typing.py", line 270, in _eval_type
  return t._evaluate(globalns, localns)
File "/usr/lib/python3.8/typing.py", line 518, in _evaluate
  eval(self.__forward_code__, globalns, localns),
File "<string>", line 1, in <module>
NameError: name 'DerivedClass' is not defined

Is there a clean way to force the typehints to be initialized when the decorator function is being called, or to delay the initialization of the decorator functions until the typehints are properly initialized?


Solution

  • The problem is that 'DerivedClass' doesn't exist in the module globals yet when 'get_type_hints' is called.

    Edit: Easiest solution:

    wrap the call to 'get_type_hints' in a try except and when you get a NameError and func.__annotations__.get("return", None) returns a string just assume the function is a member function of the return values class and use the module of the function as the module.

    Warning: Both easy-solutions will have a tough time with generics and may result in incorrect type hints when e.g. "List[MyDerivedCls]" is used it will most likely result in a type documentation of my_module.List[MyDerivedCls]

    You can either avoid calling 'get_type_hints' or apply the actual docstring change in a separate call.

    Easy solution - don't evaluate type hint and do what 'get_type_hints' does by hand:

    Access the return value directly without evaluating the forward reference by using func.__annotations__.get("return", None). That way you'll get DerivedClass as a string. If you need the module where the class is defined you can look int the module of the function and search there and if the value doesn't exist assume that it's the class the function is defined in.

    func_return = func.__annotations__.get("return", None)
    if isinstance(decorated_func_return, str):
       func_return_cls_name = func_return
       func_return = sys.modules.get(func.__module__, {}).get(func_return, None)
       if func_return is None:
           # we haven't found the class in the module of the function - assume it's returning an instance of the class this function is a member of
          func_return_module = func.__module__
    
    if func_return is not None:
       func_return_cls_name = func_return.__name__
       func_return_module = func_return.__module__
    

    You can find the fields defined on a function in the 'callable types' section on this page in the docs

    More tedious solution: Delay the docstring modifications

    Make it such that your modified_docstring decorator only marks the function as 'this function should have its docstring modified' and then make a call to a function that applies these modifications. You cannot use a decorator there as 'DerivedClass' won't exist in the module yet when it's called. That would look something like this.

    
    def modified_docstring(original_func):
       def _wrapper(decorated_func):
           decorated_func.__my_docstring_marker__ = original_func
           return decorated_func
       return _wrapper
    
    def apply_docstring_modifications(cls): # looks like a decorator but doesn't work if used as such
       for func_name, func in inspect.getmembers(cls, inspect.isfunction):
            if not hasattr(func, "__my_docstring_marker"):
                continue
    
            func = function_that_does_docstring_modification(func, func.__my_docstring_marker__)
            setattr(cls, func_name, func)
    
    class MyDerivedCls(...):
       ...
    
    apply_docstring_modifications(MyDerivedCls)
    

    Hope this helped.