[Checkins] SVN: zope.browserresource/trunk/ Support the HTTP ETag header for file resources. ETag generation can be

Marius Gedminas marius at pov.lt
Fri Aug 13 10:54:22 EDT 2010


Log message for revision 115667:
  Support the HTTP ETag header for file resources.  ETag generation can be
  customized or disabled by providing an IETag multi-adapter on
  (IFileResource, your-application-skin).
  
  Merged mgedmin-etag-support branch with
  
    svn merge -r 115595:115666 svn+ssh://svn.zope.org/repos/main/zope.browserresource/branches/mgedmin-etag-support .
  
  

Changed:
  U   zope.browserresource/trunk/CHANGES.txt
  U   zope.browserresource/trunk/setup.py
  U   zope.browserresource/trunk/src/zope/browserresource/configure.zcml
  U   zope.browserresource/trunk/src/zope/browserresource/file.py
  U   zope.browserresource/trunk/src/zope/browserresource/interfaces.py
  U   zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py
  U   zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py

-=-
Modified: zope.browserresource/trunk/CHANGES.txt
===================================================================
--- zope.browserresource/trunk/CHANGES.txt	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/CHANGES.txt	2010-08-13 14:54:19 UTC (rev 115667)
@@ -2,9 +2,12 @@
 CHANGES
 =======
 
-3.10.4 (unreleased)
+3.11.0 (unreleased)
 ===================
 
+- Support the HTTP ETag header for file resources.  ETag generation can be
+  customized or disabled by providing an IETag multi-adapter on
+  (IFileResource, your-application-skin).
 
 3.10.3 (2010-04-30)
 ===================

Modified: zope.browserresource/trunk/setup.py
===================================================================
--- zope.browserresource/trunk/setup.py	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/setup.py	2010-08-13 14:54:19 UTC (rev 115667)
@@ -19,7 +19,7 @@
                     open('CHANGES.txt').read())
 
 setup(name='zope.browserresource',
-      version = '3.10.4dev',
+      version = '3.11.0dev',
       url='http://pypi.python.org/pypi/zope.browserresource/',
       author='Zope Foundation and Contributors',
       author_email='zope-dev at zope.org',

Modified: zope.browserresource/trunk/src/zope/browserresource/configure.zcml
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/configure.zcml	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/configure.zcml	2010-08-13 14:54:19 UTC (rev 115667)
@@ -1,7 +1,8 @@
 <configure xmlns="http://namespaces.zope.org/zope">
 
   <adapter factory=".resource.AbsoluteURL" />
-  
+  <adapter factory=".file.FileETag" />
+
   <view
       for="zope.component.interfaces.ISite"
       type="zope.publisher.interfaces.browser.IDefaultBrowserLayer"
@@ -17,14 +18,14 @@
         attributes="GET HEAD __call__"
         />
   </class>
-  
+
   <class class=".i18nfile.I18nFileResource">
     <allow
         interface="zope.publisher.interfaces.browser.IBrowserPublisher"
         attributes="GET HEAD __call__"
         />
   </class>
-  
+
   <class class=".directory.DirectoryResource">
     <allow
         interface="zope.publisher.interfaces.browser.IBrowserPublisher"

Modified: zope.browserresource/trunk/src/zope/browserresource/file.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/file.py	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/file.py	2010-08-13 14:54:19 UTC (rev 115667)
@@ -16,6 +16,7 @@
 
 import os
 import time
+import re
 try:
     from email.utils import formatdate, parsedate_tz, mktime_tz
 except ImportError: # python 2.4
@@ -23,25 +24,105 @@
 
 from zope.contenttype import guess_content_type
 from zope.interface import implements, classProvides
+from zope.component import adapts, getMultiAdapter
 from zope.publisher.browser import BrowserView
 from zope.publisher.interfaces import NotFound
+from zope.publisher.interfaces.browser import IBrowserRequest
 from zope.publisher.interfaces.browser import IBrowserPublisher
 
 from zope.browserresource.resource import Resource
+from zope.browserresource.interfaces import IETag
+from zope.browserresource.interfaces import IFileResource
 from zope.browserresource.interfaces import IResourceFactory
 from zope.browserresource.interfaces import IResourceFactoryFactory
 
 
+ETAG_RX = re.compile(r'[*]|(?:W/)?"(?:[^"\\]|[\\].)*"')
+
+
+def parse_etags(value):
+    r"""Parse a list of entity tags.
+
+    HTTP/1.1 specifies the following syntax for If-Match/If-None-Match
+    headers::
+
+        If-Match = "If-Match" ":" ( "*" | 1#entity-tag )
+        If-None-Match = "If-None-Match" ":" ( "*" | 1#entity-tag )
+
+        entity-tag = [ weak ] opaque-tag
+
+        weak       = "W/"
+        opaque-tag = quoted-string
+
+        quoted-string  = ( <"> *(qdtext) <"> )
+        qdtext         = <any TEXT except <">>
+
+        The backslash character ("\") may be used as a single-character
+        quoting mechanism only within quoted-string and comment constructs.
+
+    Examples:
+
+        >>> parse_etags('*')
+        ['*']
+
+        >>> parse_etags(r' "qwerty", ,"foo",W/"bar" , "baz","\""')
+        ['"qwerty"', '"foo"', 'W/"bar"', '"baz"', '"\\""']
+
+    Ill-formed headers are ignored
+
+        >>> parse_etags("not an etag at all")
+        []
+
+    """
+    return ETAG_RX.findall(value)
+
+
+def etag_matches(etag, tags):
+    """Check if the entity tag matches any of the given tags.
+
+        >>> etag_matches('"xyzzy"', ['"abc"', '"xyzzy"', 'W/"woof"'])
+        True
+
+        >>> etag_matches('"woof"', ['"abc"', 'W/"woof"'])
+        False
+
+        >>> etag_matches('"xyzzy"', ['*'])
+        True
+
+    Note that you pass quoted etags in both arguments!
+    """
+    for tag in tags:
+        if tag == etag or tag == '*':
+            return True
+    return False
+
+
+def quote_etag(etag):
+    r"""Quote an etag value
+
+        >>> quote_etag("foo")
+        '"foo"'
+
+    Special characters are escaped
+
+        >>> quote_etag('"')
+        '"\\""'
+        >>> quote_etag('\\')
+        '"\\\\"'
+
+    """
+    return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
+
+
 class File(object):
-    
+
     def __init__(self, path, name):
         self.path = path
         self.__name__ = name
-
         f = open(path, 'rb')
-        data = f.read()
+        self.data = f.read()
         f.close()
-        self.content_type = guess_content_type(path, data)[0]
+        self.content_type = guess_content_type(path, self.data)[0]
 
         self.lmt = float(os.path.getmtime(path)) or time.time()
         self.lmh = formatdate(self.lmt, usegmt=True)
@@ -49,14 +130,14 @@
 
 class FileResource(BrowserView, Resource):
 
-    implements(IBrowserPublisher)
+    implements(IFileResource, IBrowserPublisher)
 
     cacheTimeout = 86400
 
     def publishTraverse(self, request, name):
         '''File resources can't be traversed further, so raise NotFound if
         someone tries to traverse it.
-        
+
           >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
           >>> request = TestRequest()
           >>> resource = factory(request)
@@ -87,73 +168,50 @@
           True
           >>> next == ()
           True
-        
+
         '''
         return getattr(self, request.method), ()
 
     def chooseContext(self):
         '''Choose the appropriate context.
-        
+
         This method can be overriden in subclasses, that need to choose
         appropriate file, based on current request or other condition,
         like, for example, i18n files.
-        
+
         '''
         return self.context
 
     def GET(self):
         '''Return a file data for downloading with GET requests
-        
+
           >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
           >>> request = TestRequest()
           >>> resource = factory(request)
-          >>> resource.GET() ==  open(testFilePath, 'rb').read()
+          >>> resource.GET() == open(testFilePath, 'rb').read()
           True
           >>> request.response.getHeader('Content-Type') == 'text/plain'
           True
-        
-        Let's test If-Modified-Since header support.
 
-          >>> timestamp = time.time()
-        
-          >>> file = factory._FileResourceFactory__file # get mangled file
-          >>> file.lmt = timestamp
-          >>> file.lmh = formatdate(timestamp, usegmt=True)
-
-          >>> before = timestamp - 1000
-          >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True))
-          >>> resource = factory(request)
-          >>> bool(resource.GET())
-          True
-
-          >>> after = timestamp + 1000
-          >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
-          >>> resource = factory(request)
-          >>> bool(resource.GET())
-          False
-          >>> request.response.getStatus()
-          304
-
-        It won't fail on bad If-Modified-Since headers.
-
-          >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE='bad header')
-          >>> resource = factory(request)
-          >>> bool(resource.GET())
-          True
-
         '''
 
         file = self.chooseContext()
         request = self.request
         response = request.response
 
+        etag = getMultiAdapter((self, request), IETag)(file.lmt, file.data)
+
         setCacheControl(response, self.cacheTimeout)
 
+        can_return_304 = False
+        all_cache_checks_passed = True
+
         # HTTP If-Modified-Since header handling. This is duplicated
         # from OFS.Image.Image - it really should be consolidated
         # somewhere...
         header = request.getHeader('If-Modified-Since', None)
         if header is not None:
+            can_return_304 = True
             header = header.split(';')[0]
             # Some proxies seem to send invalid date strings for this
             # header. If the date string is not valid, we ignore it
@@ -165,27 +223,42 @@
                 mod_since = long(mktime_tz(parsedate_tz(header)))
             except:
                 mod_since = None
-            if mod_since is not None:
-                if getattr(file, 'lmt', None):
-                    last_mod = long(file.lmt)
-                else:
-                    last_mod = 0L
-                if last_mod > 0 and last_mod <= mod_since:
-                    response.setStatus(304)
-                    return ''
+            if getattr(file, 'lmt', None):
+                last_mod = long(file.lmt)
+            else:
+                last_mod = 0L
+            if mod_since is None or last_mod <= 0 or last_mod > mod_since:
+                all_cache_checks_passed = False
 
+        # HTTP If-None-Match header handling
+        header = request.getHeader('If-None-Match', None)
+        if header is not None:
+            can_return_304 = True
+            tags = parse_etags(header)
+            if not etag or not etag_matches(quote_etag(etag), tags):
+                all_cache_checks_passed = False
+
+        # 304 responses MUST contain ETag, if one would've been sent with
+        # a 200 response
+        if etag:
+            response.setHeader('ETag', quote_etag(etag))
+
+        if can_return_304 and all_cache_checks_passed:
+            response.setStatus(304)
+            return ''
+
+        # 304 responses SHOULD NOT or MUST NOT include other entity headers,
+        # depending on whether the conditional GET used a strong or a weak
+        # validator.  We only use strong validators, which makes it SHOULD
+        # NOT.
         response.setHeader('Content-Type', file.content_type)
         response.setHeader('Last-Modified', file.lmh)
 
-        f = open(file.path,'rb')
-        data = f.read()
-        f.close()
+        return file.data
 
-        return data
-
     def HEAD(self):
         '''Return proper headers and no content for HEAD requests
-        
+
           >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
           >>> request = TestRequest()
           >>> resource = factory(request)
@@ -196,9 +269,12 @@
 
         '''
         file = self.chooseContext()
+        etag = getMultiAdapter((self, self.request), IETag)(file.lmt, file.data)
         response = self.request.response
         response.setHeader('Content-Type', file.content_type)
         response.setHeader('Last-Modified', file.lmh)
+        if etag:
+            response.setHeader('ETag', etag)
         setCacheControl(response, self.cacheTimeout)
         return ''
 
@@ -210,6 +286,19 @@
         return data
 
 
+class FileETag(object):
+
+    adapts(IFileResource, IBrowserRequest)
+    implements(IETag)
+
+    def __init__(self, context, request):
+        self.context = context
+        self.request = request
+
+    def __call__(self, mtime, content):
+        return '%s-%s' % (mtime, len(content))
+
+
 def setCacheControl(response, secs=86400):
     # Cache for one day by default
     response.setHeader('Cache-Control', 'public,max-age=%s' % secs)

Modified: zope.browserresource/trunk/src/zope/browserresource/interfaces.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/interfaces.py	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/interfaces.py	2010-08-13 14:54:19 UTC (rev 115667)
@@ -23,22 +23,35 @@
     def __call__():
         """return the absolute URL of this resource."""
 
+class IFileResource(IResource):
+    pass
+
 class IResourceFactory(Interface):
-    
+
     def __call__(request):
         """Return an IResource object"""
 
 class IResourceFactoryFactory(Interface):
     """A factory for IResourceFactory objects
-    
+
     These factories are registered as named utilities that can be selected
     for creating resource factories in a pluggable way.
-    
+
     Resource directories and browser:resource directive use these utilities
     to choose what resource to create, depending on the file extension, so
     third-party packages could easily plug-in additional resource types.
-    
+
     """
-    
+
     def __call__(path, checker, name):
         """Return an IResourceFactory"""
+
+class IETag(Interface):
+    """An adapter for computing resource ETags."""
+
+    def __call__(mtime, content):
+        """Compute an ETag for a resource.
+
+        May return None to disable the ETag header.
+        """
+

Modified: zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/tests/test_file.py	2010-08-13 14:54:19 UTC (rev 115667)
@@ -17,12 +17,46 @@
 import doctest
 import os
 import unittest
+import time
+try:
+    from email.utils import formatdate, parsedate_tz, mktime_tz
+except ImportError: # python 2.4
+    from email.Utils import formatdate, parsedate_tz, mktime_tz
+
 from zope.testing import cleanup
-
 from zope.publisher.browser import TestRequest
+from zope.publisher.interfaces.browser import IBrowserRequest
 from zope.security.checker import NamesChecker
+from zope.component import provideAdapter, adapts
+from zope.interface import implements
+from zope.interface.verify import verifyObject
 
+from zope.browserresource.file import FileResourceFactory, FileETag
+from zope.browserresource.interfaces import IFileResource, IETag
 
+
+class MyETag(object):
+    adapts(IFileResource, IBrowserRequest)
+    implements(IETag)
+
+    def __init__(self, context, request):
+        pass
+
+    def __call__(self, mtime, content):
+        return 'myetag'
+
+
+class NoETag(object):
+    adapts(IFileResource, IBrowserRequest)
+    implements(IETag)
+
+    def __init__(self, context, request):
+        pass
+
+    def __call__(self, mtime, content):
+        return None
+
+
 def setUp(test):
     cleanup.setUp()
     data_dir = os.path.join(os.path.dirname(__file__), 'testfiles')
@@ -30,15 +64,227 @@
     test.globs['testFilePath'] = os.path.join(data_dir, 'test.txt')
     test.globs['nullChecker'] = NamesChecker()
     test.globs['TestRequest'] = TestRequest
+    provideAdapter(MyETag)
 
 
 def tearDown(test):
     cleanup.tearDown()
 
+
+def doctest_FileETag():
+    """Tests for FileETag
+
+        >>> etag_maker = FileETag(object(), TestRequest())
+        >>> verifyObject(IETag, etag_maker)
+        True
+
+    By default we constuct an ETag from the file's mtime and size
+
+        >>> etag_maker(1234, 'abc')
+        '1234-3'
+
+    """
+
+
+def doctest_FileResource_GET_sets_cache_headers():
+    """Test caching headers set by FileResource.GET
+
+        >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+        >>> timestamp = time.time()
+
+        >>> file = factory._FileResourceFactory__file # get mangled file
+        >>> file.lmt = timestamp
+        >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+        >>> request = TestRequest()
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+        >>> request.response.getHeader('Last-Modified') == file.lmh
+        True
+        >>> request.response.getHeader('ETag')
+        '"myetag"'
+        >>> request.response.getHeader('Cache-Control')
+        'public,max-age=86400'
+        >>> bool(request.response.getHeader('Expires'))
+        True
+
+    """
+
+
+def doctest_FileResource_GET_if_modified_since():
+    """Test If-Modified-Since header support
+
+        >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+        >>> timestamp = time.time()
+
+        >>> file = factory._FileResourceFactory__file # get mangled file
+        >>> file.lmt = timestamp
+        >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+        >>> before = timestamp - 1000
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True))
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+        >>> after = timestamp + 1000
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        False
+        >>> request.response.getStatus()
+        304
+
+    Cache control headers and ETag are set on 304 responses
+
+        >>> request.response.getHeader('ETag')
+        '"myetag"'
+        >>> request.response.getHeader('Cache-Control')
+        'public,max-age=86400'
+        >>> bool(request.response.getHeader('Expires'))
+        True
+
+    Other entity headers are not
+
+        >>> request.response.getHeader('Last-Modified')
+        >>> request.response.getHeader('Content-Type')
+
+    It won't fail on bad If-Modified-Since headers.
+
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE='bad header')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    it also won't fail if we don't have a last modification time for the
+    resource
+
+        >>> file.lmt = None
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True))
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    """
+
+
+def doctest_FileResource_GET_if_none_match():
+    """Test If-None-Match header support
+
+        >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+        >>> timestamp = time.time()
+
+        >>> file = factory._FileResourceFactory__file # get mangled file
+        >>> file.lmt = timestamp
+        >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+        >>> request = TestRequest(HTTP_IF_NONE_MATCH='"othertag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+        >>> request = TestRequest(HTTP_IF_NONE_MATCH='"myetag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        False
+        >>> request.response.getStatus()
+        304
+
+    Cache control headers and ETag are set on 304 responses
+
+        >>> request.response.getHeader('ETag')
+        '"myetag"'
+        >>> request.response.getHeader('Cache-Control')
+        'public,max-age=86400'
+        >>> bool(request.response.getHeader('Expires'))
+        True
+
+    Other entity headers are not
+
+        >>> request.response.getHeader('Last-Modified')
+        >>> request.response.getHeader('Content-Type')
+
+    It won't fail on bad If-None-Match headers.
+
+        >>> request = TestRequest(HTTP_IF_NONE_MATCH='bad header')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    it also won't fail if we don't have an etag for the resource
+
+        >>> provideAdapter(NoETag)
+        >>> request = TestRequest(HTTP_IF_NONE_MATCH='"someetag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    """
+
+
+def doctest_FileResource_GET_if_none_match_and_if_modified_since():
+    """Test combined If-None-Match and If-Modified-Since header support
+
+        >>> factory = FileResourceFactory(testFilePath, nullChecker, 'test.txt')
+
+        >>> timestamp = time.time()
+
+        >>> file = factory._FileResourceFactory__file # get mangled file
+        >>> file.lmt = timestamp
+        >>> file.lmh = formatdate(timestamp, usegmt=True)
+
+    We've a match
+
+        >>> after = timestamp + 1000
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True),
+        ...                       HTTP_IF_NONE_MATCH='"myetag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        False
+        >>> request.response.getStatus()
+        304
+
+    Last-modified matches, but ETag doesn't
+
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(after, usegmt=True),
+        ...                       HTTP_IF_NONE_MATCH='"otheretag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    ETag matches but last-modified doesn't
+
+        >>> before = timestamp - 1000
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True),
+        ...                       HTTP_IF_NONE_MATCH='"myetag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    Both don't match
+
+        >>> before = timestamp - 1000
+        >>> request = TestRequest(HTTP_IF_MODIFIED_SINCE=formatdate(before, usegmt=True),
+        ...                       HTTP_IF_NONE_MATCH='"otheretag"')
+        >>> resource = factory(request)
+        >>> bool(resource.GET())
+        True
+
+    """
+
+
 def test_suite():
     return unittest.TestSuite((
         doctest.DocTestSuite(
             'zope.browserresource.file',
             setUp=setUp, tearDown=tearDown,
             optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE),
+        doctest.DocTestSuite(
+            setUp=setUp, tearDown=tearDown,
+            optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE),
         ))

Modified: zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py
===================================================================
--- zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py	2010-08-13 14:47:52 UTC (rev 115666)
+++ zope.browserresource/trunk/src/zope/browserresource/tests/test_i18nfile.py	2010-08-13 14:54:19 UTC (rev 115667)
@@ -18,14 +18,17 @@
 
 from zope.publisher.interfaces import NotFound
 
-from zope.component import provideAdapter, provideUtility
+from zope.interface import implements
+from zope.component import provideAdapter, provideUtility, adapts
 from zope.testing import cleanup
 
 from zope.i18n.interfaces import IUserPreferredCharsets, IUserPreferredLanguages
 
 from zope.publisher.http import IHTTPRequest, HTTPCharsets
 from zope.publisher.browser import BrowserLanguages, TestRequest
+from zope.publisher.interfaces.browser import IBrowserRequest
 
+from zope.browserresource.interfaces import IFileResource, IETag
 from zope.browserresource.i18nfile import I18nFileResource
 from zope.browserresource.i18nfile import I18nFileResourceFactory
 from zope.browserresource.file import File
@@ -39,6 +42,17 @@
 test_directory = os.path.dirname(p.__file__)
 
 
+class MyETag(object):
+    adapts(IFileResource, IBrowserRequest)
+    implements(IETag)
+
+    def __init__(self, context, request):
+        pass
+
+    def __call__(self, mtime, content):
+        return 'myetag'
+
+
 class Test(cleanup.CleanUp, TestII18nAware):
 
     def setUp(self):
@@ -48,8 +62,8 @@
         provideAdapter(BrowserLanguages, (IHTTPRequest,), IUserPreferredLanguages)
         # Setup the negotiator utility
         provideUtility(negotiator, INegotiator)
+        provideAdapter(MyETag)
 
-
     def _createObject(self):
         obj = I18nFileResource({'en':None, 'lt':None, 'fr':None},
                                TestRequest(), 'fr')



More information about the checkins mailing list