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 _______________________________________________ Zope-Checkins maillist - Zope-Checkins@zope.org http://mail.zope.org/mailman/listinfo/zope-checkins