Chapter 22
Object to File System mapping using FTP as example
Difficulty
Newcomer
Skills
- Be familiar with the Message Board Demo package up to this point.
- Good understanding of the Component Architecture, especially Adapters.
- Feel comfortable with writing ZCML-based configuration.
- Basic knowledge of the filesystem interfaces. Optional.
Problem/Task
Zope provides by default an FTP server , which is a filesystem based protocol. This
immediately raises the question about the way objects are mapped to the filesystem
representation and back. To accomplish the mapping in a flexible and exchangeable
way, there exists a set of interfaces that can be implemented as adapters to provide a
representation that the FTP Publisher understands. This chapter shows how to
implement some of the interfaces for a custom filesystem representation.
Solution
At this point you might wonder: “Why in the world would we have to write our own
filesystem support? Is Zope not providing any implementations by default?” Well, to
answer the latter question: Yes and no. There is an adapter registered for
IContainer to IReadDirectory and IWriteDirectory. However, they are not very
useful, since our Message Board and Message objects are not only containers but
also contain content that is not displayed anywhere. Just start Zope 3 and
check it out yourself. Thus it will be the goal of this chapter to create a
representation that handles the containment and the content at the same
time.
Since the core has already a lot of code presenting IContainer implementations
as directories, we should reuse this part of the framework. The content of an object
could be simply represented by a virtual file called contents, which contains all
relevant data normalized as simple plain text. Note also that we will not need to have
a complete mapping between the object and the filesystem representation, since
we do not need or want to expose annotations for example. I suggest that
the contents of the MessageBoard object simply contains the data of
the description attribute and for the Message I propose the following
format:
1 Title: <message title>
2
3 <message body>
This way we can also parse easily the title when new contents is submitted, since
we want to implement the read and write interfaces of the filesystem representation.
One of the main goals is to keep the VirtualContentsFile class as generic as
possible, so that we can use it for both message boards and messages. To
do that the virtual file must delegate the request to create the plain text
representation to a component that knows about the specifics of the respective
content object. For this task, we will have a simple IPlainText adapter
that provides the plain text representation of each content component’s
contents.
22.1 Step I: Plain Text Adapters
As said above, IPlainText adapters are responsible to return and accept the plain
text representation of the object’s content and just do the right thing with the data.
They are very simple objects, having two methods, one for providing and one for
processing the text.
22.1.1 (a) The Interface
The interface is simple, it defines a getText() and a setText() method:
1 class IPlainText(Interface):
2 """This interface allows you to represent an object's content in plain
3 text."""
4
5 def getText():
6 """Get a pure text representation of the object's content."""
7
8 def setText(text):
9 """Write the text to the object."""
This interface should be placed in the interfaces module of the messageboard.
In the doc strings I refer to the object’s “content” without further qualifying it. With
content I mean all data (property values) that should be represented via the plain
text representation.
The setText() method could in theory become quite complex, depending on
how many properties you want to map and how you represent them. You will see
that in our two cases it will be still fairly simple.
22.1.2 (b) The Implementation
We need two implementations, one for the Message and one for the MessageBoard
class. Note that I skipped writing tests at this point, since the functionality of these
adapters are carefully tested in the following code.
First, we write the message board adapter, so open the messageboard.py file
and add the following code:
1 from book.messageboard.interfaces import IPlainText
2
3 class PlainText:
4
5 implements(IPlainText)
6
7 def __init__(self, context):
8 self.context = context
9
10 def getText(self):
11 return self.context.description
12
13 def setText(self, text):
14 self.context.description = unicode(text)
This is an extremely simple implementation of the IPlainText interface, since
we map only one attribute.
- Line 14: Make sure that the incoming text is unicode, which is very
important for the system’s integrity.
The implementation for the Message (put it in message.py) looks like
this:
1 from book.messageboard.interfaces import IPlainText
2
3 class PlainText:
4
5 implements(IPlainText)
6
7 def __init__(self, context):
8 self.context = context
9
10 def getText(self):
11 return 'Title: %s\n\n%s' %(self.context.title,
12 self.context.body)
13
14 def setText(self, text):
15 if text.startswith('Title: '):
16 title, text = text.split('\n', 1)
17 self.context.title = title[7:]
18
19 self.context.body = text.strip()
This implementation is more interesting, since we map two properties to the plain
text.
- Line 11-12: In typical MIME-header style, define a field with the pattern
<name>:<value> for the title and place the body as content. Note that the
standard requires an empty line after the headers too.
- Line 15-17: Check whether a title was specified in this upload. If so, extract
the title from the data and store the title; if not just ignore the title
altogether. Finally store the rest of the text as the body.
22.1.3 (c) The Configuration
The last step is to register the two adapters with the Component Architecture. Just
add the following two adapter directives to configure.zcml:
1 <adapter
2 factory=".messageboard.PlainText"
3 provides=".interfaces.IPlainText"
4 for=".interfaces.IMessageBoard" />
5
6 <adapter
7 factory=".message.PlainText"
8 provides=".interfaces.IPlainText"
9 for=".interfaces.IMessage" />
We are now done. Even though we have two new fully-functional components
at this point, we gained no new functionality yet, since these adapters are
not used anywhere. We have to complete the next two sections to see any
results.
22.2 Step II: The “Virtual Contents File” Adapter
How we implement the virtual contents file is fully up to us. However,
there are benefits of choosing one way over another, since it will
save us some work. The best method is to create a new interface
IVirtualContentsFile, which extends zope.app.file.interfaces.IFile.
The advantage is that there are already filesystem-specific adapters
(implementing zope.app.filerepresentation.interfaces.IReadFile and
zope.app.filerepresentation.interfaces.IWriteFile) for the above mentioned
interface. IFile might not be the best and most concise interface for our needs, but
the advantages of using it are very convincing.
22.2.1 (a) The Interface
When you look through the Zope 3 source code, you will notice that the IFile and
IFileContent interfaces go hand in hand with each. Thus, our virtual contents file
interface will extend both of these interfaces.
1 from zope.app.file.interfaces import IFile, IFileContent
2
3 class IVirtualContentsFile(IFile, IFileContent):
4 """Marker Interface to mark special Message and Message Board
5 Contents files in FS representations."""
22.2.2 (b) The Implementation
Now the fun begins. First we note that IFile requires three properties,
contentType, data, and size. While data and size are obvious, we need to think
a bit about contentType. Since we really just want to return always text/plain,
the accessor should statically return text/plain and the mutator should just ignore
the input.
To make a long story short, here is the code, which you should place in a new file
called filerepresentation.py:
1 from zope.interface import implements
2 from interfaces import IVirtualContentsFile, IPlainText
3
4 class VirtualContentsFile(object):
5
6 implements(IVirtualContentsFile)
7
8 def __init__(self, context):
9 self.context = context
10
11 def setContentType(self, contentType):
12 '''See interface IFile'''
13 pass
14
15 def getContentType(self):
16 '''See interface IFile'''
17 return u'text/plain'
18
19 contentType = property(getContentType, setContentType)
20
21 def edit(self, data, contentType=None):
22 '''See interface IFile'''
23 self.setData(data)
24
25 def getData(self):
26 '''See interface IFile'''
27 adapter = IPlainText(self.context)
28 return adapter.getText() or u''
29
30 def setData(self, data):
31 '''See interface IFile'''
32 adapter = IPlainText(self.context)
33 return adapter.setText(data)
34
35 data = property(getData, setData)
36
37 def getSize(self):
38 '''See interface IFile'''
39 return len(self.getData())
40
41 size = property(getSize)
- Line 11-13: As promised, the mutator ignores the input totally and is
really just an empty method.
- Line 15-17: Make sure we always return “text/plain”.
- Line 25-28 & 30-33: Now we are making use of our previously created
PlainText adapters. We simply use the two available API methods.
This was pretty straightforward. There are really no surprises here.
22.2.3 (c) The Tests
Since even the last coding step did not provide a functional piece of code, it becomes
so much more important to write some careful tests for the VirtualContentsFile
component. Another requirement of the tests are that this adapter is being tested
with both MessageBoard and Message instances. To realize this, we write an a base
test and then realize this test for each component. So in the tests folder,
create a new file called test_filerepresentation.py and add the following
content:
1 import unittest
2 from zope.interface.verify import verifyObject
3 from zope.app import zapi
4 from zope.app.tests import ztapi
5 from zope.app.tests.placelesssetup import PlacelessSetup
6
7 from book.messageboard.interfaces import \
8 IVirtualContentsFile, IPlainText, IMessage, IMessageBoard
9 from book.messageboard.message import \
10 Message, PlainText as MessagePlainText
11 from book.messageboard.messageboard import \
12 MessageBoard, PlainText as MessageBoardPlainText
13 from book.messageboard.filerepresentation import VirtualContentsFile
14
15 class VirtualContentsFileTestBase(PlacelessSetup):
16
17 def _makeFile(self):
18 raise NotImplemented
19
20 def _registerPlainTextAdapter(self):
21 raise NotImplemented
22
23 def setUp(self):
24 PlacelessSetup.setUp(self)
25 self._registerPlainTextAdapter()
26
27 def testContentType(self):
28 file = self._makeFile()
29 self.assertEqual(file.getContentType(), 'text/plain')
30 file.setContentType('text/html')
31 self.assertEqual(file.getContentType(), 'text/plain')
32 self.assertEqual(file.contentType, 'text/plain')
33
34 def testData(self):
35 file = self._makeFile()
36
37 file.setData('Foobar')
38 self.assert_(file.getData().find('Foobar') >= 0)
39 self.assert_(file.data.find('Foobar') >= 0)
40
41 file.edit('Blah', 'text/html')
42 self.assertEqual(file.contentType, 'text/plain')
43 self.assert_(file.data.find('Blah') >= 0)
44
45 def testInterface(self):
46 file = self._makeFile()
47 self.failUnless(IVirtualContentsFile.providedBy(file))
48 self.failUnless(verifyObject(IVirtualContentsFile, file))
49
50
51 class MessageVirtualContentsFileTest(VirtualContentsFileTestBase,
52 unittest.TestCase):
53
54 def _makeFile(self):
55 return VirtualContentsFile(Message())
56
57 def _registerPlainTextAdapter(self):
58 ztapi.provideAdapter(IMessage, IPlainText, MessagePlainText)
59
60 def testMessageSpecifics(self):
61 file = self._makeFile()
62 self.assertEqual(file.context.title, '')
63 self.assertEqual(file.context.body, '')
64 file.data = 'Title: Hello\n\nWorld'
65 self.assertEqual(file.context.title, 'Hello')
66 self.assertEqual(file.context.body, 'World')
67 file.data = 'World 2'
68 self.assertEqual(file.context.body, 'World 2')
69
70
71 class MessageBoardVirtualContentsFileTest(
72 VirtualContentsFileTestBase, unittest.TestCase):
73
74 def _makeFile(self):
75 return VirtualContentsFile(MessageBoard())
76
77 def _registerPlainTextAdapter(self):
78 ztapi.provideAdapter(IMessageBoard, IPlainText,
79 MessageBoardPlainText)
80
81 def testMessageBoardSpecifics(self):
82 file = self._makeFile()
83 self.assertEqual(file.context.description, '')
84 file.data = 'Title: Hello\n\nWorld'
85 self.assertEqual(file.context.description,
86 'Title: Hello\n\nWorld')
87 file.data = 'World 2'
88 self.assertEqual(file.context.description, 'World 2')
89
90 def test_suite():
91 return unittest.TestSuite((
92 unittest.makeSuite(MessageVirtualContentsFileTest),
93 unittest.makeSuite(MessageBoardVirtualContentsFileTest),
94 ))
95
96 if __name__ == '__main__':
97 unittest.main(defaultTest='test_suite')
- Line 5: Since we are going to make use of adapters, we will need to bring
up the component architecture using the PlacelessSetup.
- Line 7-13: Imports all the relevant interfaces and components for this test.
This is always so much, since we have to register the components by hand
(instead of ZCML).
- Line 17-18: The implementation of this method should create a
VirtualContentsFile adapter having the correct object as context.
Since the context varies, the specific test case class has to take of its
implementation.
- Line 20-21: Since there is a specific adapter registration required for each
case (board and message), we will have to leave that up to the test case
implementation as well.
- Line 27-32: We need to make sure that the plain/text setting can never
be overwritten.
- Line 34-43: We can really just make some marginal tests here, since the
storage details really depend on the IPlainText implementation. There
will be stronger tests in the specific test cases for the message board and
message (see below).
- Line 45-48: Always make sure that the interface is completely implemented
by the component.
- Line 51: This is the beginning of a concrete test case that implements the
base test. Note that you should only make the concrete implementation a
TestCase.
- Line 54-55: Just stick a plain, empty Message instance in the adapter.
- Line 60-68: Here we test that the written contents of the virtual file is
correctly passed and the right properties are set.
- Line 71-88: Pretty much the same that was done for the Message test.
- Line 90-97: The usual test boilerplate.
You can now run the test and verify the functionality of the new tests.
22.2.4 (d) The Configuration
This section would not be complete without a registration. While we do not
need to register the file representation component, we are required to make
some security assertions about the object’s methods and properties. I simply
copied the following security assertions from the File content component’s
configuration.
1 <content class=".filerepresentation.VirtualContentsFile">
2
3 <implements interface="
4 zope.app.annotation.interfaces.IAttributeAnnotatable" />
5
6 <require
7 permission="book.messageboard.View"
8 interface="zope.app.filerepresentation.interfaces.IReadFile" />
9
10 <require
11 permission="zope.messageboard.Edit"
12 interface="zope.app.filerepresentation.interfaces.IWriteFile"
13 set_schema="zope.app.filerepresentation.interfaces.IReadFile" />
14
15 </content>
- Line 3-4: We need the virtual file to be annotable, so it can reach the
DublinCore for dates/times and owner information.
22.3 Step III: The IReadDirectory implementation
After all the preparations are complete, we are finally ready to give our content
components, MessageBoard and Message, a cool filesystem representation.
22.3.1 (a) The Implementation
The first fact we should notice is that zope.app.filerepresentation.ReadDirectory
has already a nice implementation, except for the superfluous SiteManager and the
missing contents file. So we simply take this class (subclass it) and merely
overwrite keys(), get(key,default=None), and __len__(). All other methods
depend on these three. So our code for the ReadDirectory looks something like that
(place in filerepresentation.py):
1 from zope.app.filerepresentation.interfaces import IReadDirectory
2 from zope.app.folder.filerepresentation import \
3 ReadDirectory as ReadDirectoryBase
4
5 class ReadDirectory(ReadDirectoryBase):
6 """An special implementation of the directory."""
7
8 implements(IReadDirectory)
9
10 def keys(self):
11 keys = self.context.keys()
12 return list(keys) + ['contents']
13
14 def get(self, key, default=None):
15 if key == 'contents':
16 return VirtualContentsFile(self.context)
17 return self.context.get(key, default)
18
19 def __len__(self):
20 l = len(self.context)
21 return l+1
- Line 10-12: When being asked for a list of names available for this
container, we get the list of keys plus our virtual contents file.
- Line 14-17: All objects are simply found in the context (MessageBoard
or Message) itself, except the contents. When the system asks for
the contents, we simply give it a VirtualContentsFile instance
that we prepared in the previous section and we do not have to worry
about anything, since we know that the system knows how to handle
zope.app.file.interfaces.IFile objects.
- Line 19-21: Obviously, we pretend to have one more object than we
actually have.
Now we are done with our implementation. Let’s write some unit tests to ensure
the functionality and then register the filesystem components.
22.3.2 (b) The Tests
For testing the ReadDirectory implementation, we again need to test it with the
MessageBoard and Message components. So similar to the previous tests, we
have a base test with specific implementations. Also note that it will not be
necessary to test all IReadDirectory methods, since they are already tested
in the base class tests. So we are just going to test the methods we have
overridden:
1 from book.messageboard.filerepresentation import ReadDirectory
2
3 class ReadDirectoryTestBase(PlacelessSetup):
4
5 def _makeDirectoryObject(self):
6 raise NotImplemented
7
8 def _makeTree(self):
9 base = self._makeDirectoryObject()
10 msg1 = Message()
11 msg1.title = 'Message 1'
12 msg1.description = 'This is Message 1.'
13 msg11 = Message()
14 msg11.title = 'Message 1-1'
15 msg11.description = 'This is Message 1-1.'
16 msg2 = Message()
17 msg2.title = 'Message 1'
18 msg2.description = 'This is Message 1.'
19 msg1['msg11'] = msg11
20 base['msg1'] = msg1
21 base['msg2'] = msg2
22 return ReadDirectory(base)
23
24 def testKeys(self):
25 tree = self._makeTree()
26 keys = list(tree.keys())
27 keys.sort()
28 self.assertEqual(keys, ['contents', 'msg1', 'msg2'])
29 keys = list(ReadDirectory(tree['msg1']).keys())
30 keys.sort()
31 self.assertEqual(keys, ['contents', 'msg11'])
32
33 def testGet(self):
34 tree = self._makeTree()
35 self.assertEqual(tree.get('msg1'), tree.context['msg1'])
36 self.assertEqual(tree.get('msg3'), None)
37 default = object()
38 self.assertEqual(tree.get('msg3', default), default)
39 self.assertEqual(tree.get('contents').__class__,
40 VirtualContentsFile)
41
42 def testLen(self):
43 tree = self._makeTree()
44 self.assertEqual(len(tree), 3)
45 self.assertEqual(len(ReadDirectory(tree['msg1'])), 2)
46 self.assertEqual(len(ReadDirectory(tree['msg2'])), 1)
47
48
49 class MessageReadDirectoryTest(ReadDirectoryTestBase,
50 unittest.TestCase):
51
52 def _makeDirectoryObject(self):
53 return Message()
54
55
56 class MessageBoardReadDirectoryTest(ReadDirectoryTestBase,
57 unittest.TestCase):
58
59 def _makeDirectoryObject(self):
60 return MessageBoard()
- Line 5-6: Return an instance of the object to be tested.
- Line 8-22: Create an interesting message tree on top of the base. This will
allow some more detailed testing.
- Line 24-31: Make sure this contents file and the sub-messages are
correctly listed.
- Line 33-40: Now let’s also make sure that the objects we get are the right
ones.
- Line 42-46: A simple test for the number of contained items (including
the contents).
- Line 49-60: The concrete implementations of the base test. Nothing
special.
After you are done writing the tests, do not forget to add the two new
TestCases to the TestSuite.
22.3.3 (c) The Configuration
Finally we simply register our new components properly using the following ZCML
directives:
1 <adapter
2 for=".interfaces.IMessageBoard"
3 provides="zope.app.filerepresentation.interfaces.IReadDirectory"
4 factory=".filerepresentation.ReadDirectory"
5 permission="zope.View"/>
6
7 <adapter
8 for=".interfaces.IMessage"
9 provides="zope.app.filerepresentation.interfaces.IReadDirectory"
10 factory=".filerepresentation.ReadDirectory"
11 permission="zope.View"/>
That’s it. You can now restart Zope and test the filesystem representation with
an FTP client of your choice. In the following sequence diagram you can see how a
request is guided to find its information and return it properly.
22.4 Step IV: The Icing on the Cake - A special Directory Factory
While you were playing around with the new filesystem support, you might have
tried to create a directory to see what happened and it probably just caused a
system error, since no adapter was found from IMessage/ IMessageBoard to
IDirectoryFactory. Since such a behavior is undesirable, we should create a custom
adapter that provides IDirectoryFactory. The IWriteDirectory adapter of any
container object then knows how to make use of this factory adapter. So we add the
following trivial factory to our filesystem code:
1 from zope.app.filerepresentation.interfaces import IDirectoryFactory
2 from message import Message
3
4 class MessageFactory(object):
5 """A simple message factory for file system representations."""
6
7 implements(IDirectoryFactory)
8
9 def __init__(self, context):
10 self.context = context
11
12 def __call__(self, name):
13 """See IDirectoryFactory interface."""
14 return Message()
Registering the factory is just a matter of two adapter directives (one for each
content component):
1 <adapter
2 for=".interfaces.IMessageBoard"
3 provides="zope.app.filerepresentation.interfaces.IDirectoryFactory"
4 factory=".filerepresentation.MessageFactory"
5 permission="zope.View" />
6
7 <adapter
8 for=".interfaces.IMessage"
9 provides="zope.app.filerepresentation.interfaces.IDirectoryFactory"
10 factory=".filerepresentation.MessageFactory"
11 permission="zope.View" />
Now we have finally made it. The filesystem support should be very smooth and
usable at this point. You should be able to view all relevant content, upload new
contents data and create new messages. The only problem that might remain is that
some FTP clients (like KDE’s FTP support) try to upload the contents file as
contents.part and then rename it to contents. Since our filesystem code does not
support such a feature, this will cause an error; see exercise 2 for details.
Exercises
- Currently there is no creation/modification date/time or creator defined
for the virtual contents file. This is due to the fact that the respective
Dublin Core properties were not set. The virtual file should really receive
the same Dublin Core the MessageBoard or Message has. The easiest
way would be simply to copy the Dublin Core annotation. Do that and
make sure the data and the user are shown correctly.
- In the final remarks of this chapter I stated that the current code will
not work very well with some clients, such as Konqueror. Fix the code to
support this behavior. The best would be to store the temporary file in
an annotation and delete it, once it was moved.