.. currentmodule:: pdcast

.. testsetup::

    import numpy as np
    import pandas as pd
    from pdcast import *

.. _attachable:

pdcast.attachable
=================

.. autodecorator:: attachable

.. raw:: html
    :file: ../../images/decorators/Decorators_UML.html

.. _attachable.basic:

Attachable functions
--------------------
The :func:`@attachable <attachable>` decorator transforms a function into an
:class:`Attachable` object, which can be easily bound to external classes using
the `descriptor protocol
<https://realpython.com/python-descriptors/#python-descriptors-in-methods-and-functions>`_.
These have the following interface, :ref:`in addition <attachable.nested>` to
that of the function itself itself.

.. autosummary::
    :toctree: ../../generated

    Attachable
    Attachable.attached
    Attachable.attach_to
    Attachable.__call__

The behavior of the decorated function is otherwise unchanged.

.. doctest::

    >>> @attachable
    ... def foo(data):
    ...     print("Hello, World!")
    ...     return data

    >>> foo(1)
    Hello, World!
    1

.. _attachable.attributes:

Virtual Attributes
------------------
:class:`VirtualAttributes <VirtualAttribute>` are generated by
:meth:`Attachable.attach_to` according to its ``pattern`` argument.  They come
in a variety of forms depending on how they are accessed:

.. autosummary::
    :toctree: ../../generated

    VirtualAttribute
    InstanceMethod
    ClassMethod
    StaticMethod
    Property

Each inherits the following interface :ref:`in addition <attachable.nested>` to
that of the decorated function:

.. autosummary::
    :toctree: ../../generated

    VirtualAttribute.original
    VirtualAttribute.detach
    VirtualAttribute.__get__

By default, :meth:`Attachable.attach_to` adds the decorated function as an
:class:`InstanceMethod` of the given class.

.. doctest::

    >>> class MyClass:
    ... 
    ...     def __repr__(self):
    ...         return "MyClass()"

    >>> foo.attach_to(MyClass)

This creates a new attribute ``MyClass.foo``, which is a self-binding wrapper
for the decorated function.  It behaves exactly like a normal bound method of
``MyClass``.

.. doctest::

    >>> MyClass.foo
    <function foo at ...>
    >>> MyClass().foo()
    Hello, World!
    MyClass()

If we invoke ``MyClass.foo`` as a class method (i.e. without instantiating
``MyClass`` first), then we get the same behavior as the naked ``foo``
function.

.. doctest::

    >>> MyClass.foo()
    Traceback (most recent call last):
        ...
    TypeError: foo() missing 1 required positional argument: 'data'
    >>> MyClass.foo(MyClass())
    Hello, World!
    MyClass()

We can change this by specifying a different value for the ``pattern`` argument
in :meth:`Attachable.attach_to`.  The allowed values are:

    *   ``"instance"`` (the default) - attach the function as an
        :class:`InstanceMethod` of the associated class.
    *   ``"classmethod"`` - attach the function as a :class:`ClassMethod` of
        the associated class.

        .. testsetup::

            MyClass.foo.detach()

        .. doctest::

            >>> foo.attach_to(MyClass, pattern="classmethod")
            >>> MyClass().foo()
            Hello, World!
            <class '__main__.MyClass'>
            >>> MyClass.foo()
            Hello, World!
            <class '__main__.MyClass'>

    *   ``"staticmethod"`` - attach the function as a :class:`StaticMethod` of
        the associated class.

        .. testsetup::

            MyClass.foo.detach()

        .. doctest::

            >>> foo.attach_to(MyClass, pattern="staticmethod")
            >>> MyClass().foo()
            Traceback (most recent call last):
                ...
            TypeError: foo() missing 1 required positional argument: 'data'
            >>> MyClass.foo()
            Traceback (most recent call last):
                ...
            TypeError: foo() missing 1 required positional argument: 'data'

    *   ``"property"`` - attach the function as a managed :class:`Property` of
        the associated class.

        .. testsetup::

            MyClass.foo.detach()

        .. doctest::

            >>> foo.attach_to(MyClass, pattern="property")
            >>> MyClass().foo
            Hello, World!
            MyClass()
            >>> MyClass.foo
            <pdcast.decorators.attachable.Property object at ...>

.. note::

    Virtual attributes can be removed using :meth:`VirtualAttribute.detach`.

    .. doctest::

        >>> MyClass.foo.detach()
        >>> MyClass().foo
        Traceback (most recent call last):
            ...
        AttributeError: 'MyClass' object has no attribute 'foo'

.. _attachable.namespace:

Namespaces
----------
:class:`Namespaces <Namespace>` are created via the ``namespace`` argument of
:meth:`Attachable.attach_to`.  They inherit the following interface:

.. autosummary::
    :toctree: ../../generated

    Namespace
    Namespace.original
    Namespace.attached
    Namespace.detach
    Namespace.__get__

Occasionally, name conflicts might emerge when attaching attributes to a class.
For instance, if ``MyClass`` defines its own ``MyClass.foo`` method, then our
attached ``foo`` function will mask the original.

.. doctest::

    >>> class MyClass:
    ...     def foo(self):
    ...         print("Goodbye, World!")
    ...         return self
    ... 
    ...     def __repr__(self):
    ...         return "MyClass()"

    >>> foo.attach_to(MyClass)
    >>> MyClass().foo()
    Hello, World!
    MyClass()

We can recover the masked method by calling the attribute's
:attr:`.original <VirtualAttribute.original>` accessor.

.. doctest::

    >>> MyClass().foo.original()
    Goodbye, World!
    MyClass()

But this is not done by default.  This means that any naive reference to
``MyClass.foo`` will **automatically use our overloaded implementation**, even
if that reference is internal to ``MyClass``.  This could lead to unexpected
behavior if the new implementation returns a different result than the method
it masks.

In order to avoid this, we can either rename the overloaded method:

.. testsetup::

    MyClass.foo.detach()

.. doctest::

    >>> foo.attach_to(MyClass, name="bar")
    >>> MyClass.bar
    <function foo at ...>
    >>> MyClass().bar()
    Hello, World!
    MyClass()

Or hide it behind a virtual :class:`Namespace`, which separates it from the
existing attributes of ``MyClass``.

.. testsetup::

    MyClass.bar.detach()

.. doctest::

    >>> foo.attach_to(MyClass, namespace="baz")
    >>> MyClass.baz
    <pdcast.decorators.attachable.Namespace object at ...>
    >>> MyClass.baz.foo
    <function foo at ...>
    >>> MyClass().baz.foo()
    Hello, World!
    MyClass()

Both of these approaches leave the original ``foo`` method unchanged.

.. doctest::

    >>> MyClass().foo()
    Goodbye, World!
    MyClass()

.. note::

    When we :meth:`detach() <VirtualAttribute.detach>` an attribute that is
    hidden behind a :class:`Namespace`, that :class:`Namespace` will be
    automatically removed if it is not managing any additional attributes.

    .. doctest::

        >>> MyClass.baz.foo.detach()
        >>> MyClass().baz.foo()
        Traceback (most recent call last):
            ...
        AttributeError: 'MyClass' object has no attribute 'baz'

.. _attachable.nested:

Cooperative decorators
----------------------
.. TODO: verify that this works as expected.

The :func:`@attachable <attachable>` decorator can be nested cooperatively with
other function decorators in the following order:

.. doctest::

    >>> @attachable
    ... @extension_func
    ... @dispatch
    ... def foo(bar, baz=2, **kwargs):
    ...     raise NotImplementedError(
    ...         f"foo() has no implementation for type: {detect_type(bar)}"
    ...     )

    >>> @foo.argument
    ... def baz(val, defaults):
    ...     return int(val)

    >>> @foo.overload("int")
    ... def integer_foo(bar, baz, **kwargs):
    ...     print("Hello, World!")
    ...     return bar + baz

    >>> foo.attach_to(pd.Series)

This gives the following order of operations:

    #.  If ``foo()`` is invoked from a :class:`pandas.Series` instance, insert
        it as the ``bar`` argument.
    #.  If an explicit ``baz`` argument is provided, validate it using the
        ``baz()`` function and pass the result to ``foo()``.  Otherwise, apply
        its default value.
    #.  If ``bar`` contains integers, dispatch to ``integer_foo()``, otherwise
        raise a :class:`NotImplementedError <python:NotImplementedError>`.

.. doctest::

    >>> pd.Series([1, 2, 3]).foo()
    Hello, World!
    0    3
    1    4
    2    5
    dtype: int64
    >>> pd.Series.foo.baz = "12"
    >>> pd.Series([1, 2, 3]).foo()
    Hello, World!
    0    13
    1    14
    2    15
    dtype: int64
    >>> pd.Series([1., 2., 3.]).foo()
    Traceback (most recent call last):
        ...
    NotImplementedError: foo() has no implementation for type: float64[numpy]

Attribute access will be passed down the decorator stack, effectively combining
each decorator's interface with that of the decorated function.

.. doctest::

    >>> foo.attached
    >>> foo.default_values
    >>> foo.dispatched

.. warning::

    Any dynamic :meth:`arguments <ExtensionFunc.argument>` (as well as their
    default values) will be shared between the :class:`Attachable` function and
    all of its :ref:`bound attributes <attachable.attributes>`.

    .. doctest::

        >>> foo.baz
        12

    This might lead to unexpected changes in behavior if not properly accounted
    for.

Source
------
:func:`@attachable <attachable>` is implemented in pure Python and does not
require any ``pdcast``-specific functionality to work.  It is available as a
generic :doc:`recipe <source/attachable>` for use in other projects.

.. toctree::
    :hidden:

    Recipe <source/attachable>
