 | Level: Intermediate David Mertz (mertz@gnosis.cx), Developer, Gnosis Software, Inc.
04 May 2004 PEAK is a Python framework for rapidly developing and reusing application components. While Python itself is already a very high-level language, PEAK provides even higher abstractions, largely through the clever use of metaclasses and other advanced Python techniques. In many ways, PEAK does for Python what J2EE does for the Java™ language. Part of the formalization in PEAK comes in the explicit specification of protocols, specifically in the separately available package, PyProtocols.
If trying to get a handle on metaclasses, or wrestling
with asynchronous programming in Twisted, or thinking about object-oriented programming with multiple dispatch made your brain melt,
then you ain't seen nothing yet! PEAK, or the Python Enterprise Application Kit, combines some elements of all of
these into a component programming framework. PEAK is still rough
around the edges. Like Twisted, PEAK's documentation -- though
extensive -- is difficult to penetrate. But for all that, there is
something enormously interesting about this project, led by Python
guru Phillip J. Eby; and, I think, some opportunities for extremely
productive and ultra high-level application development.
The PEAK package is composed of a number of subpackages for different
purposes. Some significant subpackages are peak.api,
peak.binding, peak.config, peak.naming, and peak.storage.
Most of those names are self-explanatory. The subpackage
peak.binding is for flexibly
connecting components; peak.config
lets you store "lazily immutable" data, which are involved in
declarative application programming; peak.naming lets you create
globally unique identifiers for (networked) resources; and peak.storage
is pretty much what the name suggests: it lets you manage
databases and persistence.
This article focuses on peak.api, and in particular, on the package
PyProtocols, which is available
separately and provides an infrastructure for other PEAK subpackages. A
version of the PyProtocols package is included within
peak.api.protocols. For now though, let's look at
the separate protocols package. In
later installments, I will
return to other parts of PEAK.
What is a protocol?
A protocol, abstractly, is simply a set of behaviors that an object
agrees to conform to. Strongly-typed programming languages --
including
Python -- have a collection of basic types, each of which has such a
collection of guaranteed behaviors: Integers know how to multiply
themselves together; lists know how to iterate over their contents;
dictionaries know how to find a value by key; files know how to read
and write bytes; and so on. The collection of behaviors you can expect
from built-in types constitutes a protocol they implement. An object
that codifies a protocol is known as an interface.
For standard types, it is not too difficult to explicitly list all the
behaviors implemented (though these change a bit between Python
versions, or certainly between different programming languages). But
at the boundaries -- for objects that belong to custom classes -- it is
difficult to state definitively what constitutes "dictionary-like" or
"file-like" behavior. Most of the time, a custom object that
implements only a subset -- even a fairly small subset -- of, for
example, the
methods of a built-in dict are
dictionary-like enough for purposes
at hand. It would be nice, however, to be able to explicitly codify
what an object needs to be able to do to be used within a given
function, module, class, or framework. That's (part of) what the
PyProtocols package does.
In programming languages that have static type declarations, you
generally need to cast or convert data from one type to
another to
use it in a new context. In other languages, conversions are performed
implicitly as a context requires them, and these are called
coercions. Python contains a mixture of casts and coercions, with
a
usual preference for the former ("explicit is better than implicit").
You can add a float to an integer, and wind up with a more general
float as a result; but if you want to convert the string "3.14" into
a number, you need to use the explicit constructor float("3.14").
PyProtocols provides a capability called "adaptation," which is akin
to the eccentric computer science concept of "partial
typing." Adaptation might also
be thought of as "coercion on steroids." If an interface defines a set
of needed capabilities (in other words, object methods), adaptation -- via the
function protocols.adapt() -- is a request to
an object to do
"whatever is necessary" to provide the needed capabilities. Obviously,
if you have an explicit conversion function to turn an object of type
X into one of type Y (where Y implements some
interface IY),
that function suffices to adapt X to protocol
IY. However,
adaptation in PyProtocols can do quite a bit more than this as well.
For example, even if you have never explicitly programmed a conversion
from type X to type Y, adapt() can often
deduce a route to let
X provide the capabilities mandated by IY (for example, by finding
intermediate conversions from X to
interface IZ, from IZ to
IW, and from IW to
IY).
 |
Declaring interfaces and adaptors
There are quite a few different ways within PyProtocols to create
interfaces and adaptors. The PyProtocols documentation goes into
these techniques in quite a bit of detail -- more than is covered in this article.
This article presents a minimal realistic example of actual PyProtocols code.
The example shows a Lisp-like serialization of
Python objects. The representation is not literally any Lisp dialect.
The idea here is just to create something that performs a
job similar to the function repr() or the module pprint, but with
a result that is both obviously different from and more easily
extensible/customizable than the aforesaid serializers. One very
un-Lisp-like choice serves to illustrate: mappings are a
more fundamental data structure than are lists (a Python tuple or list
is treated as a mapping whose keys are sequential integers). Here is
the code:
Listing 1. lispy.py PyProtocol definitions
from protocols import *
from cStringIO import StringIO
# Like unicode, & even support objects that don't explicitly support ILisp
ILisp = protocolForType(unicode, ['__repr__'], implicit=True)
# Class for interface, but no methods specifically required
class ISeq(Interface): pass
# Class for interface, extremely simple mapping interface
class IMap(Interface):
def items():
"A requirement for a map is to have an .items() method"
# Define function to create an Lisp like representation of a mapping
def map2Lisp(map_, prot):
out = StringIO()
for k,v in map_.items():
out.write("(%s %s) " % (adapt(k,prot), adapt(v,prot)))
return "(MAP %s)" % out.getvalue()
# Use this func to convert an IMap-supporting obj to ILisp-supporting obj
declareAdapter(map2Lisp, provides=[ILisp], forProtocols=[IMap])
# Note that a dict implements an IMap interface with no conversion needed
declareAdapter(NO_ADAPTER_NEEDED, provides=[IMap], forTypes=[dict])
# Define and use func to adapt an InstanceType obj to the ILisp interface
from types import InstanceType
def inst2Lisp(o, p):
return "(CLASS '(%s) %s)" % (o.__class__.__name__, adapt(o.__dict__,p))
declareAdapter(inst2Lisp, provides=[ILisp], forTypes=[InstanceType])
# Define a class to adapt an ISeq-supporting obj to an IMap-supporting obj
class SeqAsMap(object):
advise(instancesProvide=[IMap],
asAdapterForProtocols=[ISeq] )
def __init__(self, seq, prot):
self.seq = seq
self.prot = prot
def items(self): # Implement the IMap required .items() method
return enumerate(self.seq)
# Note that list, tuple implement an ISeq interface w/o conversion needed
declareAdapter(NO_ADAPTER_NEEDED, provides=[ISeq], forTypes=[list, tuple])
# Define a lambda func to adapt str, unicode to ILisp interface
declareAdapter(lambda s,p: "'(%s)" % s,
provides=[ILisp], forTypes=[str,unicode])
# Define a class to adapt several numeric types to ILisp interface
# Return a string (ILisp-supporting) directly from instance constructor
class NumberAsLisp(object):
advise(instancesProvide=[ILisp],
asAdapterForTypes=[long, float, complex, bool] )
def __new__(klass, val, proto):
return "(%s %s)" % (val.__class__.__name__.upper(), val)
|
In Listing 1, a number of adapters are declared in several
different ways. In some cases, the code converts one interface to
another interface; in other cases, types themselves are directly
adapted to an interface. Notice a few things
about the code: (1) No adapter from a list or
tuple to the ILisp
interface was created; (2) No adapter is explicitly declared for the
int numeric type; (3) For that matter, no
adapter directly from a
dict to ILisp is
declared. Here is how the code will adapt()
various
Python objects:
Listing 2. test_lispy.py object serialization
from lispy import *
from sys import stdout, stderr
toLisp = lambda o: adapt(o, ILisp)
class Foo:
def __init__(self):
self.a, self.b, self.c = 'a','b','c'
tests = [
"foo bar",
{17:2, 33:4, 'biz':'baz'},
["bar", ('f','o','o')],
1.23,
(1L, 2, 3, 4+4j),
Foo(),
True,
]
for test in tests:
stdout.write(toLisp(test)+'\n')
|
When run, the results are:
Listing 3. test_lispy.py serialization results
$ python2.3 test_lispy.py
'(foo bar)
(MAP (17 2) ('(biz) '(baz)) (33 4) )
(MAP (0 '(bar)) (1 (MAP (0 '(f)) (1 '(o)) (2 '(o)) )) )
(FLOAT 1.23)
(MAP (0 (LONG 1)) (1 2) (2 3) (3 (COMPLEX (4+4j))) )
(CLASS '(Foo) (MAP ('(a) '(a)) ('(c) '(c)) ('(b) '(b)) ))
(BOOL True)
|
Some explanation of this output is helpful. The first line is simple:
it defines an adapter directly from a string to ILisp, the call to
adapt("foo bar", ILisp) just returns the
results of the lambda
function. The next line is just a smidgeon more complicated. No
adapter takes us directly from a dict to
ILisp; but we can adapt
dict to IMap
without any adapter (we declared as much), and we
have an adapter from IMap to ILisp. Likewise, for the later lists
and tuples, we can adapt ILisp to ISeq,
ISeq to IMap, and
IMap to ILisp.
PyProtocols performs all the magic of figuring out
which adaptation path to take, behind the scenes. A old-style instance
is the same story as a string or an IMap-supporting object, we have
an adaptation directly to ILisp.
But wait a moment. What about all the integers used in our dict and
tuple objects? Numeric long, complex, float, and bool have
an explicit adapter, but int lacks any. The
trick here is that an
int object already has a .__repr__() method; by declaring implicit
support as part of the ILisp interface, we
are happy to use the
existing .__repr__() method of objects as
support for the ILisp
interface. In particular, as a built-in, integers are represented
with bare digits, rather than with a capitalized type initializer (for example,
LONG).
Adaptation protocol
Let's look closely at what the function
protocol.adapt() does. In the example, the "declaration API"
implicitly set up a collection of "factories" for adaptation. This
API has several levels to it. The "primitives" of the
declaration API are the functions: declareAdaptorForType(),
declareAdaptorForObject(), and declareAdaptorForProtocol(). The
prior example did not use these, but rather higher-level APIs like
declareImplementation(), declareAdaptor(), adviceObject(), and
protocolForType(). In one case, we saw the
"magic" declaration
advise() within a class body. The function
advise() allows a
large number of keyword arguments that configure the purpose and role
of a class so advised. You may also advise()
a module object.
You do not need to use the declaration API to create adaptable
objects or interfaces that know how to adapt() objects to them.
Let's look at the call signature of adapt() and the
procedure it follows. A call to adapt()
looks like:
adapt(component, protocol, [, default [, factory]])
This says that you would like to adapt the object component
to the interface protocol. If default is specified, it can be
returned as a wrapper object or modification for component. If
factory is specified as a keyword argument, a
conversion factory can
be used to produce the wrapper or modification. But let's back up a
little bit, and look at the complete sequence of actions attempted by
adapt() (in simplified code):
Listing 4. Hypothetical implementation of adapt()
if isinstance(component, protocol):
return component
elif hasattr(component,'__conform__'):
return component.__conform__(protocol)
elif hasattr(protocol,'__adapt__'):
return protocol.__adapt__(component)
elif default is not None:
return default
elif factory is not None:
return factory(component, protocol)
else:
NotImplementedError
|
There are a couple qualities that calls to adapt() should maintain
(but this is advice to programmers, not generally enforced by the
library). Calls to adapt() should be
idempotent. That is, for an
object x and a protocol P, we hope that:
adapt(x,P)==adapt(adapt(x,P),P). In style,
this intent is similar to
that behind iterator classes that return self
from the .__iter__()
method. Basically, you do not want re-adapting to the same thing you
already adapted to to produce fluctuating results.
It is also worth noting that adaptation might be lossy. In order to
bring an object into conformance with an interface, it may be
inconvenient or impossible to keep all the information necessary to
re-create the initial object. That is, in the general case, for
object x and protocols P1 and P2:
adapt(x,P1)!=adapt(adapt(adapt(x,P1),P2),P1).
Before concluding, let's look at another test script that takes some
advantage of the lower level behavior of adapt():
Listing 5. test_lispy2.py object serialization
from lispy import *
class Bar(object):
pass
class Baz(Bar):
def __repr__(self):
return "Represent a "+self.__class__.__name__+" object!"
class Bat(Baz):
def __conform__(self, prot):
return "Adapt "+self.__class__.__name__+" to "+repr(prot)+"!"
print adapt(Bar(), ILisp)
print adapt(Baz(), ILisp)
print adapt(Bat(), ILisp)
print adapt(adapt(Bat(), ILisp), ILisp)
$ python2.3 test_lispy2.py
<__main__.Bar object at 0x65250>
Represent a Baz object!
Adapt Bat to WeakSubset(<type 'unicode'>,('__repr__',))!
'(Adapt Bat to WeakSubset(<type 'unicode'>,('__repr__',))!)
|
It turns out the design of lispy.py fails the
idempotence goal. A
good exercise might be to improve this design. The representation as
ILisp, however, is certainly lossy of the
information in the
original object (which is fine).
Conclusion
In feel, PyProtocols has some commonalities with other "exotic" topics
this column has addressed. For one thing, the declaration API is,
well, declarative (in contrast to imperative). Rather than giving the
steps and switches needed to perform an action, declarative
programming states that certain things hold, and lets a library or
compiler figure out the details about how to carry them out. The names
"declare*()" and "advice*()" are well chosen from this perspective.
Also, however, I find that PyProtocols programming has a similarity
to programming with multiple dispatch, specifically with the
gnosis.magic.multimethods module that
I covered in another
installment. My own module performs a relatively simple deduction of
relevant ancestor classes to dispatch, in contrast to PyProtocols'
determination of adaptation paths. But both libraries tend to
encourage a similar kind of modularity in programming -- lots of little
functions or classes to perform "pluggable" tasks, without necessarily
falling into rigid class hierarchies. The style has an elegance to it,
in my opinion.
Resources
-
The reference/tutorial document
Component
Adaptation + Open Protocols = The PyProtocols Package is the place to
start exploring the intricacies of PyProtocols.
-
The home page for PEAK itself
is the place to start for an introduction to the library as a whole.
- Browse all Charming Python columns on developerWorks.
-
An earlier Charming Python
column looked
at creating
declarative mini-languages within Python.
-
Another prior Charming Python column developed and
presented a library to enable
multiple dispatch.
-
Michele Simionato and I wrote two articles on metaclasses, an
introduction, and a look at some
intricacies of metaclasses behavior.
-
The Twisted library is similar to PEAK, both in containing a concept
of protocols and in various capabilities such as asynchronous
programming and providing an application configuration framework.
Part I was
Understanding asynchronous networking. Part 2 covered
Implementing Web servers. Part 3 looked at
Stateful Web servers and templating. And finally, Part 4,
Secure clients and servers.
-
Part of what PEAK contains is a capability for something similar to
"weightless threads" that I described in two prior Charming
Python
columns:
Generator-based State Machines, and one on
semi-coroutines.
- Learn more about types and typing in Python at the
artima
Weblogs Forum.
- Find more resources for Linux developers in the
developerWorks
Linux zone.
- Browse for books on these and other technical topics.
- Develop and test your Linux applications using the latest IBM tools and middleware with a developerWorks Subscription: you get IBM software from WebSphere, DB2, Lotus, Rational, and Tivoli, and a license to use the software for 12 months, all for less money than you might think.
- Download no-charge trial versions of selected developerWorks Subscription products that run on Linux, including WebSphere Studio Site Developer, WebSphere SDK for Web services, WebSphere Application Server, DB2 Universal Database Personal Developers Edition, Tivoli Access Manager, and Lotus Domino Server, from the Speed-start your Linux app section of developerWorks. For an even speedier start, help yourself to a product-by-product collection of how-to articles and tech support.
About the author  | |  | David Mertz believes in adaptation by selective programming. You can reach David at mertz@gnosis.cx; you can investigate all aspects of his life at his personal Web page. Check out his book, Text Processing in Python. Suggestions and recommendations on past or future columns are welcome. |
Rate this page
|  |