Package turbogears :: Module controllers

Source Code for Module turbogears.controllers

  1  """Classes and methods for TurboGears controllers.""" 
  2   
  3  __all__ = ['Controller', 'absolute_url', 
  4      'error_handler', 'exception_handler', 
  5      'expose', 'get_server_name', 'flash', 'redirect', 
  6      'Root', 'RootController', 'url', 'validate'] 
  7   
  8  import logging 
  9  import urllib 
 10  import urlparse 
 11  import types 
 12   
 13  from itertools import izip 
 14  from inspect import isclass 
 15   
 16  import cherrypy 
 17  from cherrypy import request, response, url as cp_url 
 18   
 19  from peak.rules import abstract, NoApplicableMethods 
 20  from peak.rules.core import always_overrides, Method 
 21   
 22  import turbogears.util as tg_util 
 23  from turbogears import view, database, errorhandling, config 
 24  from turbogears.decorator import weak_signature_decorator 
 25  from turbogears.errorhandling import error_handler, exception_handler 
 26  from turbogears.validators import Invalid 
 27   
 28  log = logging.getLogger('turbogears.controllers') 
 29   
 30  if config.get('tools.sessions.on', False): 
 31      if config.get('tools.sessions.storage_type') == 'PostgreSQL': 
 32          import psycopg2 
 33          config.update( 
 34              {'tools.sessions.get_db' : psycopg2.connect( 
 35                  config.get('sessions.postgres.dsn')) 
 36              }) 
37 # XXX: support for mysql/sqlite/etc here 38 39 40 -def _process_output(output, template, format, content_type, fragment=False, 41 **options):
42 """Produce final output form from data returned from a controller method. 43 44 See the expose() arguments for more info since they are the same. 45 46 """ 47 if isinstance(output, dict): 48 # import this here to prevent circular import in widgets.forms 49 from turbogears.widgets import js_location 50 51 css = tg_util.setlike() 52 js = dict(izip(js_location, iter(tg_util.setlike, None))) 53 include_widgets = {} 54 include_widgets_lst = config.get('tg.include_widgets', []) 55 56 if config.get('tg.mochikit_all', False): 57 include_widgets_lst.insert(0, 'turbogears.mochikit') 58 59 for name in include_widgets_lst: 60 widget = tg_util.load_class(name) 61 if widget is None: 62 log.debug("Could not load widget %s", name) 63 continue 64 if isclass(widget): 65 widget = widget() 66 if hasattr(widget, 'retrieve_resources') and hasattr(widget, 'inject'): 67 # it's a ToscaWidget, we register it for injection 68 widget.inject() 69 # XXX: widgets with same base name will override each other 70 include_widgets['tg_%s' % name.rsplit('.', 1)[-1]] = widget 71 output.update(include_widgets) 72 73 # collect JS/CSS resources from widgets in the output dict or 74 # tg.include_widgets 75 for value in output.itervalues(): 76 if hasattr(value, 'retrieve_resources'): 77 # it's a ToscaWidget, will be injected by the ToscaWidget middleware 78 continue 79 else: 80 try: 81 css_resources = value.retrieve_css() 82 except (AttributeError, TypeError): 83 css_resources = [] 84 try: 85 js_resources = value.retrieve_javascript() 86 except (AttributeError, TypeError): 87 js_resources = [] 88 css.add_all(css_resources) 89 for script in js_resources: 90 location = getattr(script, 'location', js_location.head) 91 js[location].add(script) 92 css.sort(key=lambda obj: getattr(obj, 'order', 0)) 93 output['tg_css'] = css 94 for location in iter(js_location): 95 js[location].sort(key=lambda obj: getattr(obj, 'order', 0)) 96 output['tg_js_%s' % location] = js[location] 97 98 tg_flash = _get_flash() 99 if tg_flash: 100 output['tg_flash'] = tg_flash 101 102 headers = {'Content-Type': content_type} 103 output = view.render(output, template=template, format=format, 104 headers=headers, fragment=fragment, **options) 105 content_type = headers['Content-Type'] 106 107 if content_type: 108 response.headers['Content-Type'] = content_type 109 else: 110 content_type = response.headers.get('Content-Type', 'text/plain') 111 112 if content_type.startswith('text/'): 113 if isinstance(output, unicode): 114 output = output.encode(tg_util.get_template_encoding_default()) 115 116 return output
117
118 119 -class BadFormatError(Exception):
120 """Output-format exception."""
121
122 123 -def validate(form=None, validators=None, 124 failsafe_schema=errorhandling.FailsafeSchema.none, 125 failsafe_values=None, state_factory=None):
126 """Validate input. 127 128 @param form: a form instance that must be passed throught the validation 129 process... you must give a the same form instance as the one that will 130 be used to post data on the controller you are putting the validate 131 decorator on. 132 @type form: a form instance 133 134 @param validators: individual validators to use for parameters. 135 If you use a schema for validation then the schema instance must 136 be the sole argument. 137 If you use simple validators, then you must pass a dictionary with 138 each value name to validate as a key of the dictionary and the validator 139 instance (eg: tg.validators.Int() for integer) as the value. 140 @type validators: dictionary or schema instance 141 142 @param failsafe_schema: a schema for handling failsafe values. 143 The default is 'none', but you can also use 'values', 'map_errors', 144 or 'defaults' to map erroneous inputs to values, corresponding exceptions 145 or method defaults. 146 @type failsafe_schema: errorhandling.FailsafeSchema 147 148 @param failsafe_values: replacements for erroneous inputs. You can either 149 define replacements for every parameter, or a single replacement value 150 for all parameters. This is only used when failsafe_schema is 'values'. 151 @type failsafe_values: a dictionary or a single value 152 153 @param state_factory: If this is None, the initial state for validation 154 is set to None, otherwise this must be a callable that returns the initial 155 state to be used for validation. 156 @type state_factory: callable or None 157 158 """ 159 def entangle(func): 160 if callable(form) and not hasattr(form, 'validate'): 161 init_form = form 162 else: 163 init_form = lambda self: form 164 165 def validate(func, *args, **kw): 166 # do not validate a second time if already validated 167 if hasattr(request, 'validation_state'): 168 return func(*args, **kw) 169 170 form = init_form(args and args[0] or kw['self']) 171 args, kw = tg_util.to_kw(func, args, kw) 172 173 errors = {} 174 if state_factory is not None: 175 state = state_factory() 176 else: 177 state = None 178 179 if form: 180 value = kw.copy() 181 try: 182 kw.update(form.validate(value, state)) 183 except Invalid, e: 184 errors = e.unpack_errors() 185 request.validation_exception = e 186 request.validated_form = form 187 188 if validators: 189 if isinstance(validators, dict): 190 for field, validator in validators.iteritems(): 191 try: 192 kw[field] = validator.to_python( 193 kw.get(field, None), state) 194 except Invalid, error: 195 errors[field] = error 196 else: 197 try: 198 value = kw.copy() 199 kw.update(validators.to_python(value, state)) 200 except Invalid, e: 201 errors = e.unpack_errors() 202 request.validation_exception = e 203 request.validation_errors = errors 204 request.input_values = kw.copy() 205 request.validation_state = state 206 207 if errors: 208 kw = errorhandling.dispatch_failsafe(failsafe_schema, 209 failsafe_values, errors, func, kw) 210 args, kw = tg_util.from_kw(func, args, kw) 211 return errorhandling.run_with_errors(errors, func, *args, **kw)
212 213 return validate 214 return weak_signature_decorator(entangle) 215
216 217 -class First(Method):
218 """Resolve ambiguousness by calling the first method."""
219 - def merge(self, other):
220 return self
221 222 always_overrides(First, Method) 223 first = First.make_decorator('first')
224 225 226 -def _add_rule(_expose, found_default, as_format, accept_format, template, 227 rulefunc):
228 if as_format == 'default': 229 if found_default: 230 as_format = template.split(':', 1)[0] 231 else: 232 found_default = True 233 ruleparts = ["kw.get('tg_format', 'default') == '%s'" % as_format] 234 if accept_format: 235 ruleparts.append("(accept == '%s'" 236 " and kw.get('tg_format', 'default') == 'default')" % accept_format) 237 rule = " or ".join(ruleparts) 238 log.debug("Generated rule %s", rule) 239 first(_expose, rule)(rulefunc) 240 return found_default
241
242 243 -def _build_rules(func):
244 @abstract() 245 def _expose(func, accept, allow_json, *args, **kw): 246 pass
247 248 if func._allow_json: 249 rule = ("allow_json and (kw.get('tg_format') == 'json'" 250 " or accept in ('application/json', 'text/javascript'))") 251 log.debug("Adding allow_json rule for %s: %s", func, rule) 252 first(_expose, rule)( 253 lambda _func, accept, allow_json, *args, **kw: 254 _execute_func(_func, 'json', 'json', 'application/json', 255 False, {}, args, kw)) 256 257 found_default = False 258 for ruleinfo in func._ruleinfo: 259 found_default = _add_rule(_expose, found_default, **ruleinfo) 260 261 func._expose = _expose 262
263 264 -def expose(template=None, allow_json=None, format=None, content_type=None, 265 fragment=False, as_format='default', accept_format=None, **options):
266 """Exposes a method to the web. 267 268 By putting the expose decorator on a method, you tell TurboGears that 269 the method should be accessible via URL traversal. Additionally, expose 270 handles the output processing (turning a dictionary into finished 271 output) and is also responsible for ensuring that the request is 272 wrapped in a database transaction. 273 274 You can apply multiple expose decorators to a method, if 275 you'd like to support multiple output formats. The decorator that's 276 listed first in your code without as_format or accept_format is 277 the default that is chosen when no format is specifically asked for. 278 Any other expose calls that are missing as_format and accept_format 279 will have as_format implicitly set to the whatever comes before 280 the ':' in the template name (or the whole template name if there 281 is no ':'. For example, <code>expose('json')</code>, if it's not 282 the default expose, will have as_format set to 'json'. 283 284 When as_format is set, passing the same value in the tg_format 285 parameter in a request will choose the options for that expose 286 decorator. Similarly, accept_format will watch for matching 287 Accept headers. You can also use both. expose('json', as_format='json', 288 accept_format='application/json') will choose JSON output for either 289 case: tg_format='json' as a parameter or Accept: application/json as a 290 request header. 291 292 Passing allow_json=True to an expose decorator 293 is equivalent to adding the decorator just mentioned. 294 295 Each expose decorator has its own set of options, and each one 296 can choose a different template or even template engine (you can 297 use Kid for HTML output and Cheetah for plain text, for example). 298 See the other expose parameters below to learn about the options 299 you can pass to the template engine. 300 301 Take a look at the 302 <a href="tests/test_expose-source.html">test_expose.py</a> suite 303 for more examples. 304 305 @param template: 'templateengine:dotted.reference' reference along the 306 Python path for the template and the template engine. For 307 example, 'kid:foo.bar' will have Kid render the bar template in 308 the foo package. 309 @keyparam format: format for the template engine to output (if the 310 template engine can render different formats. Kid, for example, 311 can render 'html', 'xml' or 'xhtml') 312 @keyparam content_type: sets the content-type http header 313 @keyparam allow_json: allow the function to be exposed as json 314 @keyparam fragment: for template engines (like Kid) that generate 315 DOCTYPE declarations and the like, this is a signal to 316 just generate the immediate template fragment. Use this 317 if you're building up a page from multiple templates or 318 going to put something onto a page with .innerHTML. 319 @keyparam as_format: designates which value of tg_format will choose 320 this expose. 321 @keyparam accept_format: which value of an Accept: header will 322 choose this expose. 323 324 All additional keyword arguments are passed as keyword args to the render 325 method of the template engine. 326 327 """ 328 if not template: 329 template = format 330 331 if format == 'json' or (format is None and template is None 332 and (allow_json is None or allow_json)): 333 template = 'json' 334 allow_json = True 335 336 if content_type is None: 337 content_type = config.get('tg.content_type', None) 338 339 if config.get('tg.session.automatic_lock', None): 340 cherrypy.session.acquire_lock() 341 342 def entangle(func): 343 log.debug("Exposing %s", func) 344 log.debug("template: %s, format: %s, allow_json: %s, " 345 "content-type: %s", template, format, allow_json, content_type) 346 if not getattr(func, 'exposed', False): 347 def expose(func, *args, **kw): 348 accept = request.headers.get('Accept', "").lower() 349 accept = tg_util.simplify_http_accept_header(accept) 350 if not hasattr(func, '_expose'): 351 _build_rules(func) 352 try: 353 if hasattr(request, 'in_transaction'): 354 output = func._expose(func, accept, func._allow_json, 355 *args, **kw) 356 else: 357 request.in_transaction = True 358 output = database.run_with_transaction( 359 func._expose, func, accept, func._allow_json, 360 *args, **kw) 361 except NoApplicableMethods, e: 362 args = e.args # args from the last generic function call 363 if (args and args[0] and isinstance(args[0], tuple) 364 and args[0][0] is func): 365 # The error refers to our call above. This means that 366 # no suitable controller method was found (probably due 367 # to wrong parameters). So we will raise a "not found" 368 # error unless a specific error status was already set 369 # (e.g. "unauthorized" was set by the identity provider): 370 status = cherrypy.request.wsgi_environ.get('identity.status') 371 if status and str(status) >= '400': 372 raise cherrypy.HTTPError(*str(status).split(None, 1)) 373 raise cherrypy.NotFound 374 # If the error was raised elsewhere inside the controller, 375 # handle it like all other exceptions ("server error"): 376 raise 377 return output
378 func.exposed = True 379 func._ruleinfo = [] 380 allow_json_from_config = config.get('tg.allow_json', False) 381 func._allow_json = allow_json_from_config or template == 'json' 382 else: 383 expose = lambda func, *args, **kw: func(*args, **kw) 384 385 func._ruleinfo.insert(0, dict(as_format=as_format, 386 accept_format=accept_format, template=template, 387 rulefunc=lambda _func, accept, allow_json, *args, **kw: 388 _execute_func(_func, template, format, content_type, 389 fragment, options, args, kw))) 390 391 if allow_json: 392 func._allow_json = True 393 394 return expose 395 return weak_signature_decorator(entangle) 396
397 398 -def _execute_func(func, template, format, content_type, fragment, options, 399 args, kw):
400 """Call controller method and process its output.""" 401 402 if config.get('tg.strict_parameters', False): 403 tg_util.remove_keys(kw, ['tg_random', 'tg_format'] 404 + config.get('tg.ignore_parameters', [])) 405 406 else: 407 # get special parameters used by upstream decorators like paginate 408 try: 409 tg_kw = dict([(k, v) for k, v in kw.items() if k in func._tg_args]) 410 411 except AttributeError: 412 tg_kw = {} 413 414 # remove excessive parameters 415 args, kw = tg_util.adapt_call(func, args, kw) 416 # add special parameters again 417 kw.update(tg_kw) 418 419 env = config.get('environment') or 'development' 420 if env == 'development': 421 # Only output this in development mode: If it's a field storage object, 422 # this means big memory usage, and we don't want that in production 423 log.debug("Calling %s with *(%s), **(%s)", func, args, kw) 424 425 output = errorhandling.try_call(func, *args, **kw) 426 427 if str(getattr(response, 'status', '')).startswith('204'): 428 # HTTP status 204 indicates a response with no body 429 # so there should be no content type header 430 try: 431 del response.headers['Content-Type'] 432 except (AttributeError, KeyError): 433 pass 434 return 435 436 else: 437 assert isinstance(output, 438 (basestring, dict, list, types.GeneratorType)) or ( 439 hasattr(output, '__iter__') and hasattr(output, 'next')), ( 440 "Method %s.%s() returned unexpected output." 441 " Output should be of type basestring, dict, list or generator." 442 % (args[0].__class__.__name__, func.__name__)) 443 444 if isinstance(output, dict): 445 template = output.pop('tg_template', template) 446 format = output.pop('tg_format', format) 447 448 if isinstance(template, basestring) and template.startswith('.'): 449 template = func.__module__[:func.__module__.rfind('.')] + template 450 451 return _process_output(output, template, format, content_type, 452 fragment, **options)
453
454 455 -def flash(message):
456 """Set a message to be displayed in the browser on next page display.""" 457 message = tg_util.to_utf8(message) 458 if len(message) > 4000: 459 log.warning('Flash message exceeding maximum cookie size!') 460 response.cookie['tg_flash'] = message 461 response.cookie['tg_flash']['path'] = '/'
462
463 464 -def _get_flash():
465 """Retrieve the flash message (if one is set), clearing the message.""" 466 request_cookie = request.cookie 467 response_cookie = response.cookie 468 469 def clearcookie(): 470 response_cookie['tg_flash'] = '' 471 response_cookie['tg_flash']['expires'] = 0 472 response_cookie['tg_flash']['path'] = '/'
473 474 if 'tg_flash' in response_cookie: 475 message = response_cookie['tg_flash'].value 476 response_cookie.pop('tg_flash') 477 if 'tg_flash' in request_cookie: 478 # New flash overrided old one sitting in cookie. Clear that old cookie. 479 clearcookie() 480 elif 'tg_flash' in request_cookie: 481 message = request_cookie.value_decode(request_cookie['tg_flash'].value)[0] 482 if 'tg_flash' not in response_cookie: 483 clearcookie() 484 else: 485 message = None 486 if message: 487 message = unicode(message, 'utf-8') 488 return message 489
490 491 -class Controller(object):
492 """Base class for a web application's controller. 493 494 It is important that your controllers inherit from this class, otherwise 495 ``identity.SecureResource`` and ``identity.SecureObject`` will not work 496 correctly. 497 498 """ 499 500 is_app_root = None
501
502 503 -class RootController(Controller):
504 """Base class for the root of a web application. 505 506 Your web application must have one of these. The root of your application 507 is used to compute URLs used by your app. 508 509 """ 510 511 is_app_root = True
512 513 Root = RootController
514 515 516 -class ExposedDescriptor(object):
517 """Descriptor used by RESTMethod to tell if it is exposed.""" 518
519 - def __get__(self, obj, cls=None):
520 """Return True if object has a method for HTTP method of current request 521 """ 522 if cls is None: 523 cls = obj 524 cp_methodname = cherrypy.request.method 525 methodname = cp_methodname.lower() 526 method = getattr(cls, methodname, None) 527 if callable(method) and getattr(method, 'exposed', False): 528 return True 529 raise cherrypy.HTTPError(405, '%s not allowed on %s' % ( 530 cp_methodname, cherrypy.request.browser_url))
531
532 533 -class RESTMethod(Controller):
534 """Allow REST style dispatch based on different HTTP methods. 535 536 For an elaborate usage example see turbogears.tests.test_restmethod. 537 538 In short, instead of an exposed method, you define a sub-class of 539 RESTMethod inside the controller class and inside this class you define 540 exposed methods named after each HTTP method that should be supported. 541 542 Example:: 543 544 class Controller(controllers.Controller): 545 546 class article(copntrollers.RESTMethod): 547 @expose() 548 def get(self, id): 549 ... 550 551 @expose() 552 def post(self, id): 553 ... 554 555 """ 556 exposed = ExposedDescriptor() 557
558 - def __init__(self, *l, **kw):
559 methodname = cherrypy.request.method.lower() 560 self.result = getattr(self, methodname)(*l, **kw)
561
562 - def __iter__(self):
563 return iter(self.result)
564
565 566 -def url(tgpath, tgparams=None, **kw):
567 """Computes relocatable URLs. 568 569 tgpath can be a list or a string. If the path is absolute (starts with a 570 "/"), the server.webpath, SCRIPT_NAME and the approot of the application 571 are prepended to the path. In order for the approot to be detected 572 properly, the root object must extend controllers.RootController. 573 574 Query parameters for the URL can be passed in as a dictionary in 575 the second argument and/or as keyword parameters where keyword args 576 overwrite entries in the dictionary. 577 578 Values which are lists or tuples will create multiple key-value pairs. 579 580 tgpath may also already contain a (properly escaped) query string seperated 581 by a question mark, in which case additional query params are appended. 582 583 """ 584 if not isinstance(tgpath, basestring): 585 tgpath = '/'.join(list(tgpath)) 586 if tgpath.startswith('/'): 587 webpath = config.server.get('server.webpath', '') 588 if tg_util.request_available(): 589 tgpath = cp_url(tgpath, relative='server') 590 if not request.script_name.startswith(webpath): 591 # the virtual path dispatcher is not running 592 tgpath = webpath + tgpath 593 elif webpath: 594 # the server is not running 595 tgpath = webpath + tgpath 596 if tgparams is None: 597 tgparams = kw 598 else: 599 try: 600 tgparams = tgparams.copy() 601 tgparams.update(kw) 602 except AttributeError: 603 raise TypeError('url() expects a dictionary for query parameters') 604 args = [] 605 for key, value in tgparams.iteritems(): 606 if value is None: 607 continue 608 if isinstance(value, (list, tuple)): 609 pairs = [(key, v) for v in value] 610 else: 611 pairs = [(key, value)] 612 for k, v in pairs: 613 if v is None: 614 continue 615 if isinstance(v, unicode): 616 v = v.encode('utf-8') 617 args.append((k, str(v))) 618 if args: 619 query_string = urllib.urlencode(args, True) 620 if '?' in tgpath: 621 tgpath += '&' + query_string 622 else: 623 tgpath += '?' + query_string 624 return tgpath
625
626 627 -def get_server_name():
628 """Return name of the server this application runs on. 629 630 Respects 'Host' and 'X-Forwarded-Host' header. 631 632 See the docstring of the 'absolute_url' function for more information. 633 634 """ 635 get = config.get 636 h = request.headers 637 host = get('tg.url_domain') or h.get('X-Forwarded-Host', h.get('Host')) 638 if not host: 639 host = '%s:%s' % (get('server.socket_host', 'localhost'), 640 get('server.socket_port', 8080)) 641 return host
642
643 644 -def absolute_url(tgpath='/', params=None, **kw):
645 """Return absolute URL (including schema and host to this server). 646 647 Tries to account for 'Host' header and reverse proxying 648 ('X-Forwarded-Host'). 649 650 The host name is determined this way: 651 652 * If the config setting 'tg.url_domain' is set and non-null, use this value. 653 * Else, if the 'base_url_filter.use_x_forwarded_host' config setting is 654 True, use the value from the 'Host' or 'X-Forwarded-Host' request header. 655 * Else, if config setting 'base_url_filter.on' is True and 656 'base_url_filter.base_url' is non-null, use its value for the host AND 657 scheme part of the URL. 658 * As a last fallback, use the value of 'server.socket_host' and 659 'server.socket_port' config settings (defaults to 'localhost:8080'). 660 661 The URL scheme ('http' or 'http') used is determined in the following way: 662 663 * If 'base_url_filter.base_url' is used, use the scheme from this URL. 664 * If there is a 'X-Use-SSL' request header, use 'https'. 665 * Else, if the config setting 'tg.url_scheme' is set, use its value. 666 * Else, use the value of 'cherrypy.request.scheme'. 667 668 """ 669 get = config.get 670 use_xfh = get('base_url_filter.use_x_forwarded_host', False) 671 if request.headers.get('X-Use-SSL'): 672 scheme = 'https' 673 else: 674 scheme = get('tg.url_scheme') 675 if not scheme: 676 scheme = request.scheme 677 base_url = '%s://%s' % (scheme, get_server_name()) 678 if get('base_url_filter.on', False) and not use_xfh: 679 base_url = get('base_url_filter.base_url').rstrip('/') 680 return '%s%s' % (base_url, url(tgpath, params, **kw))
681
682 683 -def redirect(redirect_path, redirect_params=None, **kw):
684 """Redirect (via cherrypy.HTTPRedirect). 685 686 Raises the exception instead of returning it, this to allow 687 users to both call it as a function or to raise it as an exception. 688 689 """ 690 if not isinstance(redirect_path, basestring): 691 redirect_path = '/'.join(list(redirect_path)) 692 if not (redirect_path.startswith('/') 693 or redirect_path.startswith('http://') 694 or redirect_path.startswith('https://')): 695 redirect_path = urlparse.urljoin(request.path_info, redirect_path) 696 raise cherrypy.HTTPRedirect(url(redirect_path, redirect_params, **kw))
697