Create standalone functions from class methods ("uncurry"?)

Question:

I have a class with many methods, which I’d like to be able to call as ‘standalone’ functions as well.

An example to clarify:

Starting point

A class Person, and a dictionary of Person instances. The class methods can be called on each of the instances, as seen in the last two lines.

from typing import Callable


class Person:
    def __init__(self, age: int, profession: str):
        self._age = age
        self._pro = profession

    def describe(self) -> str:
        """Describes the person in a sentance.

        Parameters
        ----------
        None

        Returns
        -------
        str
        """
        return f"This person is {self._age} years old and is a {self._pro}."

    def getolder(self, years: int = 1) -> None:
        """Increases the age of the person.

        Parameters
        ----------
        years : int, optional
            Number of years to increase age by, by default 1

        Returns
        -------
        None
        """
        self._age += years

    # (and many more)


colleagues = {
    "john": Person(39, "accountant"),
    "jill": Person(40, "boss"),
    "jack": Person(25, "intern"),
}

colleagues["john"].describe() #'This person is 39 years old and is a accountant.'
colleagues["john"].getolder(4)

Goal and current solution

Now, I’d like to abstract the dictionary away, and create functions describe and getolder in such a way, that I can call describe('john') and getolder('john', 4) instead.

I use the following function, which works:

def uncurry(original: Callable) -> Callable:
    def uncurried(name, *args, **kwargs):
        return original(colleagues[name], *args, **kwargs)

    # Add parameter to docstring.
    doc = original.__doc__
    search = "Parametersn        ----------n"
    insert = "        name : strn            Name of the person.n"
    pos = doc.find(search)
    if pos == -1:
        raise ValueError("Unexpected docstring format.")
    pos += len(search)
    uncurried.__doc__ = doc[:pos] + insert + doc[pos:]

    return uncurried

describe = uncurry(Person.describe)
getolder = uncurry(Person.getolder)

describe("john") #'This person is 43 years old and is a accountant.'
getolder("john", 4)

Issues

The current solution has the following issues, see below.

  • The signatures of the describe and getolder functions do not show the parameters.

  • The original docstring is missing the name parameter, which I add in a rather hacky way, and which also is not perfect (e.g., if the original function has no parameters).

If there is a better solution, I’d love to hear it.

>>> help(describe)

Signature: describe(name, *args, **kwargs)
Docstring:
Describes the person in a sentance.

Parameters
----------
name : str
    Name of the person.
None

Returns
-------
str
File:      c:usersruud.wijtvlietruudpythondevbelvysbelvys<ipython-input-24-bbc7c1f4a60a>
Type:      function
Asked By: ElRudi

||

Answers:

If you don’t mind adding a dependency, python-forge seems to be a nice option.

I’m not aware of a nicer option for the docstring though, as they are just strings. Maybe a regex can make it a little more tidy.

import forge

def uncurry(f):
    @forge.compose(
        forge.copy(f, exclude=['self']),
        forge.insert(forge.arg('name', type=str), index=0)
    )
    def wrapper(name, *args, **kwargs):
        return forge.callwith(f, {'self': colleagues[name], **kwargs}, args)

    wrapper.__name__ = f.__name__
    wrapper.__qualname__ = f.__name__  # Not `f.__qualname__`, that  would keep the `Person` bit

    (wrapper.__doc__, subs) = re.subn(
        r'(Parameterss+-+)(s*None)?',
        (r'g<1>n'
         r'        name : strn'
         r'            Name of the person.n'),
        f.__doc__,
        count=1
    )
    if subs != 1:
        raise ValueError("Unexpected docstring format.")

    return wrapper

This gives

assert forge.repr_callable(describe) == 'describe(name: str) -> str'
assert forge.repr_callable(getolder) == 'getolder(name: str, years: int = 1) -> None'

The docstrings are as expected as well.


Without dependency

It is also possible to do this without adding python-forge, but it is not nearly as neat

def uncurry(f):
    def wrapper(name, *args, **kwargs):
        return f(colleagues[name], *args, **kwargs)

    signature = inspect.signature(f)
    name_param = inspect.Parameter('name', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str)
    other_params = [p for p in signature.parameters.values() if p.name != 'self']
    wrapper.__signature__ = signature.replace(parameters=[name_param] + other_params)

    wrapper.__annotations__ = {k: v for k, v in f.__annotations__.items() if k != 'self'}
    wrapper.__annotations__['name'] = str

    wrapper.__name__ = f.__name__
    wrapper.__qualname__ = f.__name__  # Not `f.__qualname__`, that  would keep the `Person` bit

    (wrapper.__doc__, subs) = re.subn(
        r'(Parameterss+-+)(s*None)?',
        (r'g<1>n'
         r'        name : strn'
         r'            Name of the person.n'),
        f.__doc__,
        count=1
    )
    if subs != 1:
        raise ValueError("Unexpected docstring format.")

    return wrapper
Answered By: ahoff
Categories: questions Tags:
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.