Skip to content

Commit

Permalink
Update the Adaptation docs to be more concrete.
Browse files Browse the repository at this point in the history
This should help provide better motivating use cases. Examples inspired by https://glyph.twistedmatrix.com/2021/03/interfaces-and-protocols.html

Also some minor typo fixes and updates to comments.
  • Loading branch information
jamadden committed Mar 17, 2021
1 parent dd69666 commit 488a317
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 86 deletions.
226 changes: 146 additions & 80 deletions docs/README.rst
Expand Up @@ -861,7 +861,17 @@ And the list will be filled with the individual exceptions:
Adaptation
==========

Interfaces can be called to perform adaptation.
Interfaces can be called to perform *adaptation*. Adaptation is the
process of converting an object to an object implementing the
interface. For example, in mathematics, to represent a point in space
or on a graph there's the familiar Cartesian coordinate system using
``CartesianPoint(x, y)``, and there's also the Polar coordinate system
using ``PolarPoint(r, theta)``, plus several others (homogeneous,
log-polar, etc). Polar points are most convenient for some types of
operations, but cartesian points may make more intuitive sense to most
people. Before printing an arbitrary point, we might want to *adapt* it
to ``ICartesianPoint``, or before performing some mathematical
operation you might want to adapt the arbitrary point to ``IPolarPoint``.

The semantics are based on those of the :pep:`246` ``adapt``
function.
Expand All @@ -870,52 +880,62 @@ If an object cannot be adapted, then a ``TypeError`` is raised:

.. doctest::

>>> class I(zope.interface.Interface):
... pass
>>> class ICartesianPoint(zope.interface.Interface):
... x = zope.interface.Attribute("Distance from origin along x axis")
... y = zope.interface.Attribute("Distance from origin along y axis")

>>> I(0)
>>> ICartesianPoint(0)
Traceback (most recent call last):
...
TypeError: ('Could not adapt', 0, <InterfaceClass builtins.I>)
TypeError: ('Could not adapt', 0, <InterfaceClass builtins.ICartesianPoint>)


unless an alternate value is provided as a second positional argument:
unless a default value is provided as a second positional argument;
this value is not checked to see if it implements the interface:

.. doctest::

>>> I(0, 'bob')
>>> ICartesianPoint(0, 'bob')
'bob'

If an object already implements the interface, then it will be returned:

.. doctest::

>>> @zope.interface.implementer(I)
... class C(object):
... pass
>>> @zope.interface.implementer(ICartesianPoint)
... class CartesianPoint(object):
... """The default cartesian point is the origin."""
... def __init__(self, x=0, y=0):
... self.x = x; self.y = y
... def __repr__(self):
... return "CartesianPoint(%s, %s)" % (self.x, self.y)

>>> obj = C()
>>> I(obj) is obj
>>> obj = CartesianPoint()
>>> ICartesianPoint(obj) is obj
True

``__conform__``
---------------

:pep:`246` outlines a requirement:

When the object knows about the [interface], and either considers
itself compliant, or knows how to wrap itself suitably.

This is handled with ``__conform__``. If an object implements
``__conform__``, then it will be used to give the object the chance to
decide if it knows about the interface.
decide if it knows about the interface. This is true even if the class
declares that it implements the interface.

.. doctest::

>>> @zope.interface.implementer(I)
>>> @zope.interface.implementer(ICartesianPoint)
... class C(object):
... def __conform__(self, proto):
... return 0
... return "This could be anything."

>>> I(C())
0
>>> ICartesianPoint(C())
'This could be anything.'

If ``__conform__`` returns ``None`` (because the object is unaware of
the interface), then the rest of the adaptation process will continue.
Expand All @@ -924,43 +944,40 @@ interface, it is returned.

.. doctest::

>>> @zope.interface.implementer(I)
>>> @zope.interface.implementer(ICartesianPoint)
... class C(object):
... def __conform__(self, proto):
... return None

>>> c = C()
>>> I(c) is c
>>> ICartesianPoint(c) is c
True


Adapter hooks (see ``__adapt__``) will also be used, if present (after
Adapter hooks (see :ref:`adapt_adapter_hooks`) will also be used, if present (after
a ``__conform__`` method, if any, has been tried):

.. doctest::

>>> from zope.interface.interface import adapter_hooks
>>> def adapt_0_to_42(iface, obj):
... if obj == 0:
... return 42
>>> def adapt_tuple_to_point(iface, obj):
... if isinstance(obj, tuple) and len(obj) == 2:
... return CartesianPoint(*obj)

>>> adapter_hooks.append(adapt_0_to_42)
>>> I(0)
42
>>> adapter_hooks.append(adapt_tuple_to_point)
>>> ICartesianPoint((1, 1))
CartesianPoint(1, 1)

>>> adapter_hooks.remove(adapt_0_to_42)
>>> I(0)
>>> adapter_hooks.remove(adapt_tuple_to_point)
>>> ICartesianPoint((1, 1))
Traceback (most recent call last):
...
TypeError: ('Could not adapt', 0, <InterfaceClass builtins.I>)

``__adapt__``
-------------
TypeError: ('Could not adapt', (1, 1), <InterfaceClass builtins.ICartesianPoint>)

.. doctest::
.. _adapt_adapter_hooks:

>>> class I(zope.interface.Interface):
... pass
``__adapt__`` and adapter hooks
-------------------------------

Interfaces implement the :pep:`246` ``__adapt__`` method to satisfy
the requirement:
Expand All @@ -973,78 +990,125 @@ This method is normally not called directly. It is called by the
:pep:`246` adapt framework and by the interface ``__call__`` operator
once ``__conform__`` (if any) has failed.

The ``adapt`` method is responsible for adapting an object to the
The ``__adapt__`` method is responsible for adapting an object to the
receiver.

The default version returns ``None`` (because by default no interface
"knows how to suitably wrap the object"):

.. doctest::

>>> I.__adapt__(0)
>>> ICartesianPoint.__adapt__(0)

unless the object given provides the interface ("the object already complies"):

.. doctest::

>>> @zope.interface.implementer(I)
>>> @zope.interface.implementer(ICartesianPoint)
... class C(object):
... pass

>>> obj = C()
>>> I.__adapt__(obj) is obj
>>> ICartesianPoint.__adapt__(obj) is obj
True

Adapter hooks can be provided (or removed) to provide custom
adaptation. We'll install a silly hook that adapts 0 to 42.
We install a hook by simply adding it to the ``adapter_hooks``
list:
.. rubric:: Customizing ``__adapt__`` in an interface

It is possible to replace or customize the ``__adapt___``
functionality for particular interfaces, if that interface "knows how
to suitably wrap [an] object". This method should return the adapted
object if it knows how, or call the super class to continue with the
default adaptation process.

.. doctest::

>>> from zope.interface.interface import adapter_hooks
>>> def adapt_0_to_42(iface, obj):
... if obj == 0:
... return 42
>>> import math
>>> class IPolarPoint(zope.interface.Interface):
... r = zope.interface.Attribute("Distance from center.")
... theta = zope.interface.Attribute("Angle from horizontal.")
... @zope.interface.interfacemethod
... def __adapt__(self, obj):
... if ICartesianPoint.providedBy(obj):
... # Convert to polar coordinates.
... r = math.sqrt(obj.x ** 2 + obj.y ** 2)
... theta = math.acos(obj.x / r)
... theta = math.degrees(theta)
... return PolarPoint(r, theta)
... return super(type(IPolarPoint), self).__adapt__(obj)

>>> @zope.interface.implementer(IPolarPoint)
... class PolarPoint(object):
... def __init__(self, r=0, theta=0):
... self.r = r; self.theta = theta
... def __repr__(self):
... return "PolarPoint(%s, %s)" % (self.r, self.theta)
>>> IPolarPoint(CartesianPoint(0, 1))
PolarPoint(1.0, 90.0)
>>> IPolarPoint(PolarPoint())
PolarPoint(0, 0)

.. seealso:: :func:`zope.interface.interfacemethod`, which explains
how to override functions in interface definitions and why, prior
to Python 3.6, the zero-argument version of `super` cannot be used.

>>> adapter_hooks.append(adapt_0_to_42)
>>> I.__adapt__(0)
42
.. rubric:: Using adapter hooks for loose coupling

Hooks must either return an adapter, or ``None`` if no adapter can
be found.
Commonly, the author of the interface doesn't know how to wrap all
possible objects, and neither does the author of an object know how to
``__conform__`` to all possible interfaces. To support decoupling
interfaces and objects, interfaces support the concept of "adapter
hooks." Adapter hooks are a global sequence of callables
``hook(interface, object)`` that are called, in order, from the
default ``__adapt__`` method until one returns a non-``None`` result.

Hooks can be uninstalled by removing them from the list:
.. note::
In many applications, a :doc:`adapter` is installed as
the first or only adapter hook.

We'll install a hook that adapts from a 2D ``(x, y)`` Cartesian point
on a plane to a three-dimensional point ``(x, y, z)`` by assuming the
``z`` coordinate is 0. First, we'll define this new interface and an
implementation:

.. doctest::

>>> adapter_hooks.remove(adapt_0_to_42)
>>> I.__adapt__(0)
>>> class ICartesianPoint3D(ICartesianPoint):
... z = zope.interface.Attribute("Depth.")
>>> @zope.interface.implementer(ICartesianPoint3D)
... class CartesianPoint3D(CartesianPoint):
... def __init__(self, x=0, y=0, z=0):
... CartesianPoint.__init__(self, x, y)
... self.z = 0
... def __repr__(self):
... return "CartesianPoint3D(%s, %s, %s)" % (self.x, self.y, self.z)


It is possible to replace or customize the ``__adapt___``
functionality for particular interfaces.
We install a hook by simply adding it to the ``adapter_hooks`` list:

.. doctest::

>>> class ICustomAdapt(zope.interface.Interface):
... @zope.interface.interfacemethod
... def __adapt__(self, obj):
... if isinstance(obj, str):
... return obj
... return super(type(ICustomAdapt), self).__adapt__(obj)
>>> from zope.interface.interface import adapter_hooks
>>> def returns_none(iface, obj):
... print("(First adapter hook returning None.)")
>>> def adapt_2d_to_3d(iface, obj):
... if iface == ICartesianPoint3D and ICartesianPoint.providedBy(obj):
... return CartesianPoint3D(obj.x, obj.y, 0)
>>> adapter_hooks.append(returns_none)
>>> adapter_hooks.append(adapt_2d_to_3d)
>>> ICartesianPoint3D.__adapt__(CartesianPoint())
(First adapter hook returning None.)
CartesianPoint3D(0, 0, 0)
>>> ICartesianPoint3D(CartesianPoint())
(First adapter hook returning None.)
CartesianPoint3D(0, 0, 0)

>>> @zope.interface.implementer(ICustomAdapt)
... class CustomAdapt(object):
... pass
>>> ICustomAdapt('a string')
'a string'
>>> ICustomAdapt(CustomAdapt())
<CustomAdapt object at ...>
Hooks can be uninstalled by removing them from the list:

.. seealso:: :func:`zope.interface.interfacemethod`, which explains
how to override functions in interface definitions and why, prior
to Python 3.6, the zero-argument version of `super` cannot be used.
.. doctest::

>>> adapter_hooks.remove(returns_none)
>>> adapter_hooks.remove(adapt_2d_to_3d)
>>> ICartesianPoint3D.__adapt__(CartesianPoint())

.. _global_persistence:

Expand Down Expand Up @@ -1094,6 +1158,8 @@ process, the identical object is found and returned:
>>> imported == IFoo
True

.. rubric:: References to Global Objects

The eagle-eyed reader will have noticed the two funny lines like
``sys.modules[__name__].Foo = Foo``. What's that for? To understand,
we must know a bit about how Python "pickles" (``pickle.dump`` or
Expand All @@ -1105,8 +1171,8 @@ exist (contrast this with pickling a string or an object instance,
which creates a new object in the receiving process) with all their
necessary state information (for classes and interfaces, the state
information would be things like the list of methods and defined
attributes) in the receiving process; the pickled byte string needs
only contain enough data to look up that existing object; this is a
attributes) in the receiving process, so the pickled byte string needs
only contain enough data to look up that existing object; this data is a
*reference*. Not only does this minimize the amount of data required
to persist such an object, it also facilitates changing the definition
of the object over time: if a class or interface gains or loses
Expand Down Expand Up @@ -1145,7 +1211,7 @@ line is automatic), we still cannot pickle the old one:
>>> orig_Foo = Foo
>>> class Foo(object):
... pass
>>> sys.modules[__name__].Foo = Foo # XXX, see below
>>> sys.modules[__name__].Foo = Foo # XXX, usually automatic
>>> pickle.dumps(orig_Foo)
Traceback (most recent call last):
...
Expand Down Expand Up @@ -1210,12 +1276,12 @@ other, and consistent with pickling:

>>> class IFoo(zope.interface.Interface):
... pass
>>> sys.modules[__name__].IFoo = IFoo
>>> sys.modules[__name__].IFoo = IFoo # XXX, usually automatic
>>> f1 = IFoo
>>> pickled_f1 = pickle.dumps(f1)
>>> class IFoo(zope.interface.Interface):
... pass
>>> sys.modules[__name__].IFoo = IFoo
>>> sys.modules[__name__].IFoo = IFoo # XXX, usually automatic
>>> IFoo == f1
True
>>> unpickled_f1 = pickle.loads(pickled_f1)
Expand All @@ -1229,12 +1295,12 @@ This isn't quite the case for classes; note how ``f1`` wasn't equal to

>>> class Foo(object):
... pass
>>> sys.modules[__name__].Foo = Foo
>>> sys.modules[__name__].Foo = Foo # XXX, usually automatic
>>> f1 = Foo
>>> pickled_f1 = pickle.dumps(Foo)
>>> class Foo(object):
... pass
>>> sys.modules[__name__].Foo = Foo
>>> sys.modules[__name__].Foo = Foo # XXX, usually automatic
>>> f1 == Foo
False
>>> unpickled_f1 = pickle.loads(pickled_f1)
Expand Down
2 changes: 1 addition & 1 deletion docs/api/specifications.rst
Expand Up @@ -196,7 +196,7 @@ map to the same value in a dictionary.
Caveats
~~~~~~~

While this behaviour works will with :ref:`pickling (persistence)
While this behaviour works well with :ref:`pickling (persistence)
<global_persistence>`, it has some potential downsides to be aware of.

.. rubric:: Weak References
Expand Down

0 comments on commit 488a317

Please sign in to comment.