[Zope-Checkins] SVN: Zope/branches/chrism-clockserver-merge/ Add "clock server" feature.

Chris McDonough chrism at plope.com
Wed Dec 21 10:22:34 EST 2005


Log message for revision 40957:
  Add "clock server" feature.
  
  

Changed:
  U   Zope/branches/chrism-clockserver-merge/doc/CHANGES.txt
  A   Zope/branches/chrism-clockserver-merge/lib/python/ZServer/ClockServer.py
  U   Zope/branches/chrism-clockserver-merge/lib/python/ZServer/component.xml
  U   Zope/branches/chrism-clockserver-merge/lib/python/ZServer/datatypes.py
  A   Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_clockserver.py
  U   Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_config.py
  U   Zope/branches/chrism-clockserver-merge/skel/etc/zope.conf.in

-=-
Modified: Zope/branches/chrism-clockserver-merge/doc/CHANGES.txt
===================================================================
--- Zope/branches/chrism-clockserver-merge/doc/CHANGES.txt	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/doc/CHANGES.txt	2005-12-21 15:22:34 UTC (rev 40957)
@@ -26,6 +26,58 @@
 
     Features added
 
+      - Added a "clock server" servertype which allows users to
+        configure methods that should be called periodically as if
+        they were being called by a remote user agent on one of Zope's
+        HTTP ports.  This is meant to replace wget+cron for some class
+        of periodic callables.
+
+        To use, create a "clock-server" directive section anywhere
+        in your zope.conf file, like so:
+
+         <clock-server>
+            method /do_stuff
+            period 60
+            user admin
+            password 123
+            host localhost
+         </clock-server>
+
+        Any number of clock-server sections may be defined within a
+        single zope.conf.  Note that you must specify a
+        username/password combination with the appropriate level of
+        access to call the method you've defined.  You can omit the
+        username and password if the method is anonymously callable.
+        Obviously the password is stored in the clear in the config
+        file, so you need to protect the config file with filesystem
+        security if the Zope account is privileged and those who have
+        filesystem access should not see the password.
+
+        Descriptions of the values within the clock-server section
+        follow::
+
+          method -- the traversal path (from the Zope root) to an
+          executable Zope method (Python Script, external method,
+          product method, etc).  The method must take no arguments or
+          must obtain its arguments from a query string.
+
+          period -- the number of seconds between each clock "tick" (and
+          thus each call to the above "method").  The lowest number
+          providable here is typically 30 (this is the asyncore mainloop
+          "timeout" value).
+
+          user -- a zope username.
+
+          password -- the password for the zope username provided above.
+
+          host -- the hostname passed in via the "Host:" header in the
+          faux request.  Could be useful if you have virtual host rules
+          set up inside Zope itself.
+
+        To make sure the clock is working, examine your Z2.log file.  It
+        should show requests incoming via a "Zope Clock Server"
+        useragent.
+
       - Added a 'conflict-error-log-level' directive to zope.conf, to set
         the level at which conflict errors (which are normally retried
         automatically) are logged. The default is 'info'.

Added: Zope/branches/chrism-clockserver-merge/lib/python/ZServer/ClockServer.py
===================================================================
--- Zope/branches/chrism-clockserver-merge/lib/python/ZServer/ClockServer.py	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/lib/python/ZServer/ClockServer.py	2005-12-21 15:22:34 UTC (rev 40957)
@@ -0,0 +1,161 @@
+##############################################################################
+#
+# Copyright (c) 2005 Chris McDonough. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+
+""" Zope clock server.  Generate a faux HTTP request on a regular basis
+by coopting the asyncore API. """
+
+import os
+import socket
+import time
+import StringIO
+import asyncore
+
+from ZServer.medusa.http_server import http_request
+from ZServer.medusa.default_handler import unquote
+from ZServer.PubCore import handle
+from ZServer.HTTPResponse import make_response
+from ZPublisher.HTTPRequest import HTTPRequest
+
+def timeslice(period, when=None, t=time.time):
+    if when is None:
+        when =  t()
+    return when - (when % period)
+
+class LogHelper:
+    def __init__(self, logger):
+        self.logger = logger
+
+    def log(self, ip, msg, **kw):
+        self.logger.log(ip + ' ' + msg)
+
+class DummyChannel:
+    # we need this minimal do-almost-nothing channel class to appease medusa
+    addr = ['127.0.0.1']
+    closed = 1
+
+    def __init__(self, server):
+        self.server = server
+        
+    def push_with_producer(self):
+        pass
+
+    def close_when_done(self):
+        pass
+    
+class ClockServer(asyncore.dispatcher):
+    # prototype request environment
+    _ENV = dict(REQUEST_METHOD = 'GET',
+                SERVER_PORT = 'Clock',
+                SERVER_NAME = 'Zope Clock Server',
+                SERVER_SOFTWARE = 'Zope',
+                SERVER_PROTOCOL = 'HTTP/1.0',
+                SCRIPT_NAME = '',
+                GATEWAY_INTERFACE='CGI/1.1',
+                REMOTE_ADDR = '0')
+
+    # required by ZServer
+    SERVER_IDENT = 'Zope Clock' 
+
+    def __init__ (self, method, period=60, user=None, password=None,
+                  host=None, logger=None, handler=None):
+        self.period = period
+        self.method = method
+
+        self.last_slice = timeslice(period)
+
+        h = self.headers = []
+        h.append('User-Agent: Zope Clock Server Client')
+        h.append('Accept: text/html,text/plain')
+        if not host:
+            host = socket.gethostname()
+        h.append('Host: %s' % host)
+        auth = False
+        if user and password:
+            encoded = ('%s:%s' % (user, password)).encode('base64')
+            h.append('Authorization: Basic %s' % encoded)
+            auth = True
+
+        asyncore.dispatcher.__init__(self)
+        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.logger = LogHelper(logger)
+        self.log_info('Clock server for "%s" started (user: %s, period: %s)'
+                      % (method, auth and user or 'Anonymous', self.period))
+        if handler is None:
+            # for unit testing
+            handler = handle
+        self.zhandler = handler
+
+    def get_requests_and_response(self):
+        out = StringIO.StringIO()
+        s_req = '%s %s HTTP/%s' % ('GET', self.method, '1.0')
+        req = http_request(DummyChannel(self), s_req, 'GET', self.method,
+                           '1.0', self.headers)
+        env = self.get_env(req)
+        resp = make_response(req, env)
+        zreq = HTTPRequest(out, env, resp)
+        return req, zreq, resp
+
+    def get_env(self, req):
+        env = self._ENV.copy()
+        (path, params, query, fragment) = req.split_uri()
+        if params:
+            path = path + params # undo medusa bug
+        while path and path[0] == '/':
+            path = path[1:]
+        if '%' in path:
+            path = unquote(path)
+        if query:
+            # ZPublisher doesn't want the leading '?'
+            query = query[1:]
+        env['PATH_INFO']= '/' + path
+        env['PATH_TRANSLATED']= os.path.normpath(
+            os.path.join(os.getcwd(), env['PATH_INFO']))
+        if query:
+            env['QUERY_STRING'] = query
+        env['channel.creation_time']=time.time()
+        for header in req.header:
+            key,value = header.split(":",1)
+            key = key.upper()
+            value = value.strip()
+            key = 'HTTP_%s' % ("_".join(key.split( "-")))
+            if value:
+                env[key]=value
+        return env
+
+    def readable(self):
+        # generate a request at most once every self.period seconds
+        slice = timeslice(self.period)
+        if slice != self.last_slice:
+            # no need for threadsafety here, as we're only ever in one thread
+            self.last_slice = slice
+            req, zreq, resp = self.get_requests_and_response()
+            self.zhandler('Zope2', zreq, resp)
+        return False
+
+    def handle_read(self):
+        return True
+
+    def handle_write (self):
+        self.log_info('unexpected write event', 'warning')
+        return True
+
+    def writable(self):
+        return False
+
+    def handle_error (self):      # don't close the socket on error
+        (file,fun,line), t, v, tbinfo = asyncore.compact_traceback()
+        self.log_info('Problem in Clock (%s:%s %s)' % (t, v, tbinfo),
+                      'error')
+
+
+

Modified: Zope/branches/chrism-clockserver-merge/lib/python/ZServer/component.xml
===================================================================
--- Zope/branches/chrism-clockserver-merge/lib/python/ZServer/component.xml	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/lib/python/ZServer/component.xml	2005-12-21 15:22:34 UTC (rev 40957)
@@ -58,4 +58,42 @@
      <key name="address" datatype="inet-binding-address"/>
   </sectiontype>
 
+  <sectiontype name="clock-server"
+               datatype=".ClockServerFactory"
+               implements="ZServer.server">
+     <key name="method" datatype="string">
+       <description>
+       The traversal path (from the Zope root) to an
+       executable Zope method (Python Script, external method, product
+       method, etc).  The method must take no arguments.  Ex: "/site/methodname"
+       </description>
+     </key>
+     <key name="period" datatype="integer" default="60">
+     <description>
+       The number of seconds between each clock "tick" (and
+       thus each call to the above "method").  The lowest number
+       providable here is typically 30 (this is the asyncore mainloop
+       "timeout" value).  The default is 60.  Ex: "30"
+     </description>
+     </key>
+     <key name="user" datatype="string">
+     <description>
+       A zope username. Ex: "admin"
+     </description>
+     </key>
+     <key name="password" datatype="string">
+     <description>
+      The password for the zope username provided above.  Careful: this
+      is obviously not encrypted in the config file. Ex: "123"
+     </description>
+     </key>
+     <key name="host" datatype="string">
+     <description>
+       The hostname passed in via the "Host:" header in the
+       faux request.  Could be useful if you have virtual host rules
+       set up inside Zope itself. Ex: "www.example.com"
+     </description>
+     </key>
+  </sectiontype>
+
 </component>

Modified: Zope/branches/chrism-clockserver-merge/lib/python/ZServer/datatypes.py
===================================================================
--- Zope/branches/chrism-clockserver-merge/lib/python/ZServer/datatypes.py	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/lib/python/ZServer/datatypes.py	2005-12-21 15:22:34 UTC (rev 40957)
@@ -198,3 +198,20 @@
     def create(self):
         from ZServer.ICPServer import ICPServer
         return ICPServer(self.ip, self.port)
+
+class ClockServerFactory(ServerFactory):
+    def __init__(self, section):
+        ServerFactory.__init__(self)
+        self.method = section.method
+        self.period = section.period
+        self.user = section.user
+        self.password = section.password
+        self.hostheader = section.host
+        self.host = None # appease configuration machinery
+
+    def create(self):
+        from ZServer.ClockServer import ClockServer
+        from ZServer.AccessLogger import access_logger
+        return ClockServer(self.method, self.period, self.user,
+                           self.password, self.hostheader, access_logger)
+    

Added: Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_clockserver.py
===================================================================
--- Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_clockserver.py	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_clockserver.py	2005-12-21 15:22:34 UTC (rev 40957)
@@ -0,0 +1,161 @@
+import unittest
+import time
+from StringIO import StringIO
+
+from ZServer import ClockServer
+
+class DummyLogger:
+    def __init__(self):
+        self.L = []
+        
+    def log(self, *arg, **kw):
+        self.L.extend(arg)
+
+    def read(self):
+        return ' '.join(self.L)
+
+class LogHelperTests(unittest.TestCase):
+    def _getTargetClass(self):
+        return ClockServer.LogHelper
+
+    def _makeOne(self, *arg, **kw):
+        return self._getTargetClass()(*arg, **kw)
+
+    def test_helper(self):
+        from StringIO import StringIO
+        logger = DummyLogger()
+        helper = self._makeOne(logger)
+        self.assertEqual(helper.logger, logger)
+        logger.log('ip', 'msg', foo=1, bar=2)
+        self.assertEqual(logger.read(), 'ip msg')
+
+class ClockServerTests(unittest.TestCase):
+    def _getTargetClass(self):
+        return ClockServer.ClockServer
+
+    def _makeOne(self, *arg, **kw):
+        return self._getTargetClass()(*arg, **kw)
+
+    def test_ctor(self):
+        logger = DummyLogger()
+        server = self._makeOne(method='a', period=60, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger)
+        auth = 'charlie:brown'.encode('base64')
+        self.assertEqual(server.headers,
+                         ['User-Agent: Zope Clock Server Client',
+                          'Accept: text/html,text/plain',
+                          'Host: localhost',
+                          'Authorization: Basic %s' % auth])
+                          
+    def test_get_requests_and_response(self):
+        logger = DummyLogger()
+        server = self._makeOne(method='a', period=60, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger)
+        req, zreq, resp = server.get_requests_and_response()
+
+        from ZServer.medusa.http_server import http_request
+        from ZServer.HTTPResponse import HTTPResponse
+        from ZPublisher.HTTPRequest import HTTPRequest
+        self.failUnless(isinstance(req, http_request))
+        self.failUnless(isinstance(resp, HTTPResponse))
+        self.failUnless(isinstance(zreq, HTTPRequest))
+
+    def test_get_env(self):
+        logger = DummyLogger()
+        server = self._makeOne(method='a', period=60, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger)
+        class dummy_request:
+            def split_uri(self):
+                return '/a%20', '/b', '?foo=bar', ''
+
+            header = ['BAR:baz']
+        env = server.get_env(dummy_request())
+        _ENV = dict(REQUEST_METHOD = 'GET',
+                    SERVER_PORT = 'Clock',
+                    SERVER_NAME = 'Zope Clock Server',
+                    SERVER_SOFTWARE = 'Zope',
+                    SERVER_PROTOCOL = 'HTTP/1.0',
+                    SCRIPT_NAME = '',
+                    GATEWAY_INTERFACE='CGI/1.1',
+                    REMOTE_ADDR = '0')
+        for k, v in _ENV.items():
+            self.assertEqual(env[k], v)
+        self.assertEqual(env['PATH_INFO'], '/a /b')
+        self.assertEqual(env['PATH_TRANSLATED'], '/a /b')
+        self.assertEqual(env['QUERY_STRING'], 'foo=bar')
+        self.assert_(env['channel.creation_time'])
+
+    def test_handle_write(self):
+        logger = DummyLogger()
+        server = self._makeOne(method='a', period=60, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger)
+        self.assertEqual(server.handle_write(), True)
+
+    def test_handle_error(self):
+        logger = DummyLogger()
+        server = self._makeOne(method='a', period=60, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger)
+        self.assertRaises(AssertionError, server.handle_error)
+
+    def test_readable(self):
+        logger = DummyLogger()
+        class DummyHandler:
+            def __init__(self):
+                self.arg = []
+            def __call__(self, *arg):
+                self.arg = arg
+        handler = DummyHandler()
+        server = self._makeOne(method='a', period=1, user='charlie',
+                               password='brown', host='localhost',
+                               logger=logger, handler=handler)
+        self.assertEqual(server.readable(), False)
+        self.assertEqual(handler.arg, [])
+        time.sleep(1.1) # allow timeslice to switch
+        self.assertEqual(server.readable(), False)
+        self.assertEqual(handler.arg[0], 'Zope')
+        from ZServer.HTTPResponse import HTTPResponse
+        from ZPublisher.HTTPRequest import HTTPRequest
+        self.assert_(isinstance(handler.arg[1], HTTPRequest))
+        self.assert_(isinstance(handler.arg[2], HTTPResponse))
+
+    def test_timeslice(self):
+        from ZServer.ClockServer import timeslice
+        aslice = timeslice(3, 6)
+        self.assertEqual(aslice, 6)
+        aslice = timeslice(3, 7)
+        self.assertEqual(aslice, 6)
+        aslice = timeslice(3, 8)
+        self.assertEqual(aslice, 6)
+        aslice = timeslice(3, 9)
+        self.assertEqual(aslice, 9)
+        aslice = timeslice(3, 10)
+        self.assertEqual(aslice, 9)
+        aslice = timeslice(3, 11)
+        self.assertEqual(aslice, 9)
+        aslice = timeslice(3, 12)
+        self.assertEqual(aslice, 12)
+        aslice = timeslice(3, 13)
+        self.assertEqual(aslice, 12)
+        aslice = timeslice(3, 14)
+        self.assertEqual(aslice, 12)
+        aslice = timeslice(3, 15)
+        self.assertEqual(aslice, 15)
+        aslice = timeslice(3, 16)
+        self.assertEqual(aslice, 15)
+        aslice = timeslice(3, 17)
+        self.assertEqual(aslice, 15)
+        aslice = timeslice(3, 18)
+        self.assertEqual(aslice, 18)
+
+def test_suite():
+    suite = unittest.makeSuite(ClockServerTests)
+    suite.addTest(unittest.makeSuite(LogHelperTests))
+    return suite
+
+if __name__ == "__main__":
+    unittest.main(defaultTest="test_suite")

Modified: Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_config.py
===================================================================
--- Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_config.py	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/lib/python/ZServer/tests/test_config.py	2005-12-21 15:22:34 UTC (rev 40957)
@@ -61,8 +61,8 @@
         self.assertEqual(factory.module, "module")
         self.assertEqual(factory.cgienv.items(), [("key", "value")])
         if port is None:
-            self.assert_(factory.host is None)
-            self.assert_(factory.port is None)
+            self.assert_(factory.host is None, factory.host)
+            self.assert_(factory.port is None, factory.port)
         else:
             self.assertEqual(factory.host, expected_factory_host)
             self.assertEqual(factory.port, 9300 + port)
@@ -226,7 +226,27 @@
         self.check_prepare(factory)
         factory.create().close()
 
+    def test_clockserver_factory(self):
+        factory = self.load_factory("""\
+            <clock-server>
+              method /foo/bar
+              period 30
+              user chrism
+              password 123
+              host www.example.com
+            </clock-server>
+            """)
+        self.assert_(isinstance(factory,
+                                ZServer.datatypes.ClockServerFactory))
+        self.assertEqual(factory.method, '/foo/bar')
+        self.assertEqual(factory.period, 30)
+        self.assertEqual(factory.user, 'chrism')
+        self.assertEqual(factory.password, '123')
+        self.assertEqual(factory.hostheader, 'www.example.com')
+        self.check_prepare(factory)
+        factory.create().close()
 
+
 class MonitorServerConfigurationTestCase(BaseTest):
 
     def setUp(self):

Modified: Zope/branches/chrism-clockserver-merge/skel/etc/zope.conf.in
===================================================================
--- Zope/branches/chrism-clockserver-merge/skel/etc/zope.conf.in	2005-12-21 15:12:00 UTC (rev 40956)
+++ Zope/branches/chrism-clockserver-merge/skel/etc/zope.conf.in	2005-12-21 15:22:34 UTC (rev 40957)
@@ -882,10 +882,10 @@
 #
 # Description:
 #     A set of sections which allow the specification of Zope's various
-#     ZServer servers.  7 different server types may be defined:
+#     ZServer servers.  8 different server types may be defined:
 #     http-server, ftp-server, webdav-source-server, persistent-cgi,
-#     fast-cgi, monitor-server, and icp-server.  If no servers are
-#     defined, the default servers are used.
+#     fast-cgi, monitor-server, icp-server, and clock-server.  If no servers
+#     are defined, the default servers are used.
 #
 #     Ports may be specified using the 'address' directive either in simple
 #     form (80) or in complex form including hostname 127.0.0.1:80.  If the
@@ -939,6 +939,14 @@
 #    # valid key is "address"
 #    address 888
 #  </icp-server>
+#
+#  <clock-server>
+#    # starts a clock which calls /foo/bar every 30 seconds
+#    method /foo/bar
+#    period 30
+#    user admin
+#    password 123
+#  </clock-server>
 
 
 # Database (zodb_db) section



More information about the Zope-Checkins mailing list