Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/trunk/cherrypy/lib/cptools.py

Revision 2641 (checked in by chbrown, 5 months ago)

Sphinx docs in cherrypy.lib.*

  • Property svn:eol-style set to native
Line 
1 """Functions for builtin CherryPy tools."""
2
3 import logging
4 try:
5     # Python 2.5+
6     from hashlib import md5
7 except ImportError:
8     from md5 import new as md5
9 import re
10
11 try:
12     set
13 except NameError:
14     from sets import Set as set
15
16 import cherrypy
17 from cherrypy.lib import httputil as _httputil
18
19
20 #                     Conditional HTTP request support                     #
21
22 def validate_etags(autotags=False, debug=False):
23     """Validate the current ETag against If-Match, If-None-Match headers.
24     
25     If autotags is True, an ETag response-header value will be provided
26     from an MD5 hash of the response body (unless some other code has
27     already provided an ETag header). If False (the default), the ETag
28     will not be automatic.
29     
30     WARNING: the autotags feature is not designed for URL's which allow
31     methods other than GET. For example, if a POST to the same URL returns
32     no content, the automatic ETag will be incorrect, breaking a fundamental
33     use for entity tags in a possibly destructive fashion. Likewise, if you
34     raise 304 Not Modified, the response body will be empty, the ETag hash
35     will be incorrect, and your application will break.
36     See :rfc:`2616` Section 14.24.
37     """
38     response = cherrypy.serving.response
39    
40     # Guard against being run twice.
41     if hasattr(response, "ETag"):
42         return
43    
44     status, reason, msg = _httputil.valid_status(response.status)
45    
46     etag = response.headers.get('ETag')
47    
48     # Automatic ETag generation. See warning in docstring.
49     if etag:
50         if debug:
51             cherrypy.log('ETag already set: %s' % etag, 'TOOLS.ETAGS')
52     elif not autotags:
53         if debug:
54             cherrypy.log('Autotags off', 'TOOLS.ETAGS')
55     elif status != 200:
56         if debug:
57             cherrypy.log('Status not 200', 'TOOLS.ETAGS')
58     else:
59         etag = response.collapse_body()
60         etag = '"%s"' % md5(etag).hexdigest()
61         if debug:
62             cherrypy.log('Setting ETag: %s' % etag, 'TOOLS.ETAGS')
63         response.headers['ETag'] = etag
64    
65     response.ETag = etag
66    
67     # "If the request would, without the If-Match header field, result in
68     # anything other than a 2xx or 412 status, then the If-Match header
69     # MUST be ignored."
70     if debug:
71         cherrypy.log('Status: %s' % status, 'TOOLS.ETAGS')
72     if status >= 200 and status <= 299:
73         request = cherrypy.serving.request
74        
75         conditions = request.headers.elements('If-Match') or []
76         conditions = [str(x) for x in conditions]
77         if debug:
78             cherrypy.log('If-Match conditions: %s' % repr(conditions),
79                          'TOOLS.ETAGS')
80         if conditions and not (conditions == ["*"] or etag in conditions):
81             raise cherrypy.HTTPError(412, "If-Match failed: ETag %r did "
82                                      "not match %r" % (etag, conditions))
83        
84         conditions = request.headers.elements('If-None-Match') or []
85         conditions = [str(x) for x in conditions]
86         if debug:
87             cherrypy.log('If-None-Match conditions: %s' % repr(conditions),
88                          'TOOLS.ETAGS')
89         if conditions == ["*"] or etag in conditions:
90             if debug:
91                 cherrypy.log('request.method: %s' % request.method, 'TOOLS.ETAGS')
92             if request.method in ("GET", "HEAD"):
93                 raise cherrypy.HTTPRedirect([], 304)
94             else:
95                 raise cherrypy.HTTPError(412, "If-None-Match failed: ETag %r "
96                                          "matched %r" % (etag, conditions))
97
98 def validate_since():
99     """Validate the current Last-Modified against If-Modified-Since headers.
100     
101     If no code has set the Last-Modified response header, then no validation
102     will be performed.
103     """
104     response = cherrypy.serving.response
105     lastmod = response.headers.get('Last-Modified')
106     if lastmod:
107         status, reason, msg = _httputil.valid_status(response.status)
108        
109         request = cherrypy.serving.request
110        
111         since = request.headers.get('If-Unmodified-Since')
112         if since and since != lastmod:
113             if (status >= 200 and status <= 299) or status == 412:
114                 raise cherrypy.HTTPError(412)
115        
116         since = request.headers.get('If-Modified-Since')
117         if since and since == lastmod:
118             if (status >= 200 and status <= 299) or status == 304:
119                 if request.method in ("GET", "HEAD"):
120                     raise cherrypy.HTTPRedirect([], 304)
121                 else:
122                     raise cherrypy.HTTPError(412)
123
124
125 #                                Tool code                                #
126
127 def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For',
128           scheme='X-Forwarded-Proto', debug=False):
129     """Change the base URL (scheme://host[:port][/path]).
130     
131     For running a CP server behind Apache, lighttpd, or other HTTP server.
132     
133     If you want the new request.base to include path info (not just the host),
134     you must explicitly set base to the full base path, and ALSO set 'local'
135     to '', so that the X-Forwarded-Host request header (which never includes
136     path info) does not override it. Regardless, the value for 'base' MUST
137     NOT end in a slash.
138     
139     cherrypy.request.remote.ip (the IP address of the client) will be
140     rewritten if the header specified by the 'remote' arg is valid.
141     By default, 'remote' is set to 'X-Forwarded-For'. If you do not
142     want to rewrite remote.ip, set the 'remote' arg to an empty string.
143     """
144    
145     request = cherrypy.serving.request
146    
147     if scheme:
148         s = request.headers.get(scheme, None)
149         if debug:
150             cherrypy.log('Testing scheme %r:%r' % (scheme, s), 'TOOLS.PROXY')
151         if s == 'on' and 'ssl' in scheme.lower():
152             # This handles e.g. webfaction's 'X-Forwarded-Ssl: on' header
153             scheme = 'https'
154         else:
155             # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https'
156             scheme = s
157     if not scheme:
158         scheme = request.base[:request.base.find("://")]
159    
160     if local:
161         lbase = request.headers.get(local, None)
162         if debug:
163             cherrypy.log('Testing local %r:%r' % (local, lbase), 'TOOLS.PROXY')
164         if lbase is not None:
165             base = lbase.split(',')[0]
166     if not base:
167         port = request.local.port
168         if port == 80:
169             base = '127.0.0.1'
170         else:
171             base = '127.0.0.1:%s' % port
172    
173     if base.find("://") == -1:
174         # add http:// or https:// if needed
175         base = scheme + "://" + base
176    
177     request.base = base
178    
179     if remote:
180         xff = request.headers.get(remote)
181         if debug:
182             cherrypy.log('Testing remote %r:%r' % (remote, xff), 'TOOLS.PROXY')
183         if xff:
184             if remote == 'X-Forwarded-For':
185                 # See http://bob.pythonmac.org/archives/2005/09/23/apache-x-forwarded-for-caveat/
186                 xff = xff.split(',')[-1].strip()
187             request.remote.ip = xff
188
189
190 def ignore_headers(headers=('Range',), debug=False):
191     """Delete request headers whose field names are included in 'headers'.
192     
193     This is a useful tool for working behind certain HTTP servers;
194     for example, Apache duplicates the work that CP does for 'Range'
195     headers, and will doubly-truncate the response.
196     """
197     request = cherrypy.serving.request
198     for name in headers:
199         if name in request.headers:
200             if debug:
201                 cherrypy.log('Ignoring request header %r' % name,
202                              'TOOLS.IGNORE_HEADERS')
203             del request.headers[name]
204
205
206 def response_headers(headers=None, debug=False):
207     """Set headers on the response."""
208     if debug:
209         cherrypy.log('Setting response headers: %s' % repr(headers),
210                      'TOOLS.RESPONSE_HEADERS')
211     for name, value in (headers or []):
212         cherrypy.serving.response.headers[name] = value
213 response_headers.failsafe = True
214
215
216 def referer(pattern, accept=True, accept_missing=False, error=403,
217             message='Forbidden Referer header.', debug=False):
218     """Raise HTTPError if Referer header does/does not match the given pattern.
219     
220     pattern
221         A regular expression pattern to test against the Referer.
222         
223     accept
224         If True, the Referer must match the pattern; if False,
225         the Referer must NOT match the pattern.
226
227     accept_missing
228         If True, permit requests with no Referer header.
229
230     error
231         The HTTP error code to return to the client on failure.
232         
233     message
234         A string to include in the response body on failure.
235     
236     """
237     try:
238         ref = cherrypy.serving.request.headers['Referer']
239         match = bool(re.match(pattern, ref))
240         if debug:
241             cherrypy.log('Referer %r matches %r' % (ref, pattern),
242                          'TOOLS.REFERER')
243         if accept == match:
244             return
245     except KeyError:
246         if debug:
247             cherrypy.log('No Referer header', 'TOOLS.REFERER')
248         if accept_missing:
249             return
250    
251     raise cherrypy.HTTPError(error, message)
252
253
254 class SessionAuth(object):
255     """Assert that the user is logged in."""
256    
257     session_key = "username"
258     debug = False
259    
260     def check_username_and_password(self, username, password):
261         pass
262    
263     def anonymous(self):
264         """Provide a temporary user name for anonymous users."""
265         pass
266    
267     def on_login(self, username):
268         pass
269    
270     def on_logout(self, username):
271         pass
272    
273     def on_check(self, username):
274         pass
275    
276     def login_screen(self, from_page='..', username='', error_msg='', **kwargs):
277         return """<html><body>
278 Message: %(error_msg)s
279 <form method="post" action="do_login">
280     Login: <input type="text" name="username" value="%(username)s" size="10" /><br />
281     Password: <input type="password" name="password" size="10" /><br />
282     <input type="hidden" name="from_page" value="%(from_page)s" /><br />
283     <input type="submit" />
284 </form>
285 </body></html>""" % {'from_page': from_page, 'username': username,
286                      'error_msg': error_msg}
287    
288     def do_login(self, username, password, from_page='..', **kwargs):
289         """Login. May raise redirect, or return True if request handled."""
290         response = cherrypy.serving.response
291         error_msg = self.check_username_and_password(username, password)
292         if error_msg:
293             body = self.login_screen(from_page, username, error_msg)
294             response.body = body
295             if "Content-Length" in response.headers:
296                 # Delete Content-Length header so finalize() recalcs it.
297                 del response.headers["Content-Length"]
298             return True
299         else:
300             cherrypy.serving.request.login = username
301             cherrypy.session[self.session_key] = username
302             self.on_login(username)
303             raise cherrypy.HTTPRedirect(from_page or "/")
304    
305     def do_logout(self, from_page='..', **kwargs):
306         """Logout. May raise redirect, or return True if request handled."""
307         sess = cherrypy.session
308         username = sess.get(self.session_key)
309         sess[self.session_key] = None
310         if username:
311             cherrypy.serving.request.login = None
312             self.on_logout(username)
313         raise cherrypy.HTTPRedirect(from_page)
314    
315     def do_check(self):
316         """Assert username. May raise redirect, or return True if request handled."""
317         sess = cherrypy.session
318         request = cherrypy.serving.request
319         response = cherrypy.serving.response
320        
321         username = sess.get(self.session_key)
322         if not username:
323             sess[self.session_key] = username = self.anonymous()
324             if self.debug:
325                 cherrypy.log('No session[username], trying anonymous', 'TOOLS.SESSAUTH')
326         if not username:
327             url = cherrypy.url(qs=request.query_string)
328             if self.debug:
329                 cherrypy.log('No username, routing to login_screen with '
330                              'from_page %r' % url, 'TOOLS.SESSAUTH')
331             response.body = self.login_screen(url)
332             if "Content-Length" in response.headers:
333                 # Delete Content-Length header so finalize() recalcs it.
334                 del response.headers["Content-Length"]
335             return True
336         if self.debug:
337             cherrypy.log('Setting request.login to %r' % username, 'TOOLS.SESSAUTH')
338         request.login = username
339         self.on_check(username)
340    
341     def run(self):
342         request = cherrypy.serving.request
343         response = cherrypy.serving.response
344        
345         path = request.path_info
346         if path.endswith('login_screen'):
347             if self.debug:
348                 cherrypy.log('routing %r to login_screen' % path, 'TOOLS.SESSAUTH')
349             return self.login_screen(**request.params)
350         elif path.endswith('do_login'):
351             if request.method != 'POST':
352                 response.headers['Allow'] = "POST"
353                 if self.debug:
354                     cherrypy.log('do_login requires POST', 'TOOLS.SESSAUTH')
355                 raise cherrypy.HTTPError(405)
356             if self.debug:
357                 cherrypy.log('routing %r to do_login' % path, 'TOOLS.SESSAUTH')
358             return self.do_login(**request.params)
359         elif path.endswith('do_logout'):
360             if request.method != 'POST':
361                 response.headers['Allow'] = "POST"
362                 raise cherrypy.HTTPError(405)
363             if self.debug:
364                 cherrypy.log('routing %r to do_logout' % path, 'TOOLS.SESSAUTH')
365             return self.do_logout(**request.params)
366         else:
367             if self.debug:
368                 cherrypy.log('No special path, running do_check', 'TOOLS.SESSAUTH')
369             return self.do_check()
370
371
372 def session_auth(**kwargs):
373     sa = SessionAuth()
374     for k, v in kwargs.items():
375         setattr(sa, k, v)
376     return sa.run()
377 session_auth.__doc__ = """Session authentication hook.
378
379 Any attribute of the SessionAuth class may be overridden via a keyword arg
380 to this function:
381
382 """ + "\n".join(["%s: %s" % (k, type(getattr(SessionAuth, k)).__name__)
383                  for k in dir(SessionAuth) if not k.startswith("__")])
384
385
386 def log_traceback(severity=logging.ERROR, debug=False):
387     """Write the last error's traceback to the cherrypy error log."""
388     cherrypy.log("", "HTTP", severity=severity, traceback=True)
389
390 def log_request_headers(debug=False):
391     """Write request headers to the cherrypy error log."""
392     h = ["  %s: %s" % (k, v) for k, v in cherrypy.serving.request.header_list]
393     cherrypy.log('\nRequest Headers:\n' + '\n'.join(h), "HTTP")
394
395 def log_hooks(debug=False):
396     """Write request.hooks to the cherrypy error log."""
397     request = cherrypy.serving.request
398    
399     msg = []
400     # Sort by the standard points if possible.
401     from cherrypy import _cprequest
402     points = _cprequest.hookpoints
403     for k in request.hooks.keys():
404         if k not in points:
405             points.append(k)
406    
407     for k in points:
408         msg.append("    %s:" % k)
409         v = request.hooks.get(k, [])
410         v.sort()
411         for h in v:
412             msg.append("        %r" % h)
413     cherrypy.log('\nRequest Hooks for ' + cherrypy.url() +
414                  ':\n' + '\n'.join(msg), "HTTP")
415
416 def redirect(url='', internal=True, debug=False):
417     """Raise InternalRedirect or HTTPRedirect to the given url."""
418     if debug:
419         cherrypy.log('Redirecting %sto: %s' %
420                      ({True: 'internal ', False: ''}[internal], url),
421                      'TOOLS.REDIRECT')
422     if internal:
423         raise cherrypy.InternalRedirect(url)
424     else:
425         raise cherrypy.HTTPRedirect(url)
426
427 def trailing_slash(missing=True, extra=False, status=None, debug=False):
428     """Redirect if path_info has (missing|extra) trailing slash."""
429     request = cherrypy.serving.request
430     pi = request.path_info
431    
432     if debug:
433         cherrypy.log('is_index: %r, missing: %r, extra: %r, path_info: %r' %
434                      (request.is_index, missing, extra, pi),
435                      'TOOLS.TRAILING_SLASH')
436     if request.is_index is True:
437         if missing:
438             if not pi.endswith('/'):
439                 new_url = cherrypy.url(pi + '/', request.query_string)
440                 raise cherrypy.HTTPRedirect(new_url, status=status or 301)
441     elif request.is_index is False:
442         if extra:
443             # If pi == '/', don't redirect to ''!
444             if pi.endswith('/') and pi != '/':
445                 new_url = cherrypy.url(pi[:-1], request.query_string)
446                 raise cherrypy.HTTPRedirect(new_url, status=status or 301)
447
448 def flatten(debug=False):
449     """Wrap response.body in a generator that recursively iterates over body.
450     
451     This allows cherrypy.response.body to consist of 'nested generators';
452     that is, a set of generators that yield generators.
453     """
454     import types
455     def flattener(input):
456         numchunks = 0
457         for x in input:
458             if not isinstance(x, types.GeneratorType):
459                 numchunks += 1
460                 yield x
461             else:
462                 for y in flattener(x):
463                     numchunks += 1
464                     yield y
465         if debug:
466             cherrypy.log('Flattened %d chunks' % numchunks, 'TOOLS.FLATTEN')
467     response = cherrypy.serving.response
468     response.body = flattener(response.body)
469
470
471 def accept(media=None, debug=False):
472     """Return the client's preferred media-type (from the given Content-Types).
473     
474     If 'media' is None (the default), no test will be performed.
475     
476     If 'media' is provided, it should be the Content-Type value (as a string)
477     or values (as a list or tuple of strings) which the current resource
478     can emit. The client's acceptable media ranges (as declared in the
479     Accept request header) will be matched in order to these Content-Type
480     values; the first such string is returned. That is, the return value
481     will always be one of the strings provided in the 'media' arg (or None
482     if 'media' is None).
483     
484     If no match is found, then HTTPError 406 (Not Acceptable) is raised.
485     Note that most web browsers send */* as a (low-quality) acceptable
486     media range, which should match any Content-Type. In addition, "...if
487     no Accept header field is present, then it is assumed that the client
488     accepts all media types."
489     
490     Matching types are checked in order of client preference first,
491     and then in the order of the given 'media' values.
492     
493     Note that this function does not honor accept-params (other than "q").
494     """
495     if not media:
496         return
497     if isinstance(media, basestring):
498         media = [media]
499     request = cherrypy.serving.request
500    
501     # Parse the Accept request header, and try to match one
502     # of the requested media-ranges (in order of preference).
503     ranges = request.headers.elements('Accept')
504     if not ranges:
505         # Any media type is acceptable.
506         if debug:
507             cherrypy.log('No Accept header elements', 'TOOLS.ACCEPT')
508         return media[0]
509     else:
510         # Note that 'ranges' is sorted in order of preference
511         for element in ranges:
512             if element.qvalue > 0:
513                 if element.value == "*/*":
514                     # Matches any type or subtype
515                     if debug:
516                         cherrypy.log('Match due to */*', 'TOOLS.ACCEPT')
517                     return media[0]
518                 elif element.value.endswith("/*"):
519                     # Matches any subtype
520                     mtype = element.value[:-1]  # Keep the slash
521                     for m in media:
522                         if m.startswith(mtype):
523                             if debug:
524                                 cherrypy.log('Match due to %s' % element.value,
525                                              'TOOLS.ACCEPT')
526                             return m
527                 else:
528                     # Matches exact value
529                     if element.value in media:
530                         if debug:
531                             cherrypy.log('Match due to %s' % element.value,
532                                          'TOOLS.ACCEPT')
533                         return element.value
534    
535     # No suitable media-range found.
536     ah = request.headers.get('Accept')
537     if ah is None:
538         msg = "Your client did not send an Accept header."
539     else:
540         msg = "Your client sent this Accept header: %s." % ah
541     msg += (" But this resource only emits these media types: %s." %
542             ", ".join(media))
543     raise cherrypy.HTTPError(406, msg)
544
545
546 class MonitoredHeaderMap(_httputil.HeaderMap):
547    
548     def __init__(self):
549         self.accessed_headers = set()
550    
551     def __getitem__(self, key):
552         self.accessed_headers.add(key)
553         return _httputil.HeaderMap.__getitem__(self, key)
554    
555     def __contains__(self, key):
556         self.accessed_headers.add(key)
557         return _httputil.HeaderMap.__contains__(self, key)
558    
559     def get(self, key, default=None):
560         self.accessed_headers.add(key)
561         return _httputil.HeaderMap.get(self, key, default=default)
562    
563     def has_key(self, key):
564         self.accessed_headers.add(key)
565         return _httputil.HeaderMap.has_key(self, key)
566
567
568 def autovary(ignore=None, debug=False):
569     """Auto-populate the Vary response header based on request.header access."""
570     request = cherrypy.serving.request
571    
572     req_h = request.headers
573     request.headers = MonitoredHeaderMap()
574     request.headers.update(req_h)
575     if ignore is None:
576         ignore = set(['Content-Disposition', 'Content-Length', 'Content-Type'])
577    
578     def set_response_header():
579         resp_h = cherrypy.serving.response.headers
580         v = set([e.value for e in resp_h.elements('Vary')])
581         if debug:
582             cherrypy.log('Accessed headers: %s' % request.headers.accessed_headers,
583                          'TOOLS.AUTOVARY')
584         v = v.union(request.headers.accessed_headers)
585         v = v.difference(ignore)
586         v = list(v)
587         v.sort()
588         resp_h['Vary'] = ', '.join(v)
589     request.hooks.attach('before_finalize', set_response_header, 95)
590
Note: See TracBrowser for help on using the browser.

Hosted by WebFaction

Log in as guest/cpguest to create tickets