[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