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

root/branches/cherrypy-2.x/cherrypy/lib/cptools.py

Revision 1525 (checked in by fumanchu, 2 years ago)

2.x fix for #609 (Support for IronPython? 1.0).

  • Property svn:eol-style set to native
Line 
1 """Tools which both CherryPy and application developers may invoke."""
2
3 import md5
4 import mimetools
5 import mimetypes
6 mimetypes.init()
7 mimetypes.types_map['.dwg']='image/x-dwg'
8 mimetypes.types_map['.ico']='image/x-icon'
9
10 import os
11 import re
12 import stat as _stat
13 import sys
14 import time
15
16 import cherrypy
17 import httptools
18
19 from cherrypy.filters.wsgiappfilter import WSGIAppFilter
20
21
22 def decorate(func, decorator):
23     """
24     Return the decorated func. This will automatically copy all
25     non-standard attributes (like exposed) to the newly decorated function.
26     """
27     newfunc = decorator(func)
28     for key in dir(func):
29         if not hasattr(newfunc, key):
30             setattr(newfunc, key, getattr(func, key))
31     return newfunc
32
33 def decorateAll(obj, decorator):
34     """
35     Recursively decorate all exposed functions of obj and all of its children,
36     grandchildren, etc. If you used to use aspects, you might want to look
37     into these. This function modifies obj; there is no return value.
38     """
39     obj_type = type(obj)
40     for key in dir(obj):
41         # only deal with user-defined attributes
42         if hasattr(obj_type, key):
43             value = getattr(obj, key)
44             if callable(value) and getattr(value, "exposed", False):
45                 setattr(obj, key, decorate(value, decorator))
46             decorateAll(value, decorator)
47
48
49 class ExposeItems:
50     """
51     Utility class that exposes a getitem-aware object. It does not provide
52     index() or default() methods, and it does not expose the individual item
53     objects - just the list or dict that contains them. User-specific index()
54     and default() methods can be implemented by inheriting from this class.
55     
56     Use case:
57     
58     from cherrypy.lib.cptools import ExposeItems
59     ...
60     cherrypy.root.foo = ExposeItems(mylist)
61     cherrypy.root.bar = ExposeItems(mydict)
62     """
63     exposed = True
64     def __init__(self, items):
65         self.items = items
66     def __getattr__(self, key):
67         return self.items[key]
68
69
70 #                     Conditional HTTP request support                     #
71
72 def validate_etags(autotags=False):
73     """Validate the current ETag against If-Match, If-None-Match headers.
74     
75     If autotags is True, an ETag response-header value will be provided
76     from an MD5 hash of the response body (unless some other code has
77     already provided an ETag header). If False (the default), the ETag
78     will not be automatic.
79     
80     WARNING: the autotags feature is not designed for URL's which allow
81     methods other than GET. For example, if a POST to the same URL returns
82     no content, the automatic ETag will be incorrect, breaking a fundamental
83     use for entity tags in a possibly destructive fashion. Likewise, if you
84     raise 304 Not Modified, the response body will be empty, the ETag hash
85     will be incorrect, and your application will break.
86     See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.24
87     """
88     response = cherrypy.response
89    
90     # Guard against being run twice.
91     if hasattr(response, "ETag"):
92         return
93    
94     status, reason, msg = httptools.validStatus(response.status)
95    
96     etag = response.headers.get('ETag')
97    
98     # Automatic ETag generation. See warning in docstring.
99     if (not etag) and autotags:
100         if status == 200:
101             etag = response.collapse_body()
102             etag = '"%s"' % md5.new(etag).hexdigest()
103             response.headers['ETag'] = etag
104    
105     response.ETag = etag
106    
107     # "If the request would, without the If-Match header field, result in
108     # anything other than a 2xx or 412 status, then the If-Match header
109     # MUST be ignored."
110     if status >= 200 and status <= 299:
111         request = cherrypy.request
112        
113         conditions = request.headers.elements('If-Match') or []
114         conditions = [str(x) for x in conditions]
115         if conditions and not (conditions == ["*"] or etag in conditions):
116             raise cherrypy.HTTPError(412, "If-Match failed: ETag %r did "
117                                      "not match %r" % (etag, conditions))
118        
119         conditions = request.headers.elements('If-None-Match') or []
120         conditions = [str(x) for x in conditions]
121         if conditions == ["*"] or etag in conditions:
122             if request.method in ("GET", "HEAD"):
123                 raise cherrypy.HTTPRedirect([], 304)
124             else:
125                 raise cherrypy.HTTPError(412, "If-None-Match failed: ETag %r "
126                                          "matched %r" % (etag, conditions))
127
128 def validate_since():
129     """Validate the current Last-Modified against If-Modified-Since headers.
130     
131     If no code has set the Last-Modified response header, then no validation
132     will be performed.
133     """
134     response = cherrypy.response
135     lastmod = response.headers.get('Last-Modified')
136     if lastmod:
137         status, reason, msg = httptools.validStatus(response.status)
138        
139         request = cherrypy.request
140        
141         since = request.headers.get('If-Unmodified-Since')
142         if since and since != lastmod:
143             if (status >= 200 and status <= 299) or status == 412:
144                 raise cherrypy.HTTPError(412)
145        
146         since = request.headers.get('If-Modified-Since')
147         if since and since == lastmod:
148             if (status >= 200 and status <= 299) or status == 304:
149                 if request.method in ("GET", "HEAD"):
150                     raise cherrypy.HTTPRedirect([], 304)
151                 else:
152                     raise cherrypy.HTTPError(412)
153
154
155 def modified_since(path, stat=None):
156     """Check whether a file has been modified since the date
157     provided in 'If-Modified-Since'
158     It doesn't check if the file exists or not
159     Return True if has been modified, False otherwise
160     """
161     # serveFile already creates a stat object so let's not
162     # waste our energy to do it again
163     if not stat:
164         try:
165             stat = os.stat(path)
166         except OSError:
167             if cherrypy.config.get('server.log_file_not_found', False):
168                 cherrypy.log("    NOT FOUND file: %s" % path, "DEBUG")
169             raise cherrypy.NotFound()
170    
171     response = cherrypy.response
172     strModifTime = httptools.HTTPDate(time.gmtime(stat.st_mtime))
173     if cherrypy.request.headers.has_key('If-Modified-Since'):
174         if cherrypy.request.headers['If-Modified-Since'] == strModifTime:
175             raise cherrypy.HTTPRedirect([], 304)
176     response.headers['Last-Modified'] = strModifTime
177     return True
178
179 def serveFile(path, contentType=None, disposition=None, name=None):
180     """Set status, headers, and body in order to serve the given file.
181     
182     The Content-Type header will be set to the contentType arg, if provided.
183     If not provided, the Content-Type will be guessed by its extension.
184     
185     If disposition is not None, the Content-Disposition header will be set
186     to "<disposition>; filename=<name>". If name is None, it will be set
187     to the basename of path. If disposition is None, no Content-Disposition
188     header will be written.
189     """
190    
191     response = cherrypy.response
192    
193     # If path is relative, users should fix it by making path absolute.
194     # That is, CherryPy should not guess where the application root is.
195     # It certainly should *not* use cwd (since CP may be invoked from a
196     # variety of paths). If using static_filter, you can make your relative
197     # paths become absolute by supplying a value for "static_filter.root".
198     if not os.path.isabs(path):
199         raise ValueError("'%s' is not an absolute path." % path)
200    
201     try:
202         stat = os.stat(path)
203     except OSError:
204         if cherrypy.config.get('server.log_file_not_found', False):
205             cherrypy.log("    NOT FOUND file: %s" % path, "DEBUG")
206         raise cherrypy.NotFound()
207    
208     # Check if path is a directory.
209     if _stat.S_ISDIR(stat.st_mode):
210         # Let the caller deal with it as they like.
211         raise cherrypy.NotFound()
212    
213     if contentType is None:
214         # Set content-type based on filename extension
215         ext = ""
216         i = path.rfind('.')
217         if i != -1:
218             ext = path[i:].lower()
219         contentType = mimetypes.types_map.get(ext, "text/plain")
220     response.headers['Content-Type'] = contentType
221    
222     # Set the Last-Modified response header, so that
223     # modified-since validation code can work.
224     response.headers['Last-Modified'] = httptools.HTTPDate(time.gmtime(stat.st_mtime))
225     validate_since()
226    
227     if disposition is not None:
228         if name is None:
229             name = os.path.basename(path)
230         cd = '%s; filename="%s"' % (disposition, name)
231         response.headers["Content-Disposition"] = cd
232    
233     # Set Content-Length and use an iterable (file object)
234     #   this way CP won't load the whole file in memory
235     c_len = stat.st_size
236     bodyfile = open(path, 'rb')
237     if getattr(cherrypy, "debug", None):
238         cherrypy.log("    Found file: %s" % path, "DEBUG")
239    
240     # HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code
241     if cherrypy.response.version >= "1.1":
242         response.headers["Accept-Ranges"] = "bytes"
243         r = httptools.getRanges(cherrypy.request.headers.get('Range'), c_len)
244         if r == []:
245             response.headers['Content-Range'] = "bytes */%s" % c_len
246             message = "Invalid Range (first-byte-pos greater than Content-Length)"
247             raise cherrypy.HTTPError(416, message)
248         if r:
249             if len(r) == 1:
250                 # Return a single-part response.
251                 start, stop = r[0]
252                 r_len = stop - start
253                 response.status = "206 Partial Content"
254                 response.headers['Content-Range'] = ("bytes %s-%s/%s" %
255                                                        (start, stop - 1, c_len))
256                 response.headers['Content-Length'] = r_len
257                 bodyfile.seek(start)
258                 response.body = bodyfile.read(r_len)
259             else:
260                 # Return a multipart/byteranges response.
261                 response.status = "206 Partial Content"
262                 boundary = mimetools.choose_boundary()
263                 ct = "multipart/byteranges; boundary=%s" % boundary
264                 response.headers['Content-Type'] = ct
265 ##                del response.headers['Content-Length']
266                
267                 def fileRanges():
268                     # Apache compatibility:
269                     yield "\r\n"
270                    
271                     for start, stop in r:
272                         yield "--" + boundary
273                         yield "\r\nContent-type: %s" % contentType
274                         yield ("\r\nContent-range: bytes %s-%s/%s\r\n\r\n"
275                                % (start, stop - 1, c_len))
276                         bodyfile.seek(start)
277                         yield bodyfile.read(stop - start)
278                         yield "\r\n"
279                     # Final boundary
280                     yield "--" + boundary + "--"
281                    
282                     # Apache compatibility:
283                     yield "\r\n"
284                 response.body = fileRanges()
285         else:
286             response.headers['Content-Length'] = c_len
287             response.body = bodyfile
288     else:
289         response.headers['Content-Length'] = c_len
290         response.body = bodyfile
291     return response.body
292 serve_file = serveFile
293
294 def serve_download(path, name=None):
295     """Serve 'path' as an application/x-download attachment."""
296     # This is such a common idiom I felt it deserved its own wrapper.
297     return serve_file(path, "application/x-download", "attachment", name)
298
299 def fileGenerator(input, chunkSize=65536):
300     """Yield the given input (a file object) in chunks (default 64k)."""
301     chunk = input.read(chunkSize)
302     while chunk:
303         yield chunk
304         chunk = input.read(chunkSize)
305     input.close()
306
307 def modules(modulePath):
308     """Load a module and retrieve a reference to that module."""
309     try:
310         mod = sys.modules[modulePath]
311         if mod is None:
312             raise KeyError()
313     except KeyError:
314         # The last [''] is important.
315         mod = __import__(modulePath, globals(), locals(), [''])
316     return mod
317
318 def attributes(fullAttributeName):
319     """Load a module and retrieve an attribute of that module."""
320    
321     # Parse out the path, module, and attribute
322     lastDot = fullAttributeName.rfind(u".")
323     attrName = fullAttributeName[lastDot + 1:]
324     modPath = fullAttributeName[:lastDot]
325    
326     aMod = modules(modPath)
327     # Let an AttributeError propagate outward.
328     try:
329         attr = getattr(aMod, attrName)
330     except AttributeError:
331         raise AttributeError("'%s' object has no attribute '%s'"
332                              % (modPath, attrName))
333    
334     # Return a reference to the attribute.
335     return attr
336
337
338 class WSGIApp(object):
339     """a convenience class that uses the WSGIAppFilter
340     
341     to easily add a WSGI application to the CP object tree.
342
343     example:
344     cherrypy.tree.mount(SomeRoot(), '/')
345     cherrypy.tree.mount(WSGIApp(other_wsgi_app), '/ext_app')
346     """
347     def __init__(self, app, env_update=None):
348         self._cpFilterList = [WSGIAppFilter(app, env_update)]
349
350
351 # public domain "unrepr" implementation, found on the web and then improved.
352
353 def getObj(s):
354     try:
355         import compiler
356     except ImportError:
357         # Fallback to eval when compiler package is not available,
358         # e.g. IronPython 1.0.
359         return eval(s)
360    
361     s = "a=" + s
362     p = compiler.parse(s)
363     return p.getChildren()[1].getChildren()[0].getChildren()[1]
364
365
366 class UnknownType(Exception):
367     pass
368
369
370 class Builder:
371    
372     def build(self, o):
373         m = getattr(self, 'build_' + o.__class__.__name__, None)
374         if m is None:
375             raise UnknownType(o.__class__.__name__)
376         return m(o)
377    
378     def build_CallFunc(self, o):
379         callee, args, starargs, kwargs = map(self.build, o.getChildren())
380         return callee(args, *(starargs or ()), **(kwargs or {}))
381    
382     def build_List(self, o):
383         return map(self.build, o.getChildren())
384    
385     def build_Const(self, o):
386         return o.value
387    
388     def build_Dict(self, o):
389         d = {}
390         i = iter(map(self.build, o.getChildren()))
391         for el in i:
392             d[el] = i.next()
393         return d
394    
395     def build_Tuple(self, o):
396         return tuple(self.build_List(o))
397    
398     def build_Name(self, o):
399         if o.name == 'None':
400             return None
401         if o.name == 'True':
402             return True
403         if o.name == 'False':
404             return False
405        
406         # See if the Name is a package or module
407         try:
408             return modules(o.name)
409         except ImportError:
410             pass
411        
412         raise UnknownType(o.name)
413    
414     def build_Add(self, o):
415         real, imag = map(self.build_Const, o.getChildren())
416         try:
417             real = float(real)
418         except TypeError:
419             raise UnknownType('Add')
420         if not isinstance(imag, complex) or imag.real != 0.0:
421             raise UnknownType('Add')
422         return real+imag
423    
424     def build_Getattr(self, o):
425         parent = self.build(o.expr)
426         return getattr(parent, o.attrname)
427    
428     def build_NoneType(self, o):
429         return None
430    
431     def build_UnarySub(self, o):
432         return -self.build_Const(o.getChildren()[0])
433    
434     def build_UnaryAdd(self, o):
435         return self.build_Const(o.getChildren()[0])
436
437
438 def unrepr(s):
439     if not s:
440         return s
441     return Builder().build(getObj(s))
442
443
444 def referer(pattern, accept=True, accept_missing=False, error=403,
445             message='Forbidden Referer header.'):
446     """Raise HTTPError if Referer header does not pass our test.
447     
448     pattern: a regular expression pattern to test against the Referer.
449     accept: if True, the Referer must match the pattern; if False,
450         the Referer must NOT match the pattern.
451     accept_missing: if True, permit requests with no Referer header.
452     error: the HTTP error code to return to the client on failure.
453     message: a string to include in the response body on failure.
454     """
455     try:
456         match = bool(re.match(pattern, cherrypy.request.headers['Referer']))
457         if accept == match:
458             return
459     except KeyError:
460         if accept_missing:
461             return
462    
463     raise cherrypy.HTTPError(error, message)
464
465 def accept(media=None):
466     """Return the client's preferred media-type (from the given Content-Types).
467     
468     If 'media' is None (the default), no test will be performed.
469     
470     If 'media' is provided, it should be the Content-Type value (as a string)
471     or values (as a list or tuple of strings) which the current request
472     can emit. The client's acceptable media ranges (as declared in the
473     Accept request header) will be matched in order to these Content-Type
474     values; the first such string is returned. That is, the return value
475     will always be one of the strings provided in the 'media' arg (or None
476     if 'media' is None).
477     
478     If no match is found, then HTTPError 406 (Not Acceptable) is raised.
479     Note that most web browsers send */* as a (low-quality) acceptable
480     media range, which should match any Content-Type. In addition, "...if
481     no Accept header field is present, then it is assumed that the client
482     accepts all media types."
483     
484     Matching types are checked in order of client preference first,
485     and then in the order of the given 'media' values.
486     
487     Note that this function does not honor accept-params (other than "q").
488     """
489     if not media:
490         return
491     if isinstance(media, basestring):
492         media = [media]
493    
494     # Parse the Accept request header, and try to match one
495     # of the requested media-ranges (in order of preference).
496     ranges = cherrypy.request.headers.elements('Accept')
497     if not ranges:
498         # Any media type is acceptable.
499         return media[0]
500     else:
501         # Note that 'ranges' is sorted in order of preference
502         for element in ranges:
503             if element.qvalue > 0:
504                 if element.value == "*/*":
505                     # Matches any type or subtype
506                     return media[0]
507                 elif element.value.endswith("/*"):
508                     # Matches any subtype
509                     mtype = element.value[:-1]  # Keep the slash
510                     for m in media:
511                         if m.startswith(mtype):
512                             return m
513                 else:
514 &nbs