diff options
28 files changed, 1565 insertions, 0 deletions
diff --git a/examples/todo_wareweb/__init__.py b/examples/todo_wareweb/__init__.py new file mode 100644 index 0000000..0454673 --- /dev/null +++ b/examples/todo_wareweb/__init__.py @@ -0,0 +1,5 @@ +from paste.util.thirdparty import add_package + +add_package('ZopePageTemplates') +add_package('Component') +add_package('ZPTKit') diff --git a/examples/todo_wareweb/db.py b/examples/todo_wareweb/db.py new file mode 100644 index 0000000..174f0f5 --- /dev/null +++ b/examples/todo_wareweb/db.py @@ -0,0 +1,16 @@ +from sqlobject import * + +class TodoList(SQLObject): + + description = StringCol(notNull=True) + items = MultipleJoin('TodoItem') + +class TodoItem(SQLObject): + + todo_list = ForeignKey('TodoList') + description = StringCol(notNull=True) + done = BoolCol(notNull=True, default=False) + +def check_db(): + for soClass in (TodoList, TodoItem): + soClass.createTable(ifNotExists=True) diff --git a/examples/todo_wareweb/server.conf b/examples/todo_wareweb/server.conf new file mode 100644 index 0000000..70d1b49 --- /dev/null +++ b/examples/todo_wareweb/server.conf @@ -0,0 +1,24 @@ +# -*- python -*- Note: this file is in Python syntax + +import os + +app_name = 'todo_wareweb' +publish_dir = os.path.join(os.path.dirname(__file__), 'web') +sys_path = [os.path.dirname(os.path.dirname(__file__))] + +database = 'sqlite:%s/data.db' % os.path.dirname(__file__) +root_path = os.path.dirname(__file__) + +## Server options: + +verbose = True +# The name of the server-type to start: +server = 'wsgiutils' +# If true, files will be regularly polled and the server restarted +# if files are modified: +reload = True +# If true, tracebacks will be shown in the browser: +debug = True +lint = True +session_type = 'memory' +show_exceptions_in_error_log = False diff --git a/examples/todo_wareweb/sitepage.py b/examples/todo_wareweb/sitepage.py new file mode 100644 index 0000000..52af53c --- /dev/null +++ b/examples/todo_wareweb/sitepage.py @@ -0,0 +1,14 @@ +from paste.wareweb import Servlet, Notify +from ZPTKit.zptwareweb import ZPTComponent + +class SitePage(Servlet): + + zpt = ZPTComponent() + message = Notify() + + def awake(self): + self.app_static_url = self.app_url + '/static' + super(SitePage, self).awake() + + def title__get(self): + return self.options.get('title', super(SitePage, self).title) diff --git a/examples/todo_wareweb/templates/edit_list.pt b/examples/todo_wareweb/templates/edit_list.pt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/todo_wareweb/templates/edit_list.pt diff --git a/examples/todo_wareweb/templates/generic_error.pt b/examples/todo_wareweb/templates/generic_error.pt new file mode 100644 index 0000000..7b32820 --- /dev/null +++ b/examples/todo_wareweb/templates/generic_error.pt @@ -0,0 +1,7 @@ +<html metal:use-macro="here/standard_template.pt/macros/page"> +<metal:body fill-slot="body"> + +<tal:block replace="structure options/error_message" /> + +</metal:body> +</html>
\ No newline at end of file diff --git a/examples/todo_wareweb/templates/index.pt b/examples/todo_wareweb/templates/index.pt new file mode 100644 index 0000000..704fca8 --- /dev/null +++ b/examples/todo_wareweb/templates/index.pt @@ -0,0 +1,22 @@ +<html metal:use-macro="here/standard_template.pt/macros/page"> +<metal:body fill-slot="body"> + +<ul> + <li tal:repeat="list options/lists"> + <a tal:attributes="href python: '%s/view_list?id=%s' % (servlet.app_url, list.id)" + tal:content="list/description">list</a></li> +</ul> + +<p tal:condition="not: options/lists"> + There are no lists to display +</p> + +<form action="./edit_list" method="POST"> + Create a new list:<br> + <input type="hidden" name="id" value="new"> + <input type="hidden" name="_action_" value="save"> + <input type="text" name="description"> + <input type="submit" value="Create!!!!"> +</form> +</metal:body> +</html> diff --git a/examples/todo_wareweb/templates/standard_template.pt b/examples/todo_wareweb/templates/standard_template.pt new file mode 100644 index 0000000..0abe2dd --- /dev/null +++ b/examples/todo_wareweb/templates/standard_template.pt @@ -0,0 +1,31 @@ +<metal:tpl define-macro="page"> +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" + "http://www.w3.org/TR/REC-html40/loose.dtd"> +<html lang="en-US"> +<head> +<title tal:content="servlet/title">title</title> +<link rel="stylesheet" type="text/css" + tal:attributes="href python: servlet.app_static_url + '/stylesheet.css'"> + +<metal:slot define-slot="extra_head"></metal:slot> + +</head> +<body> + +<h1><tal:mark replace="servlet/title"/></h1> + +<div class="notifyMessage" + tal:condition="servlet/message" + tal:content="structure servlet/message/html"> + [This is where the notification messages go] +</div> + +<div id="content"> +<metal:tpl define-slot="body"> +[This page has not customized its "body" slot] +</metal:tpl> +</div> + +</body> +</html> +</metal:tpl> diff --git a/examples/todo_wareweb/templates/view_list.pt b/examples/todo_wareweb/templates/view_list.pt new file mode 100644 index 0000000..5ba99c8 --- /dev/null +++ b/examples/todo_wareweb/templates/view_list.pt @@ -0,0 +1,57 @@ +<html metal:use-macro="here/standard_template.pt/macros/page"> +<metal:body fill-slot="body"> + +<form action="view_list?_action_=check" method="POST"> +<input type="hidden" name="id" + tal:attributes="value options/list/id"> + +<table> +<tr> + <th>Done?</th> + <th>Description</th> +</tr> + +<tr tal:repeat="item options/list_items"> + <tal:let define="name python: 'item_%s' % item.id"> + <td> + <input type="checkbox" + tal:attributes="name name; + id name; + checked item/done"> + </td> + <td> + <label tal:attributes="for name" + tal:content="item/description">description</label> + </td> + <td><a tal:attributes=" + href python: 'view_list?_action_=destroy&id=%s&item_id=%s' + % (options['list'].id, item.id)">[delete]</a></td> + </tal:let> +</tr> + +<tr tal:condition="not: options/list_items"> + <td colspan=2>No items to display</td> +</tr> + +</table> + +<input type="submit" value="Update item status"> +</form> + +<form action="view_list?_action_=add" method="POST"> +<input type="hidden" name="id" + tal:attributes="value options/list/id"> +Add item:<br> +<input type="text" style="width: 100%" name="description"><br> +<input type="submit" value="Add"> +</form> + +<form action="edit_list?_action_=destroy" method="POST"> +<input type="hidden" name="id" + tal:attributes="value options/list/id"> +<input type="submit" value="Delete this list" + onclick="return window.confirm('Really delete this list?')"> +</form> + +</metal:body> +</html> diff --git a/examples/todo_wareweb/web/__init__.py b/examples/todo_wareweb/web/__init__.py new file mode 100644 index 0000000..0fca385 --- /dev/null +++ b/examples/todo_wareweb/web/__init__.py @@ -0,0 +1,29 @@ +from paste.util import thirdparty +thirdparty.add_package('sqlobject') +import sqlobject +from todo_wareweb import db + +conns = {} + +def urlparser_hook(environ): + if not environ.has_key('todo_wareweb.base_url'): + environ['todo_wareweb.base_url'] = environ['SCRIPT_NAME'] + +def urlparser_wrap(environ, start_response, app): + db_uri = environ['paste.config']['database'] + first_conn = False + if db_uri in conns: + conn = conns[db_uri] + else: + first_conn = True + conns[db_uri] = conn = sqlobject.connectionForURI(db_uri) + sqlobject.sqlhub.threadConnection = conn + if first_conn: + db.check_db() + # @@: This doesn't allow for using database connections during + # the application iterator + try: + return app(environ, start_response) + finally: + sqlobject.sqlhub.threadConnection = None + diff --git a/examples/todo_wareweb/web/edit_list.py b/examples/todo_wareweb/web/edit_list.py new file mode 100644 index 0000000..0b79339 --- /dev/null +++ b/examples/todo_wareweb/web/edit_list.py @@ -0,0 +1,31 @@ +from todo_wareweb.sitepage import SitePage +from todo_wareweb.db import * +from paste.wareweb import ActionDispatch, public + +class edit_list(SitePage): + + dispatch = ActionDispatch() + + def setup(self): + self.list_id = self.fields.id + if self.list_id != 'new': + self.list = TodoList.get(int(self.list_id)) + self.options.title = 'Edit list ' + + @public + def save(self): + desc = self.fields.description + if self.list_id == 'new': + self.list = TodoList(description=desc) + self.message.write('List created') + else: + self.list.description = desc + self.message.write('List updated') + self.redirect('view_list', id=self.list.id) + + @public + def destroy(self): + desc = self.list.description + self.list.destroySelf() + self.message.write('List %s deleted' % desc) + self.redirect('./') diff --git a/examples/todo_wareweb/web/index.py b/examples/todo_wareweb/web/index.py new file mode 100644 index 0000000..b5cbaf1 --- /dev/null +++ b/examples/todo_wareweb/web/index.py @@ -0,0 +1,9 @@ +from todo_wareweb.sitepage import SitePage +from todo_wareweb.db import * + +class index(SitePage): + + def setup(self): + self.options.title = 'List of Lists' + self.options.lists = list(TodoList.select()) + diff --git a/examples/todo_wareweb/web/static/stylesheet.css b/examples/todo_wareweb/web/static/stylesheet.css new file mode 100644 index 0000000..d70e9ec --- /dev/null +++ b/examples/todo_wareweb/web/static/stylesheet.css @@ -0,0 +1,13 @@ +body { + font-family: Helvetica, Arial, sans-serif; +} + +pre { + overflow: auto; +} + +div.notifyMessage { + border: 2px solid black; + background-color: #007700; + color: #ffffff; +} diff --git a/examples/todo_wareweb/web/view_list.py b/examples/todo_wareweb/web/view_list.py new file mode 100644 index 0000000..99750c2 --- /dev/null +++ b/examples/todo_wareweb/web/view_list.py @@ -0,0 +1,51 @@ +from todo_wareweb.sitepage import SitePage +from todo_wareweb.db import * +from paste.wareweb import ActionDispatch, public + +class view_list(SitePage): + + dispatch = ActionDispatch() + + def setup(self): + self.options.list = TodoList.get(int(self.fields.id)) + self.options.list_items = list(self.options.list.items) + self.options.title = 'List: %s' % self.options.list.description + + @public + def check(self): + for item in self.options.list.items: + checked = self.fields.get('item_%s' % item.id, False) + if not checked and item.done: + self.message.write('Item %s marked not done' + % item.description) + item.done = False + if checked and not item.done: + self.message.write('Item %s marked done' + % item.description) + item.done = True + self.redirect('view_list', id=self.options.list.id) + + @public + def add(self): + desc = self.fields.description + if not desc: + self.message.write('You must give a description') + else: + TodoItem(todo_list=self.options.list, + description=desc) + self.message.write('Item added') + self.redirect( + 'view_list', id=self.options.list.id) + + @public + def destroy(self): + id = int(self.fields.item_id) + item = TodoItem.get(id) + assert item.todo_list.id == self.options.list.id, ( + "You are trying to delete %s, which does not " + "belong to the list %s" % (item, self.options.list)) + desc = item.description + item.destroySelf() + self.message.write("Item %s removed" % desc) + self.redirect('view_list', id=self.options.list.id) + diff --git a/paste/wareweb/__init__.py b/paste/wareweb/__init__.py new file mode 100644 index 0000000..d82d368 --- /dev/null +++ b/paste/wareweb/__init__.py @@ -0,0 +1,5 @@ +from servlet import * +from unpack import * +from dispatch import * +from notify import * + diff --git a/paste/wareweb/cgifields.py b/paste/wareweb/cgifields.py new file mode 100644 index 0000000..fc7188f --- /dev/null +++ b/paste/wareweb/cgifields.py @@ -0,0 +1,53 @@ +import cgi +from UserDict import UserDict + +def parse_fields(environ): + fs = cgi.FieldStorage( + environ['wsgi.input'], + environ=environ, + keep_blank_values=True, + strict_parsing=False) + try: + keys = fs.keys() + except TypeError: + # Maybe an XML-RPC request + keys = [] + d = {} + for key in keys: + value = fs[key] + if not isinstance(value, list): + if not value.filename: + # Turn the MiniFieldStorage into a string: + value = value.value + else: + value = [v.value for v in value] + d[key] = value + if environ['REQUEST_METHOD'].upper() == 'POST': + # Then we must also parse GET variables + getfields = cgi.parse_qs( + environ.get('QUERY_STRING', ''), + keep_blank_values=True, + strict_parsing=False) + for name, value in getfields.items(): + if not d.has_key(name): + if isinstance(value, list) and len(value) == 1: + # parse_qs always returns a list of lists, + # while FieldStorage only uses lists for + # keys that actually repeat; this fixes that. + value = value[0] + d[name] = value + return d + +class Fields(UserDict): + + def __init__(self, field_dict): + self.data = field_dict + + def __getattr__(self, attr): + return self.data.get(attr) + + def getlist(self, name): + v = self.data.get(name, []) + if isinstance(v, list): + return v + return [v] diff --git a/paste/wareweb/classinit.py b/paste/wareweb/classinit.py new file mode 100644 index 0000000..137f0d5 --- /dev/null +++ b/paste/wareweb/classinit.py @@ -0,0 +1,75 @@ +class classinstancemethod(object): + """ + Acts like a class method when called from a class, like an + instance method when called by an instance. The method should + take two arguments, 'self' and 'cls'; one of these will be None + depending on how the method was called. + """ + + def __init__(self, func): + self.func = func + + def __get__(self, obj, type=None): + return _methodwrapper(self.func, obj=obj, type=type) + +class _methodwrapper(object): + + def __init__(self, func, obj, type): + self.func = func + self.obj = obj + self.type = type + + def __call__(self, *args, **kw): + assert not kw.has_key('self') and not kw.has_key('cls'), ( + "You cannot use 'self' or 'cls' arguments to a " + "classinstancemethod") + return self.func(*((self.obj, self.type) + args), **kw) + + def __repr__(self): + if self.obj is None: + return ('<bound class method %s.%s>' + % (self.type.__name__, self.func.func_name)) + else: + return ('<bound method %s.%s of %r>' + % (self.type.__name__, self.func.func_name, self.obj)) + + +class ClassInitMeta(type): + + def __new__(meta, class_name, bases, new_attrs): + cls = type.__new__(meta, class_name, bases, new_attrs) + if (new_attrs.has_key('__classinit__') + and not isinstance(cls.__classinit__, staticmethod)): + setattr(cls, '__classinit__', + staticmethod(cls.__classinit__.im_func)) + if hasattr(cls, '__classinit__'): + cls.__classinit__(cls, new_attrs) + return cls + +def build_properties(cls, new_attrs): + """ + Given a class and a new set of attributes (as passed in by + __classinit__), create or modify properties based on functions + with special names ending in __get, __set, and __del. + """ + for name, value in new_attrs.items(): + if (name.endswith('__get') or name.endswith('__set') + or name.endswith('__del')): + base = name[:-5] + if hasattr(cls, base): + old_prop = getattr(cls, base) + if not isinstance(old_prop, property): + raise ValueError( + "Attribute %s is a %s, not a property; function %s is named like a property" + % (base, type(old_prop), name)) + attrs = {'fget': old_prop.fget, + 'fset': old_prop.fset, + 'fdel': old_prop.fdel, + 'doc': old_prop.__doc__} + else: + attrs = {} + attrs['f' + name[-3:]] = value + if name.endswith('__get') and value.__doc__: + attrs['doc'] = value.__doc__ + new_prop = property(**attrs) + setattr(cls, base, new_prop) diff --git a/paste/wareweb/cookie.py b/paste/wareweb/cookie.py new file mode 100644 index 0000000..a1a2625 --- /dev/null +++ b/paste/wareweb/cookie.py @@ -0,0 +1,28 @@ +import timeinterval + +class Cookie(object): + + def __init__(self, name, value, path, expires=None, secure=False): + self.name = name + self.value = value + self.path = path + self.secure = secure + if expires == 'ONCLOSE' or not expires: + expires = None + elif expires == 'NOW' or expires == 'NEVER': + expires = time.gmtime(time.time()) + if expires == 'NEVER': + expires = (expires[0] + 10,) + expires[1:] + expires = time.strftime("%a, %d-%b-%Y %H:%M:%S GMT", expires) + else: + if isinstance(expires, (str, unicode)) and expires.startswith('+'): + interval = timeinterval.time_decode(expires[1:]) + expires = time.time() + interval + if isinstance(expires, (int, long, float)): + expires = time.gmtime(expires) + if isinstance(expires, (tuple, time.struct_time)): + expires = time.strftime("%a, %d-%b-%Y %H:%M:%S GMT", expires) + self.expires = expires + + def __str__(self): + raise NotImplementedError diff --git a/paste/wareweb/dispatch.py b/paste/wareweb/dispatch.py new file mode 100644 index 0000000..8cfc6ec --- /dev/null +++ b/paste/wareweb/dispatch.py @@ -0,0 +1,91 @@ +from paste import httpexceptions +import event + +__all__ = ['public', 'ActionDispatch', 'PathDispatch'] + +def public(func): + func.public = True + return func + +class MethodDispatch(object): + + prefix = None + + def __addtoclass__(self, attr, cls): + cls.listeners.append(self.respond_event) + + def respond_event(self, name, servlet, *args, **kw): + if name == 'end_awake': + return self.find_method(servlet, *args, **kw) + return event.Continue + + def get_method(self, servlet, action): + try: + return action, getattr(servlet, action) + except AttributeError: + pass + if self.prefix: + try: + return (self.prefix + action, + getattr(servlet, self.prefix + action)) + except AttributeError: + pass + return None, None + + def valid_method(self, name, method): + if getattr(method, 'public', False): + return True + if self.prefix and name.startswith(self.prefix): + return True + return False + +class ActionDispatch(MethodDispatch): + + prefix = 'action_' + + def __init__(self, action_name='_action_'): + self.action_name = action_name + + def find_method(self, servlet, ret_value): + possible_actions = [] + for name, value in servlet.fields.items(): + if name == self.action_name: + possible_actions.append(value) + elif name.startswith(self.action_name): + possible_actions.append(name[len(self.action_name):]) + if not possible_actions: + return event.Continue + if len(possible_actions) > 1: + raise httpexceptions.HTTPBadRequest( + "More than one action received: %s" + % ', '.join(map(repr, possible_actions))) + action = possible_actions[0] + name, method = self.get_method(servlet, action) + if name is None: + raise httpexceptions.Forbidden( + "Action method not found: %r" % action) + if not self.valid_method(name, method): + raise httpexceptions.Forbidden( + "Method not allowed: %r" % action) + return method() + +class PathDispatch(MethodDispatch): + + prefix = 'path_' + + def find_method(self, servlet, ret_value): + parts = servlet.path_parts + if not parts: + action == 'index' + else: + action == parts[0] + servlet.path_parts = parts[1:] + name, method = self.get_method(servlet, action) + if name is None: + raise httpexceptions.Forbidden( + "Method not found: %r" % action) + if not self.valid_method(name, method): + raise httpexceptions.Forbidden( + "Method not allowed: %r" % action) + return method() + diff --git a/paste/wareweb/docs/wareweb.txt b/paste/wareweb/docs/wareweb.txt new file mode 100644 index 0000000..ba554fd --- /dev/null +++ b/paste/wareweb/docs/wareweb.txt @@ -0,0 +1,149 @@ +Wareweb +======= + +.. contents:: + +Introduction +------------ + +Wareweb is a rethinking of the ideas behind `Webware +<http://webwareforpython.org>`_. Webware is a fairly old framework, +written back in the days of Python 1.5.2, and some of the API +decisions behind it now show their age. Also, many of the +abstractions in Webware didn't turn out to be useful, while some other +abstractions are missing. + +Wareweb tries to keep the basic model of Webware, while simplifying +many portions and adding some new techniques. It's not backward +compatible, but it doesn't require major revamping to apply to a +Webware application. + +Wareweb also requires Python 2.4; as long as we're updating the API, I +want to make it as modern as possible. Decorators in particular are +widely used in the framework. + +Interface +--------- + +See ``paste/wareweb/interfaces.py`` for a description of the +``IServlet`` interface. + +Components and Events +--------------------- + +There are a couple fairly simple metaprogramming techniques added to +``Servlet`` to make it easier to add functionality. + +Active Attributes +~~~~~~~~~~~~~~~~~ + +When you create a class, attributes can be "active". That is, simply +assigning the attribute can cause changes to the class. + +This is implemented by looking for a special method ``__addtoclass__`` +in all new attributes. The dispatchers, for instance, add themselves +as "event listeners," so that simply by putting a dispatcher in your +class definition somewhere you'll have changed the functionality of +the class. + +Events +~~~~~~ + +Many methods throw events (with ``paste.wareweb.event.raise_event``). +An event is sent to each listener on the object, and the handler can +do nothing (returning ``event.Continue``) or can override or respond +to the event in some way. + +Dispatching objects listen for the ``end_awake`` event (called at the +end of ``awake()``), and may call an extra method on the servlet based +on some criteria (like ``_awake_`` or ``path_info``). + +Templating systems can use an event to write a template. ZPTKit +overrides ``start_respond`` to write the template. + +Unpacking Methods +~~~~~~~~~~~~~~~~~ + +On a per-method basis you can use the algorithms in +``paste.wareweb.unpack`` to unpack your request. E.g., something +like: + + dispatch = MethodDispatch() + + @public + @unpack + def edit_item(self, item_id_int_path, name): + Item.get(id=item_id_int_path).name = name + +This unpacks a request like ``servlet_name/edit_item/1?name=new_name`` +into the function call. Note that ``MethodDispatch`` makes +``/edit_item`` call the ``edit_item()`` method. ``@public`` declares +that the method is actually available to the public. ``@unpack`` uses +the method signature to identify the parts -- ``item_id_int_path`` +ends with ``_path``, and so will be taken from ``path_info``; and has +``_int`` and so will be coerced into an integer. + +Automatic Properties +~~~~~~~~~~~~~~~~~~~~ + +Methods that end with ``__get``, ``__set`` and ``__del`` will +automatically be turned into properties. Subclasses can selectively +replace just one of these implementations, while leaving the others in +place. + +Differences from Webware +------------------------ + +This is a rough summary of the differences from Webware. There's lots +of little things that are just plain gone; this doesn't enumerate all +of them. + +No Request And Response +~~~~~~~~~~~~~~~~~~~~~~~ + +There are no longer request and response objects. There is only the +servlet. The servlet contains all the useful methods from these +objects. + +From the response, several methods: + +* set_cookie +* set_header +* add_header +* write +* redirect + +From the request, several attributes (note: no methods): + +* environ +* config +* request_method +* app_url +* path_info +* path_parts +* fields + +For more exact details, see ``paste.wareweb.interfaces.IServlet`` + +Only One Servlet Class +~~~~~~~~~~~~~~~~~~~~~~ + +There's one ``Servlet`` for everyone -- no ``Servlet``, +``HTTPServlet``, and ``Page``. All servlets instances are *not* +threadsafe, because they contain information about the request and +response in their instance variables. In practice no Webware servlets +were threadsafe, nor was there anything to be gained by that. + +Also, reusable functionality is not meant to be added with subclasses +of ``Servlet``. Subclassing is for the end-developer to add +application-specific functionality; there's other techniques for +adding reusable functionality. + +No _action_ by default +~~~~~~~~~~~~~~~~~~~~~~ + +URL dispatching is handled more generically; several dispatchers are +available, one which implements the ``_action_` parsing of Webware. +Dispatchers for XML-RPC and JSON-RPC are also possible, and they have +their own built-in methods of indicating methods. Other forms of URL +parsing are also implemented. diff --git a/paste/wareweb/event.py b/paste/wareweb/event.py new file mode 100644 index 0000000..5376d63 --- /dev/null +++ b/paste/wareweb/event.py @@ -0,0 +1,34 @@ +class Continue: + """ + This class is a singleton (never meant to be instantiated) that + represents a kind of no-op return from a class. + """ + def __init__(self): + assert False, ( + "Continue cannot be instantiated (use the class object " + "itself)") + +def wrap_func(func): + name = func.__name__ + def replacement_func(actor, *args, **kw): + # @@: It's really more of an "inner_value" than "next_method" + kw['next_method'] = func + result = raise_event('start_%s' % name, actor, *args, **kw) + del kw['next_method'] + if result is Continue: + value = func(actor, *args, **kw) + else: + return result + result = raise_event('end_%s' % name, actor, value, *args, **kw) + if result is Continue: + return value + return result + replacement_func.__name__ = name + return replacement_func + +def raise_event(name, actor, *args, **kw): + for listener in actor.listeners: + value = listener(name, actor, *args, **kw) + if value is not Continue: + return value + return Continue diff --git a/paste/wareweb/interfaces.py b/paste/wareweb/interfaces.py new file mode 100644 index 0000000..9cd39b5 --- /dev/null +++ b/paste/wareweb/interfaces.py @@ -0,0 +1,196 @@ +""" +Descriptions of the main interfaces in Wareweb. These aren't formal +interfaces, they are just here for documentation purposes. +""" + +class IServlet: + + """ + Response method sequence + ------------------------ + + These methods are called roughly in order to produce the response. + """ + + def __call__(environ, start_response): + """ + Implementation of the WSGI application interface. Instances + of IServlet are WSGI applications. + + Calls event ``call`` with ``environ`` and ``start_response``, + which may return (status_headers, app_iter) and thus abort the + rest of the call. + """ + + def run(): + """ + 'Runs' the request. This typically just calls ``awake()``, + ``respond()`` and ``sleep()``. Error handlers could be added + here by subclasses. + + Wrapped as event (wrapped methods call ``start_method_name`` + and ``end_method_name``). + """ + + def awake(call_setup=True): + """ + Called at beginning of request. SHOULD NOT produce output. + If ``call_setup`` is true then ``.setup()`` will be called. + ``SitePage`` classes (that is, abstract subclasses of + ``Servlet``) should override this method and not ``setup``. + + Wrapped as event. + """ + + def setup(): + """ + Called at beginning of requests. Subclasses do not need to + call the superclass implementation. This is where individual + (non-abstract) servlets typically do their setup. + """ + + def respond(): + """ + Called after ``.awake()``, this typically produces the body of + the response. Some components may intercept this method + (e.g., components that want to take over the body of the + response, like templating components). + + Wrapped as event. + """ + + def sleep(call_teardown=True): + """ + Called at the end of a request, to clean up resources. Called + regardless of exceptions in ``.awake()`` or ``.respond()``, so + implementations should be careful not to assume all resources + have been successfully set up. Like ``awake`` this calls + ``.teardown()`` if ``call_teardown`` is true; abstract classes + should override this method and not ``teardown``. + + Wrapped as event. + """ + + def teardown(): + """ + Resource cleanup at the end of request. Subclasses do not + need to call the superclass implementation. + """ + + """ + Request Attributes + ------------------ + + These attributes describe the request + """ + + environ = """ + The WSGI environment. Contains typical CGI variables, in addition + to any WSGI extensions. + """ + + config = """ + The Paste configuration object; a dictionary-like object. + """ + + app_url = """ + The URL (usually not fully qualified) of this application. This + looks in the environmental variable ``<app_name>.base_url``. + The default ``app_name`` is ``"app"``, and this requires a + ``urlparser_hook`` in ``__init__.py`` to set accurately, which + would look like:: + + app_name = 'app' + def urlparse_hook(environ): + key = '%s.base_url' % app_name + if not key in environ: + environ[key] = environ['SCRIPT_NAME'] + """ + + path_info = """ + The value of environ['PATH_INFO'] -- all the URL that comes after + this servlet's location. + """ + + path_parts = """ + A list of path parts; essentially ``path_info.split('/')`` + """ + + fields = """ + A dictionary-like object of all the request fields (both GET and + POST variables, folded together). + + You can also get variables as attributes (which default to None). + + Also has a ``.getlist(name)`` method, which returns the variable + as a list (i.e., if missing the return value is ``[]``; if one + string value the return value is ``[value]``) + """ + + """ + Response Methods + ---------------- + + These methods are used to create the response + """ + + title = """ + Attribute that returns the title of this page. Default + implementation returns the class's name. + """ + + session = """ + The session object. This is a dictionary-like object where values + are persisted through multiple requests (using a cookie). + """ + + def set_cookie(cookie_name, value, path='/', expires='ONCLOSE', + secure=False): + """ + Creates the named cookie in the response. + + ``expires`` can be: + * ``ONCLOSE`` (default: expires when browser is closed) + * ``NOW`` (for immediate deletion) + * ``NEVER`` (some time far in the future) + * A integer value (expiration in seconds) + * A time.struct_time object + * A string that starts with ``+`` and describes the time, like + ``+1w`` (1 week) or ``+1b`` (1 month) + """ + + def set_header(header_name, header_value): + """ + Sets the named header; overwriting any header that previously + existed. The ``Status`` header is specially used to change + the response status. + """ + + def add_header(header_name, header_value): + """ + Adds the named header, appending to any previous header that + might have been set. + """ + + def write(*objs): + """ + Writes the objects. ``None`` is written as the empty string, + and unicode objects are encoded with UTF-8. + """ + + def redirect(url, **query_vars): + """ + Redirects to the given URL. If the URL is relative, then it + will be resolved to be absolute with respect to + ``self.app_url``. + + The status is 303 by default; a status keyword argument can be + passed to override this. + + Other variables are appended to the query string, e.g.:: + + self.redirect('edit', id=5) + + Will redirect to ``edit?id=5`` + """ + diff --git a/paste/wareweb/notify.py b/paste/wareweb/notify.py new file mode 100644 index 0000000..c4ced66 --- /dev/null +++ b/paste/wareweb/notify.py @@ -0,0 +1,67 @@ +import event + +__all__ = ['Notify'] + +class Notify(object): + + """ + This allows you to queue messages to be displayed to the user on + the next page the user reads. This allows you to give messages in + response to POST requests, then redirect to a another page and + display that message (e.g, 'event added'). + + Usage:: + + class SitePage(Servlet): + message = Notify() + + class MyPage(SitePage): + def setup(self): + if something_done: + self.message.write('All good!') + self.redirect(...) + def respond(self): + write header... + if self.message: + self.write('<div class="message">%s</div>\n' + % self.message.html) + """ + + def __addtoclass__(self, attr, cls): + cls.listeners.append(self.servlet_event) + self.name = attr + + def servlet_event(self, name, servlet, *args, **kw): + if name == 'start_awake': + setattr(servlet, self.name, Message(servlet)) + elif name == 'end_sleep': + if getattr(servlet, '_notify_written', False): + if 'notify.messages' in servlet.session: + del servlet.session['notify.messages'] + return event.Continue + +class Message(object): + + def __init__(self, servlet): + self.servlet = servlet + + def write(self, message): + session = self.servlet.session + messages = session.get('notify.messages') or [] + messages.append(message) + session['notify.messages'] = messages + + def __nonzero__(self): + return bool(self.servlet.session.get('notify.messages')) + + def html__get(self): + messages = self.servlet.session.get('notify.messages') or [] + self.servlet._notify_written = True + return self.peek + html = property(html__get) + + def peek__get(self): + messages = self.servlet.session.get('notify.messages') or [] + return '<br>'.join(messages) + peek = property(peek__get) + diff --git a/paste/wareweb/servlet.py b/paste/wareweb/servlet.py new file mode 100644 index 0000000..d52152e --- /dev/null +++ b/paste/wareweb/servlet.py @@ -0,0 +1,176 @@ +import re +import urllib +from paste import httpexceptions +import time +import timeinterval +import cgifields +import event +import classinit +from UserDict import UserDict + +__all__ = ['Servlet'] + +class Servlet(object): + + app_name = 'app' + listeners = [] + + __metaclass__ = classinit.ClassInitMeta + + def __classinit__(cls, new_attrs): + if not new_attrs.has_key('listeners'): + cls.listeners = cls.listeners[:] + for attr, value in new_attrs.items(): + cls.add_attr(attr, value, set=False) + classinit.build_properties(cls, new_attrs) + + @classmethod + def add_attr(cls, attr, value, set=True): + if set: + setattr(cls, attr, value) + if hasattr(value, '__addtoclass__'): + value.__addtoclass__(attr, cls) + + def __call__(self, environ, start_response): + value = event.raise_event('call', self, environ, start_response) + if value is not event.Continue: + status, headers, app_iter = value + start_response(status, headers) + return app_iter + self.environ = environ + self.config = self.environ['paste.config'] + if self.config.get('app_name'): + self.app_name = self.config['app_name'] + self._cached_output = [] + self.headers_out = { + 'Content-type': 'text/html; charset=UTF-8'} + self.status = '200 OK' + self.cookies_out = {} + self.request_method = environ['REQUEST_METHOD'].upper() + self.app_url = self.environ.get('%s.base_url' % self.app_name, '') + self.path_info = self.environ.get('PATH_INFO', '') + if self.path_info: + self.path_parts = filter(None, self.path_info[1:].split('/')) + else: + # Note that you have to look at self.path_info to + # distinguish between '' and '/' + self.path_parts = [] + self.fields = cgifields.Fields(cgifields.parse_fields(environ)) + self.run() + headers = [] + for name, value in self.headers_out.items(): + if isinstance(value, list): + for v in value: + headers.append((name, v)) + else: + headers.append((name, value)) + # @@: cookies + start_response(self.status, headers) + return self._cached_output + + @event.wrap_func + def run(self): + try: + self.awake() + self.respond() + finally: + self.sleep() + + + @event.wrap_func + def awake(self, call_setup=True): + if call_setup: + self.setup() + + def setup(self): + pass + + @event.wrap_func + def respond(self): + pass + + @event.wrap_func + def sleep(self, call_teardown=True): + if call_teardown: + self.teardown() + + def teardown(self): + pass + + ############################################################ + ## Request + ############################################################ + + def session__get(self): + if 'paste.session.factory' in self.environ: + sess = self.environ['paste.session.factory']() + elif 'paste.flup_session_service' in self.environ: + sess = self.environ['paste.flup_session_service'].session + self.__dict__['session'] = sess + return sess + + ############################################################ + ## Response + ############################################################ + + def title__get(self): + return self.__class__.__name__ + + def set_cookie(self, cookie_name, value, path='/', + expires='ONCLOSE', secure=False): + c = cookie.Cookie(cookie_name, value, path=path, + expires=expires, secure=secure) + self._cookies_out[cookie_name] = c + + def set_header(self, header_name, header_value): + header_name = header_name.lower() + if header_name == 'status': + self.status = header_value + return + self.headers_out[header_name] = header_value + + def add_header(self, header_name, header_value): + header_name = header_name.lower() + if header_name == 'status': + self.status = header_value + return + if self.headers_out.has_key(header_name): + if not isinstance(self.headers_out[header_name], list): + self.headers_out[header_name] = [self.headers_out[header_name], + header_value] + else: + self.headers_out[header_name].append(header_value) + else: + self.headers_out[header_name] = header_value + + def write(self, *obj): + for v in obj: + if v is None: + continue + elif isinstance(v, str): + self._cached_output.append(v) + elif isinstance(v, unicode): + self._cached_output.append(v.encode('utf-8')) + else: + self._cached_output.append(unicode(v).encode('utf-8')) + + def redirect(self, url, **query_vars): + if not url.startswith('/') and not abs_regex.search(url): + url = self.app_url + '/' + url + if 'status' in query_vars: + status = query_vars.pop('status') + if isinstance(status, (str, unicode)): + status = int(status.split()[0]) + else: + status = 303 + if query_vars: + if '?' in url: + url += '&' + else: + url += '?' + url += urllib.urlencode(query_vars) + raise httpexceptions.get_exception(status)( + "This resource has been redirected", + headers={'Location': url}) + +abs_regex = re.compile(r'^[a-zA-Z]:') diff --git a/paste/wareweb/tests/__init__.py b/paste/wareweb/tests/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/paste/wareweb/tests/__init__.py @@ -0,0 +1 @@ +# diff --git a/paste/wareweb/tests/test_unpack.py b/paste/wareweb/tests/test_unpack.py new file mode 100644 index 0000000..560de03 --- /dev/null +++ b/paste/wareweb/tests/test_unpack.py @@ -0,0 +1,97 @@ +from paste.wareweb import unpack +from paste.wareweb import cgifields +from cStringIO import StringIO + +test_funcs = [] + +def unpack_test(query, *args, **kw): + def decorate(func): + test_funcs.append((func, query, args, kw)) + return func + return decorate + +def test_functions(): + for a1, a2, a3, a4 in test_funcs: + yield function_test, a1, a2, a3, a4 + +def function_test(func, query, expect_args, expect_kw): + spec = unpack.FunctionArgSpec(func) + if '?' in query: + path_info, query = query.split('?') + else: + path_info = '' + assert not path_info or path_info.startswith('/') + path_parts = filter(None, path_info[1:].split('/')) + fields = cgifields.parse_fields({ + 'REQUEST_METHOD': 'GET', + 'QUERY_STRING': query, + 'wsgi.input': StringIO()}) + try: + args, kw = spec.unpack_args(path_parts, fields) + except unpack.HTTPBadRequest, e: + print fields + print path_parts + if not expect_kw and len(expect_args) == 1: + print e + assert expect_args[0] == str(e) + return + raise + bad_args = [] + if len(expect_args) > args: + for arg in expect_args[len(args):]: + bad_args.append('%s: missing' % arg) + if len(args) > expect_args: + for arg in args[len(expect_args):]: + bad_args.append('Bad input positional arg: %s' % arg) + for i, (got, expected) in enumerate(zip(args, expect_args)): + if got != expected: + bad_args.append('Arg %i; got %r != %r' + % (i, got, expected)) + for name, value in kw.iteritems(): + if name not in expect_kw: + bad_args.append('kw %s: not expected' % name) + elif expect_kw[name] != value: + bad_args.append('kw %s; got %r != %r' + % (name, value, expect_kw[name])) + for name in expect_kw: + if name not in kw: + bad_args.append('kw %s; expected, not gotten' % name) + if bad_args: + print "Expected:" + print expect_args + print expect_kw + print "Got:" + print args + print kw + for line in bad_args: + print line + assert 0, "Bad arguments" + + +@unpack_test('name=bob&age=5', name='bob', age_int=5) +@unpack_test('name=joe&age=ten', "Bad variable 'age': invalid literal for int(): ten") +@unpack_test('age=10', "Variable 'name' required") +@unpack_test('name=name&bob=bob', "Variable 'bob' not expected") +@unpack_test('name=name1&name=name2&age=10', "Bad variable 'name': List not expected") +def t(name, age_int): + pass + + +@unpack_test('name1=x&name2=x&name3=x', name1='x', name2='x', name3='x') +@unpack_test('/test/this/out?stuff', 'test', 'this', 'out', stuff='') +def t2(*args, **kw): + pass + +@unpack_test('/this/here?x', 'this', 'here', x='') +@unpack_test('/this?x', 'this', x='') +@unpack_test('/?x', 'Not enough parameters on the URL (expected 1 more path segments)') +@unpack_test('/this/here/bad?x', 'Too many parameters on the URL (expected 1 less path segments)') +def t3(arg1_path, arg2_path=None, x=None): + pass + +@unpack_test('a=1', a_list_int=[1]) +@unpack_test('') +@unpack_test('a=1&a=2', a_list_int=[1, 2]) +@unpack_test('a=b', "Bad variable 'a': invalid literal for int(): b") +def t4(a_list_int=[]): + pass diff --git a/paste/wareweb/timeinterval.py b/paste/wareweb/timeinterval.py new file mode 100644 index 0000000..f2d14b8 --- /dev/null +++ b/paste/wareweb/timeinterval.py @@ -0,0 +1,70 @@ +""" +timeinterval + +Convert interval strings (in the form of 1w2d, etc) to +seconds, and back again. Is not exactly about months or +years (leap years in particular). + +Accepts (y)ear, (b)month, (w)eek, (d)ay, (h)our, (m)inute, (s)econd. + +Exports only time_encode and time_decode functions. +""" + +import re + +second = 1 +minute = second*60 +hour = minute*60 +day = hour*24 +week = day*7 +month = day*30 +year = day*365 +time_values = { + 'y': year, + 'b': month, + 'w': week, + 'd': day, + 'h': hour, + 'm': minute, + 's': second, + } +time_ordered = time_values.items() +time_ordered.sort(lambda a, b: -cmp(a[1], b[1])) + +def time_encode(seconds): + """Encodes a number of seconds (representing a time interval) + into a form like 1h2d3s.""" + s = '' + for char, amount in time_ordered: + if seconds >= amount: + i, seconds = divmod(seconds, amount) + s += '%i%s' % (i, char) + return s + +time_re = re.compile(r'[0-9]+[a-zA-Z]') +def time_decode(s): + """Decodes a number in the format 1h4d3m (1 hour, 3 days, 3 minutes) + into a number of seconds""" + time = 0 + for match in all_matches(s, time_re): + char = match.group(0)[-1].lower() + if not time_values.has_key(char): + # @@: should signal error + continue + time += int(match.group(0)[:-1]) * time_values[char] + return time + +def all_matches(source, regex): + """ + Return a list of matches for regex in source + """ + pos = 0 + end = len(source) + rv = [] + match = regex.search(source, pos) + while match: + rv.append(match) + match = regex.search(source, match.end() ) + return rv + +__all__ = ['time_encode', 'time_decode'] diff --git a/paste/wareweb/unpack.py b/paste/wareweb/unpack.py new file mode 100644 index 0000000..a2b3217 --- /dev/null +++ b/paste/wareweb/unpack.py @@ -0,0 +1,214 @@ +import inspect +import xmlrpclib +import traceback +from paste.httpexceptions import HTTPBadRequest +json = None + +__all__ = ['unpack', 'unpack_xmlrpc', 'unpack_json'] + +def unpack(func): + argspec = FunctionArgSpec(func) + def replacement_func(self): + args, kw = argspec.unpack_args(self.path_parts, self.fields) + return func(self, *args, **kw) + replacement_func.__doc__ = func.__doc__ + replacement_func.__name__ = func.__name__ + return replacement_func + +def unpack_xmlrpc(func): + def replacement_func(self): + assert self.environ['CONTENT_TYPE'].startswith('text/xml') + data = self.environ['wsgi.input'].read() + xmlargs, method_name = xmlrpclib.loads(data) + if method_name: + kw = {'method_name': method_name} + else: + kw = {} + self.set_header('content-type', 'text/xml; charset=UTF-8') + try: + result = func(self, *xmlargs, **kw) + except: + body = make_rpc_exception(environ, sys.exc_info()) + body = xmlrpclib.dumps( + xmlrpclib.Fault(1, fault), encoding='utf-8') + else: + if not isinstance(result, tuple): + result = (result,) + body = xmlrpclib.dumps( + result, methodresponse=True, encoding='utf-8') + self.write(body) + replacement_func.__doc__ = func.__doc__ + replacement_func.__name__ = func.__name__ + return replacement_func + +def make_rpc_exception(environ, exc_info): + config = environ['paste.config'] + rpc_exception = config.get('rpc_exception', None) + if rpc_exception not in (None, 'occurred', 'exception', 'traceback'): + environ['wsgi.errors'].write( + "Bad 'rpc_exception' setting: %r\n" % rpc_exception) + rpc_exception = None + if rpc_exception is None: + if config.get('debug'): + rpc_exception = 'traceback' + else: + rpc_exception = 'exception' + if rpc_exception == 'occurred': + fault = 'unhandled exception' + elif rpc_exception == 'exception': + fault = str(e) + elif rpc_exception == 'traceback': + out = StringIO() + traceback.print_exception(*exc_info, **{'file': out}) + fault = out.getvalue() + return fault + +def unpack_json(func): + global json + if json is None: + import json + def replacement_func(self): + data = self.environ['wsgi.input'].read() + jsonrpc = json.jsonToObj(data) + method = jsonrpc['method'] + params = jsonrpc['params'] + id = jsonrpc['id'] + if method: + kw = {'method_name': method} + else: + kw = {} + self.set_header('content-type', 'text/plain; charset: UTF-8') + try: + result = func(self, *params, **kw) + except: + body = make_rpc_exception(environ, sys.exc_info()) + response = { + 'result': None, + 'error': body, + 'id': id} + else: + response = { + 'result': result, + 'error': None, + 'id': id} + self.write(json.objToJson(response)) + replacement_func.__doc__ = func.__doc__ + replacement_func.__name__ = func.__name__ + return replacement_func + + +class FunctionArgSpec(object): + + def __init__(self, func): + self.funcargs, self.varargs, self.varkw, self.defaults = ( + inspect.getargspec(func)) + self.positional = [] + self.optional_pos = [] + self.coersions = self.collect_coersions(self.funcargs) + while self.funcargs and self.funcargs[0].endswith('_path'): + if len(self.defaults or ()) == len(self.funcargs): + # This is an optional path segment + self.optional_pos.append(self.funcargs.pop(0)) + self.defaults = self.defaults[1:] + else: + self.positional.append(self.funcargs.pop(0)) + self.reqargs = [] + if not self.defaults: + self.reqargs = self.funcargs + else: + self.reqargs = self.funcargs[:-len(self.defaults)] + + def unpack_args(self, path_parts, fields): + args = [] + kw = {} + fields = fields.copy() + if len(self.positional) > len(path_parts): + raise HTTPBadRequest( + "Not enough parameters on the URL (expected %i more " + "path segments)" % (len(self.positional)-len(path_parts))) + if (not self.varargs + and (len(self.positional)+len(self.optional_pos)) + < len(path_parts)): + raise HTTPBadRequest( + "Too many parameters on the URL (expected %i less path " + "segments)" % (len(path_parts)-len(self.positional) + -len(self.optional_pos))) + for name, value in fields.iteritems(): + if not self.varkw and name not in self.coersions: + raise HTTPBadRequest( + "Variable %r not expected" % name) + if name not in self.coersions: + kw[name] = value + continue + orig_name, coercer = self.coersions[name] + if coercer: + try: + value = coercer(value) + except (ValueError, TypeError), e: + raise HTTPBadRequest( + "Bad variable %r: %s" % (name, e)) + kw[orig_name] = value + for arg in self.reqargs: + if arg not in kw: + raise HTTPBadRequest( + "Variable %r required" % arg) + return args, kw + + def collect_coersions(self, funcargs): + coersions = {} + for name in funcargs: + coercer = normal + orig = name + while 1: + if name.endswith('_int'): + coercer = self.add_coercer(coercer, make_int) + name = name[:-4] + elif name.endswith('_list'): + coercer = self.add_coercer(coercer, make_list) + name = name[:-5] + elif name.endswith('_float'): + coercer = self.add_coercer(coercer, make_float) + name = name[:-6] + elif name.endswith('_req'): + coercer = self.add_coercer(coercer, make_required) + name = name[:-4] + else: + break + coersions[name] = (orig, coercer) + return coersions + + def add_coercer(self, coercer, new_coercer): + if not coercer or coercer is normal: + return new_coercer + else: + def coerce(val): + return new_coercer(coercer(val)) + return coerce + +def make_int(v): + if isinstance(v, list): + return map(int, v) + else: + return int(v) + +def make_float(v): + if isinstance(v, list): + return map(float, v) + else: + return float(v) + +def make_list(v): + if isinstance(v, list): + return v + else: + return [v] + +def make_required(s): + if s is None: + raise TypeError + return s + +def normal(v): + if isinstance(v, list): + raise ValueError("List not expected") + return v |
