First version, initial module hello, initial load module
[kisspi.git] / web / application.py
1 #!/usr/bin/python
2 """
3 Web application
4 (from web.py)
5 """
6 import webapi as web
7 import webapi, wsgi, utils
8 import debugerror
9 from utils import lstrips, safeunicode
10 import sys
11
12 import urllib
13 import traceback
14 import itertools
15 import os
16 import re
17 import types
18 from exceptions import SystemExit
19
20 try:
21     import wsgiref.handlers
22 except ImportError:
23     pass # don't break people with old Pythons
24
25 __all__ = [
26     "application", "auto_application",
27     "subdir_application", "subdomain_application", 
28     "loadhook", "unloadhook",
29     "autodelegate"
30 ]
31
32 class application:
33     """
34     Application to delegate requests based on path.
35     
36         >>> urls = ("/hello", "hello")
37         >>> app = application(urls, globals())
38         >>> class hello:
39         ...     def GET(self): return "hello"
40         >>>
41         >>> app.request("/hello").data
42         'hello'
43     """
44     def __init__(self, mapping=(), fvars={}, autoreload=None):
45         if autoreload is None:
46             autoreload = web.config.get('debug', False)
47         self.mapping = mapping
48         self.fvars = fvars
49         self.processors = []
50         
51         self.add_processor(loadhook(self._load))
52         self.add_processor(unloadhook(self._unload))
53         
54         if autoreload:
55             def main_module_name():
56                 mod = sys.modules['__main__']
57                 file = getattr(mod, '__file__', None) # make sure this works even from python interpreter
58                 return file and os.path.splitext(os.path.basename(file))[0]
59
60             def modname(fvars):
61                 """find name of the module name from fvars."""
62                 file, name = fvars.get('__file__'), fvars.get('__name__')
63                 if file is None or name is None:
64                     return None
65
66                 if name == '__main__':
67                     # Since the __main__ module can't be reloaded, the module has 
68                     # to be imported using its file name.                    
69                     name = main_module_name()
70                 return name
71                 
72             mapping_name = utils.dictfind(fvars, mapping)
73             module_name = modname(fvars)
74             
75             def reload_mapping():
76                 """loadhook to reload mapping and fvars."""
77                 mod = __import__(module_name)
78                 mapping = getattr(mod, mapping_name, None)
79                 if mapping:
80                     self.fvars = mod.__dict__
81                     self.mapping = mapping
82
83             self.add_processor(loadhook(Reloader()))
84             if mapping_name and module_name:
85                 self.add_processor(loadhook(reload_mapping))
86
87             # load __main__ module usings its filename, so that it can be reloaded.
88             if main_module_name() and '__main__' in sys.argv:
89                 try:
90                     __import__(main_module_name())
91                 except ImportError:
92                     pass
93                     
94     def _load(self):
95         web.ctx.app_stack.append(self)
96         
97     def _unload(self):
98         web.ctx.app_stack = web.ctx.app_stack[:-1]
99         
100         if web.ctx.app_stack:
101             # this is a sub-application, revert ctx to earlier state.
102             oldctx = web.ctx.get('_oldctx')
103             if oldctx:
104                 web.ctx.home = oldctx.home
105                 web.ctx.homepath = oldctx.homepath
106                 web.ctx.path = oldctx.path
107                 web.ctx.fullpath = oldctx.fullpath
108                 
109     def _cleanup(self):
110         #@@@
111         # Since the CherryPy Webserver uses thread pool, the thread-local state is never cleared.
112         # This interferes with the other requests. 
113         # clearing the thread-local storage to avoid that.
114         # see utils.ThreadedDict for details
115         import threading
116         t = threading.currentThread()
117         if hasattr(t, '_d'):
118             del t._d
119     
120     def add_mapping(self, pattern, classname):
121         self.mapping += (pattern, classname)
122         
123     def add_processor(self, processor):
124         """
125         Adds a processor to the application. 
126         
127             >>> urls = ("/(.*)", "echo")
128             >>> app = application(urls, globals())
129             >>> class echo:
130             ...     def GET(self, name): return name
131             ...
132             >>>
133             >>> def hello(handler): return "hello, " +  handler()
134             >>> app.add_processor(hello)
135             >>> app.request("/web.py").data
136             'hello, web.py'
137         """
138         self.processors.append(processor)
139
140     def request(self, localpart='/', method='GET', data=None,
141                 host="0.0.0.0:8080", headers=None, https=False, **kw):
142         """Makes request to this application for the specified path and method.
143         Response will be a storage object with data, status and headers.
144
145             >>> urls = ("/hello", "hello")
146             >>> app = application(urls, globals())
147             >>> class hello:
148             ...     def GET(self): 
149             ...         web.header('Content-Type', 'text/plain')
150             ...         return "hello"
151             ...
152             >>> response = app.request("/hello")
153             >>> response.data
154             'hello'
155             >>> response.status
156             '200 OK'
157             >>> response.headers['Content-Type']
158             'text/plain'
159
160         To use https, use https=True.
161
162             >>> urls = ("/redirect", "redirect")
163             >>> app = application(urls, globals())
164             >>> class redirect:
165             ...     def GET(self): raise web.seeother("/foo")
166             ...
167             >>> response = app.request("/redirect")
168             >>> response.headers['Location']
169             'http://0.0.0.0:8080/foo'
170             >>> response = app.request("/redirect", https=True)
171             >>> response.headers['Location']
172             'https://0.0.0.0:8080/foo'
173
174         The headers argument specifies HTTP headers as a mapping object
175         such as a dict.
176
177             >>> urls = ('/ua', 'uaprinter')
178             >>> class uaprinter:
179             ...     def GET(self):
180             ...         return 'your user-agent is ' + web.ctx.env['HTTP_USER_AGENT']
181             ... 
182             >>> app = application(urls, globals())
183             >>> app.request('/ua', headers = {
184             ...      'User-Agent': 'a small jumping bean/1.0 (compatible)'
185             ... }).data
186             'your user-agent is a small jumping bean/1.0 (compatible)'
187
188         """
189         path, maybe_query = urllib.splitquery(localpart)
190         query = maybe_query or ""
191         
192         if 'env' in kw:
193             env = kw['env']
194         else:
195             env = {}
196         env = dict(env, HTTP_HOST=host, REQUEST_METHOD=method, PATH_INFO=path, QUERY_STRING=query, HTTPS=str(https))
197         headers = headers or {}
198
199         for k, v in headers.items():
200             env['HTTP_' + k.upper().replace('-', '_')] = v
201
202         if 'HTTP_CONTENT_LENGTH' in env:
203             env['CONTENT_LENGTH'] = env.pop('HTTP_CONTENT_LENGTH')
204
205         if 'HTTP_CONTENT_TYPE' in env:
206             env['CONTENT_TYPE'] = env.pop('HTTP_CONTENT_TYPE')
207
208         if method in ["POST", "PUT"]:
209             data = data or ''
210             import StringIO
211             if isinstance(data, dict):
212                 q = urllib.urlencode(data)
213             else:
214                 q = data
215             env['wsgi.input'] = StringIO.StringIO(q)
216             if not env.get('CONTENT_TYPE', '').lower().startswith('multipart/') and 'CONTENT_LENGTH' not in env:
217                 env['CONTENT_LENGTH'] = len(q)
218         response = web.storage()
219         def start_response(status, headers):
220             response.status = status
221             response.headers = dict(headers)
222             response.header_items = headers
223         response.data = "".join(self.wsgifunc()(env, start_response))
224         return response
225
226     def browser(self):
227         import browser
228         return browser.AppBrowser(self)
229
230     def handle(self):
231         fn, args = self._match(self.mapping, web.ctx.path)
232         return self._delegate(fn, self.fvars, args)
233         
234     def handle_with_processors(self):
235         def process(processors):
236             try:
237                 if processors:
238                     p, processors = processors[0], processors[1:]
239                     return p(lambda: process(processors))
240                 else:
241                     return self.handle()
242             except web.HTTPError:
243                 raise
244             except (KeyboardInterrupt, SystemExit):
245                 raise
246             except:
247                 print >> web.debug, traceback.format_exc()
248                 raise self.internalerror()
249         
250         # processors must be applied in the resvere order. (??)
251         return process(self.processors)
252                         
253     def wsgifunc(self, *middleware):
254         """Returns a WSGI-compatible function for this application."""
255         def peep(iterator):
256             """Peeps into an iterator by doing an iteration
257             and returns an equivalent iterator.
258             """
259             # wsgi requires the headers first
260             # so we need to do an iteration
261             # and save the result for later
262             try:
263                 firstchunk = iterator.next()
264             except StopIteration:
265                 firstchunk = ''
266
267             return itertools.chain([firstchunk], iterator)    
268                                 
269         def is_generator(x): return x and hasattr(x, 'next')
270         
271         def wsgi(env, start_resp):
272             self.load(env)
273             try:
274                 # allow uppercase methods only
275                 if web.ctx.method.upper() != web.ctx.method:
276                     raise web.nomethod()
277
278                 result = self.handle_with_processors()
279                 if is_generator(result):
280                     result = peep(result)
281                 else:
282                     result = [result]
283             except web.HTTPError, e:
284                 result = [e.data]
285
286             result = web.utf8(iter(result))
287
288             status, headers = web.ctx.status, web.ctx.headers
289             start_resp(status, headers)
290             
291             def cleanup():
292                 self._cleanup()
293                 yield '' # force this function to be a generator
294                             
295             return itertools.chain(result, cleanup())
296
297         for m in middleware: 
298             wsgi = m(wsgi)
299
300         return wsgi
301
302     def run(self, *middleware):
303         """
304         Starts handling requests. If called in a CGI or FastCGI context, it will follow
305         that protocol. If called from the command line, it will start an HTTP
306         server on the port named in the first command line argument, or, if there
307         is no argument, on port 8080.
308         
309         `middleware` is a list of WSGI middleware which is applied to the resulting WSGI
310         function.
311         """
312         return wsgi.runwsgi(self.wsgifunc(*middleware))
313     
314     def cgirun(self, *middleware):
315         """
316         Return a CGI handler. This is mostly useful with Google App Engine.
317         There you can just do:
318         
319             main = app.cgirun()
320         """
321         wsgiapp = self.wsgifunc(*middleware)
322
323         try:
324             from google.appengine.ext.webapp.util import run_wsgi_app
325             return run_wsgi_app(wsgiapp)
326         except ImportError:
327             # we're not running from within Google App Engine
328             return wsgiref.handlers.CGIHandler().run(wsgiapp)
329     
330     def load(self, env):
331         """Initializes ctx using env."""
332         ctx = web.ctx
333         ctx.clear()
334         ctx.status = '200 OK'
335         ctx.headers = []
336         ctx.output = ''
337         ctx.environ = ctx.env = env
338         ctx.host = env.get('HTTP_HOST')
339
340         if env.get('wsgi.url_scheme') in ['http', 'https']:
341             ctx.protocol = env['wsgi.url_scheme']
342         elif env.get('HTTPS', '').lower() in ['on', 'true', '1']:
343             ctx.protocol = 'https'
344         else:
345             ctx.protocol = 'http'
346         ctx.homedomain = ctx.protocol + '://' + env.get('HTTP_HOST', '[unknown]')
347         ctx.homepath = os.environ.get('REAL_SCRIPT_NAME', env.get('SCRIPT_NAME', ''))
348         ctx.home = ctx.homedomain + ctx.homepath
349         #@@ home is changed when the request is handled to a sub-application.
350         #@@ but the real home is required for doing absolute redirects.
351         ctx.realhome = ctx.home
352         ctx.ip = env.get('REMOTE_ADDR')
353         ctx.method = env.get('REQUEST_METHOD')
354         ctx.path = env.get('PATH_INFO')
355         # http://trac.lighttpd.net/trac/ticket/406 requires:
356         if env.get('SERVER_SOFTWARE', '').startswith('lighttpd/'):
357             ctx.path = lstrips(env.get('REQUEST_URI').split('?')[0], ctx.homepath)
358             # Apache and CherryPy webservers unquote the url but lighttpd doesn't. 
359             # unquote explicitly for lighttpd to make ctx.path uniform across all servers.
360             ctx.path = urllib.unquote(ctx.path)
361
362         if env.get('QUERY_STRING'):
363             ctx.query = '?' + env.get('QUERY_STRING', '')
364         else:
365             ctx.query = ''
366
367         ctx.fullpath = ctx.path + ctx.query
368         
369         for k, v in ctx.iteritems():
370             if isinstance(v, str):
371                 ctx[k] = safeunicode(v)
372
373         # status must always be str
374         ctx.status = '200 OK'
375         
376         ctx.app_stack = []
377
378     def _delegate(self, f, fvars, args=[]):
379         def handle_class(cls):
380             meth = web.ctx.method
381             if meth == 'HEAD' and not hasattr(cls, meth):
382                 meth = 'GET'
383             if not hasattr(cls, meth):
384                 raise web.nomethod(cls)
385             tocall = getattr(cls(), meth)
386             return tocall(*args)
387             
388         def is_class(o): return isinstance(o, (types.ClassType, type))
389             
390         if f is None:
391             raise web.notfound()
392         elif isinstance(f, application):
393             return f.handle_with_processors()
394         elif is_class(f):
395             return handle_class(f)
396         elif isinstance(f, basestring):
397             if f.startswith('redirect '):
398                 url = f.split(' ', 1)[1]
399                 if web.ctx.method == "GET":
400                     x = web.ctx.env.get('QUERY_STRING', '')
401                     if x:
402                         url += '?' + x
403                 raise web.redirect(url)
404             elif '.' in f:
405                 x = f.split('.')
406                 mod, cls = '.'.join(x[:-1]), x[-1]
407                 mod = __import__(mod, globals(), locals(), [""])
408                 cls = getattr(mod, cls)
409             else:
410                 cls = fvars[f]
411             return handle_class(cls)
412         elif hasattr(f, '__call__'):
413             return f()
414         else:
415             return web.notfound()
416
417     def _match(self, mapping, value):
418         for pat, what in utils.group(mapping, 2):
419             if isinstance(what, application):
420                 if value.startswith(pat):
421                     f = lambda: self._delegate_sub_application(pat, what)
422                     return f, None
423                 else:
424                     continue
425             elif isinstance(what, basestring):
426                 what, result = utils.re_subm('^' + pat + '$', what, value)
427             else:
428                 result = utils.re_compile('^' + pat + '$').match(value)
429                 
430             if result: # it's a match
431                 return what, [x for x in result.groups()]
432         return None, None
433         
434     def _delegate_sub_application(self, dir, app):
435         """Deletes request to sub application `app` rooted at the directory `dir`.
436         The home, homepath, path and fullpath values in web.ctx are updated to mimic request
437         to the subapp and are restored after it is handled. 
438         
439         @@Any issues with when used with yield?
440         """
441         web.ctx._oldctx = web.storage(web.ctx)
442         web.ctx.home += dir
443         web.ctx.homepath += dir
444         web.ctx.path = web.ctx.path[len(dir):]
445         web.ctx.fullpath = web.ctx.fullpath[len(dir):]
446         return app.handle_with_processors()
447             
448     def get_parent_app(self):
449         if self in web.ctx.app_stack:
450             index = web.ctx.app_stack.index(self)
451             if index > 0:
452                 return web.ctx.app_stack[index-1]
453         
454     def notfound(self):
455         """Returns HTTPError with '404 not found' message"""
456         parent = self.get_parent_app()
457         if parent:
458             return parent.notfound()
459         else:
460             return web._NotFound()
461             
462     def internalerror(self):
463         """Returns HTTPError with '500 internal error' message"""
464         parent = self.get_parent_app()
465         if parent:
466             return parent.internalerror()
467         elif web.config.get('debug'):
468             import debugerror
469             return debugerror.debugerror()
470         else:
471             return web._InternalError()
472
473 class auto_application(application):
474     """Application similar to `application` but urls are constructed 
475     automatiacally using metaclass.
476
477         >>> app = auto_application()
478         >>> class hello(app.page):
479         ...     def GET(self): return "hello, world"
480         ...
481         >>> class foo(app.page):
482         ...     path = '/foo/.*'
483         ...     def GET(self): return "foo"
484         >>> app.request("/hello").data
485         'hello, world'
486         >>> app.request('/foo/bar').data
487         'foo'
488     """
489     def __init__(self):
490         application.__init__(self)
491
492         class metapage(type):
493             def __init__(klass, name, bases, attrs):
494                 type.__init__(klass, name, bases, attrs)
495                 path = attrs.get('path', '/' + name)
496
497                 # path can be specified as None to ignore that class
498                 # typically required to create a abstract base class.
499                 if path is not None:
500                     self.add_mapping(path, klass)
501
502         class page:
503             path = None
504             __metaclass__ = metapage
505
506         self.page = page
507
508 # The application class already has the required functionality of subdir_application
509 subdir_application = application
510                 
511 class subdomain_application(application):
512     """
513     Application to delegate requests based on the host.
514
515         >>> urls = ("/hello", "hello")
516         >>> app = application(urls, globals())
517         >>> class hello:
518         ...     def GET(self): return "hello"
519         >>>
520         >>> mapping = (r"hello\.example\.com", app)
521         >>> app2 = subdomain_application(mapping)
522         >>> app2.request("/hello", host="hello.example.com").data
523         'hello'
524         >>> response = app2.request("/hello", host="something.example.com")
525         >>> response.status
526         '404 Not Found'
527         >>> response.data
528         'not found'
529     """
530     def handle(self):
531         host = web.ctx.host.split(':')[0] #strip port
532         fn, args = self._match(self.mapping, host)
533         return self._delegate(fn, self.fvars, args)
534         
535     def _match(self, mapping, value):
536         for pat, what in utils.group(mapping, 2):
537             if isinstance(what, basestring):
538                 what, result = utils.re_subm('^' + pat + '$', what, value)
539             else:
540                 result = utils.re_compile('^' + pat + '$').match(value)
541
542             if result: # it's a match
543                 return what, [x for x in result.groups()]
544         return None, None
545         
546 def loadhook(h):
547     """
548     Converts a load hook into an application processor.
549     
550         >>> app = auto_application()
551         >>> def f(): "something done before handling request"
552         ...
553         >>> app.add_processor(loadhook(f))
554     """
555     def processor(handler):
556         h()
557         return handler()
558         
559     return processor
560     
561 def unloadhook(h):
562     """
563     Converts an unload hook into an application processor.
564     
565         >>> app = auto_application()
566         >>> def f(): "something done after handling request"
567         ...
568         >>> app.add_processor(unloadhook(f))    
569     """
570     def processor(handler):
571         result = handler()
572         is_generator = result and hasattr(result, 'next')
573
574         if is_generator:
575             return wrap(result)
576         else:
577             h()
578             return result
579             
580     def wrap(result):
581         def next():
582             try:
583                 return result.next()
584             except:
585                 # call the hook at the and of iterator
586                 h()
587                 raise
588
589         result = iter(result)
590         while True:
591             yield next()
592             
593     return processor
594
595 def autodelegate(prefix=''):
596     """
597     Returns a method that takes one argument and calls the method named prefix+arg,
598     calling `notfound()` if there isn't one. Example:
599
600         urls = ('/prefs/(.*)', 'prefs')
601
602         class prefs:
603             GET = autodelegate('GET_')
604             def GET_password(self): pass
605             def GET_privacy(self): pass
606
607     `GET_password` would get called for `/prefs/password` while `GET_privacy` for 
608     `GET_privacy` gets called for `/prefs/privacy`.
609     
610     If a user visits `/prefs/password/change` then `GET_password(self, '/change')`
611     is called.
612     """
613     def internal(self, arg):
614         if '/' in arg:
615             first, rest = arg.split('/', 1)
616             func = prefix + first
617             args = ['/' + rest]
618         else:
619             func = prefix + arg
620             args = []
621         
622         if hasattr(self, func):
623             try:
624                 return getattr(self, func)(*args)
625             except TypeError:
626                 return web.notfound()
627         else:
628             return web.notfound()
629     return internal
630
631 class Reloader:
632     """Checks to see if any loaded modules have changed on disk and, 
633     if so, reloads them.
634     """
635     def __init__(self):
636         self.mtimes = {}
637
638     def __call__(self):
639         for mod in sys.modules.values():
640             self.check(mod)
641             
642     def check(self, mod):
643         try: 
644             mtime = os.stat(mod.__file__).st_mtime
645         except (AttributeError, OSError, IOError):
646             return
647         if mod.__file__.endswith('.pyc') and os.path.exists(mod.__file__[:-1]):
648             mtime = max(os.stat(mod.__file__[:-1]).st_mtime, mtime)
649             
650         if mod not in self.mtimes:
651             self.mtimes[mod] = mtime
652         elif self.mtimes[mod] < mtime:
653             try: 
654                 reload(mod)
655                 self.mtimes[mod] = mtime
656             except ImportError: 
657                 pass
658                 
659 if __name__ == "__main__":
660     import doctest
661     doctest.testmod()