Mercurial > genshi > mirror
comparison examples/trac/trac/web/api.py @ 39:93b4dcbafd7b trunk
Copy Trac to main branch.
author | cmlenz |
---|---|
date | Mon, 03 Jul 2006 18:53:27 +0000 |
parents | |
children | f8a5a6ee2097 |
comparison
equal
deleted
inserted
replaced
38:ee669cb9cccc | 39:93b4dcbafd7b |
---|---|
1 # -*- coding: utf-8 -*- | |
2 # | |
3 # Copyright (C) 2005 Edgewall Software | |
4 # Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de> | |
5 # All rights reserved. | |
6 # | |
7 # This software is licensed as described in the file COPYING, which | |
8 # you should have received as part of this distribution. The terms | |
9 # are also available at http://trac.edgewall.com/license.html. | |
10 # | |
11 # This software consists of voluntary contributions made by many | |
12 # individuals. For the exact contribution history, see the revision | |
13 # history and logs, available at http://projects.edgewall.com/trac/. | |
14 # | |
15 # Author: Christopher Lenz <cmlenz@gmx.de> | |
16 | |
17 from BaseHTTPServer import BaseHTTPRequestHandler | |
18 from Cookie import SimpleCookie as Cookie | |
19 import cgi | |
20 import mimetypes | |
21 import os | |
22 from StringIO import StringIO | |
23 import sys | |
24 import urlparse | |
25 | |
26 from trac.core import Interface | |
27 from trac.util import get_last_traceback | |
28 from trac.util.datefmt import http_date | |
29 from trac.web.href import Href | |
30 | |
31 HTTP_STATUS = dict([(code, reason.title()) for code, (reason, description) | |
32 in BaseHTTPRequestHandler.responses.items()]) | |
33 | |
34 | |
35 class HTTPException(Exception): | |
36 """Exception representing a HTTP status code.""" | |
37 | |
38 def __init__(self, code): | |
39 self.code = code | |
40 self.reason = HTTP_STATUS.get(self.code, 'Unknown') | |
41 self.__doc__ = 'Exception for HTTP %d %s' % (self.code, self.reason) | |
42 | |
43 def __call__(self, message, *args): | |
44 self.message = message | |
45 if args: | |
46 self.message = self.message % args | |
47 Exception.__init__(self, '%s %s (%s)' % (self.code, self.reason, | |
48 message)) | |
49 return self | |
50 | |
51 def __str__(self): | |
52 return '%s %s (%s)' % (self.code, self.reason, self.message) | |
53 | |
54 | |
55 for code in [code for code in HTTP_STATUS if code >= 400]: | |
56 exc_name = HTTP_STATUS[code].replace(' ', '') | |
57 if exc_name.lower().startswith('http'): | |
58 exc_name = exc_name[4:] | |
59 setattr(sys.modules[__name__], 'HTTP' + exc_name, HTTPException(code)) | |
60 del code, exc_name | |
61 | |
62 | |
63 class _RequestArgs(dict): | |
64 """Dictionary subclass that provides convenient access to request | |
65 parameters that may contain multiple values.""" | |
66 | |
67 def getfirst(self, name, default=None): | |
68 """Return the first value for the specified parameter, or `default` if | |
69 the parameter was not provided. | |
70 """ | |
71 if name not in self: | |
72 return default | |
73 val = self[name] | |
74 if isinstance(val, list): | |
75 val = val[0] | |
76 return val | |
77 | |
78 def getlist(self, name): | |
79 """Return a list of values for the specified parameter, even if only | |
80 one value was provided. | |
81 """ | |
82 if name not in self: | |
83 return [] | |
84 val = self[name] | |
85 if not isinstance(val, list): | |
86 val = [val] | |
87 return val | |
88 | |
89 | |
90 class RequestDone(Exception): | |
91 """Marker exception that indicates whether request processing has completed | |
92 and a response was sent. | |
93 """ | |
94 | |
95 | |
96 class Request(object): | |
97 """Represents a HTTP request/response pair. | |
98 | |
99 This class provides a convenience API over WSGI. | |
100 """ | |
101 args = None | |
102 hdf = None | |
103 authname = None | |
104 perm = None | |
105 session = None | |
106 | |
107 def __init__(self, environ, start_response): | |
108 """Create the request wrapper. | |
109 | |
110 @param environ: The WSGI environment dict | |
111 @param start_response: The WSGI callback for starting the response | |
112 """ | |
113 self.environ = environ | |
114 self._start_response = start_response | |
115 self._write = None | |
116 self._status = '200 OK' | |
117 self._response = None | |
118 | |
119 self._inheaders = [(name[5:].replace('_', '-').lower(), value) | |
120 for name, value in environ.items() | |
121 if name.startswith('HTTP_')] | |
122 if 'CONTENT_LENGTH' in environ: | |
123 self._inheaders.append(('content-length', | |
124 environ['CONTENT_LENGTH'])) | |
125 if 'CONTENT_TYPE' in environ: | |
126 self._inheaders.append(('content-type', environ['CONTENT_TYPE'])) | |
127 self._outheaders = [] | |
128 self._outcharset = None | |
129 | |
130 self.incookie = Cookie() | |
131 cookie = self.get_header('Cookie') | |
132 if cookie: | |
133 self.incookie.load(cookie) | |
134 self.outcookie = Cookie() | |
135 | |
136 self.base_url = self.environ.get('trac.base_url') | |
137 if not self.base_url: | |
138 self.base_url = self._reconstruct_url() | |
139 self.href = Href(self.base_path) | |
140 self.abs_href = Href(self.base_url) | |
141 | |
142 self.args = self._parse_args() | |
143 | |
144 def _parse_args(self): | |
145 """Parse the supplied request parameters into a dictionary.""" | |
146 args = _RequestArgs() | |
147 | |
148 fp = self.environ['wsgi.input'] | |
149 ctype = self.get_header('Content-Type') | |
150 if ctype: | |
151 # Avoid letting cgi.FieldStorage consume the input stream when the | |
152 # request does not contain form data | |
153 ctype, options = cgi.parse_header(ctype) | |
154 if ctype not in ('application/x-www-form-urlencoded', | |
155 'multipart/form-data'): | |
156 fp = StringIO('') | |
157 | |
158 fs = cgi.FieldStorage(fp, environ=self.environ, keep_blank_values=True) | |
159 if fs.list: | |
160 for name in fs.keys(): | |
161 values = fs[name] | |
162 if not isinstance(values, list): | |
163 values = [values] | |
164 for value in values: | |
165 if not value.filename: | |
166 value = unicode(value.value, 'utf-8') | |
167 if name in args: | |
168 if isinstance(args[name], list): | |
169 args[name].append(value) | |
170 else: | |
171 args[name] = [args[name], value] | |
172 else: | |
173 args[name] = value | |
174 | |
175 return args | |
176 | |
177 def _reconstruct_url(self): | |
178 """Reconstruct the absolute base URL of the application.""" | |
179 host = self.get_header('Host') | |
180 if not host: | |
181 # Missing host header, so reconstruct the host from the | |
182 # server name and port | |
183 default_port = {'http': 80, 'https': 443} | |
184 if self.server_port and self.server_port != default_port[self.scheme]: | |
185 host = '%s:%d' % (self.server_name, self.server_port) | |
186 else: | |
187 host = self.server_name | |
188 return urlparse.urlunparse((self.scheme, host, self.base_path, None, | |
189 None, None)) | |
190 | |
191 method = property(fget=lambda self: self.environ['REQUEST_METHOD'], | |
192 doc='The HTTP method of the request') | |
193 path_info = property(fget=lambda self: self.environ.get('PATH_INFO', '').decode('utf-8'), | |
194 doc='Path inside the application') | |
195 remote_addr = property(fget=lambda self: self.environ.get('REMOTE_ADDR'), | |
196 doc='IP address of the remote user') | |
197 remote_user = property(fget=lambda self: self.environ.get('REMOTE_USER'), | |
198 doc='Name of the remote user, `None` if the user' | |
199 'has not logged in using HTTP authentication') | |
200 scheme = property(fget=lambda self: self.environ['wsgi.url_scheme'], | |
201 doc='The scheme of the request URL') | |
202 base_path = property(fget=lambda self: self.environ.get('SCRIPT_NAME', ''), | |
203 doc='The root path of the application') | |
204 server_name = property(fget=lambda self: self.environ['SERVER_NAME'], | |
205 doc='Name of the server') | |
206 server_port = property(fget=lambda self: int(self.environ['SERVER_PORT']), | |
207 doc='Port number the server is bound to') | |
208 | |
209 def get_header(self, name): | |
210 """Return the value of the specified HTTP header, or `None` if there's | |
211 no such header in the request. | |
212 """ | |
213 name = name.lower() | |
214 for key, value in self._inheaders: | |
215 if key == name: | |
216 return value | |
217 return None | |
218 | |
219 def send_response(self, code=200): | |
220 """Set the status code of the response.""" | |
221 self._status = '%s %s' % (code, HTTP_STATUS.get(code, 'Unknown')) | |
222 | |
223 def send_header(self, name, value): | |
224 """Send the response header with the specified name and value. | |
225 | |
226 `value` must either be an `unicode` string or can be converted to one | |
227 (e.g. numbers, ...) | |
228 """ | |
229 if name.lower() == 'content-type': | |
230 ctpos = value.find('charset=') | |
231 if ctpos >= 0: | |
232 self._outcharset = value[ctpos + 8:].strip() | |
233 self._outheaders.append((name, unicode(value).encode('utf-8'))) | |
234 | |
235 def _send_cookie_headers(self): | |
236 for name in self.outcookie.keys(): | |
237 path = self.outcookie[name].get('path') | |
238 if path: | |
239 path = path.replace(' ', '%20') \ | |
240 .replace(';', '%3B') \ | |
241 .replace(',', '%3C') | |
242 self.outcookie[name]['path'] = path | |
243 | |
244 cookies = self.outcookie.output(header='') | |
245 for cookie in cookies.splitlines(): | |
246 self._outheaders.append(('Set-Cookie', cookie.strip())) | |
247 | |
248 def end_headers(self): | |
249 """Must be called after all headers have been sent and before the actual | |
250 content is written. | |
251 """ | |
252 self._send_cookie_headers() | |
253 self._write = self._start_response(self._status, self._outheaders) | |
254 | |
255 def check_modified(self, timesecs, extra=''): | |
256 """Check the request "If-None-Match" header against an entity tag. | |
257 | |
258 The entity tag is generated from the specified last modified time | |
259 in seconds (`timesecs`), optionally appending an `extra` string to | |
260 indicate variants of the requested resource. | |
261 | |
262 That `extra` parameter can also be a list, in which case the MD5 sum | |
263 of the list content will be used. | |
264 | |
265 If the generated tag matches the "If-None-Match" header of the request, | |
266 this method sends a "304 Not Modified" response to the client. | |
267 Otherwise, it adds the entity tag as an "ETag" header to the response | |
268 so that consecutive requests can be cached. | |
269 """ | |
270 if isinstance(extra, list): | |
271 import md5 | |
272 m = md5.new() | |
273 for elt in extra: | |
274 m.update(repr(elt)) | |
275 extra = m.hexdigest() | |
276 etag = 'W"%s/%d/%s"' % (self.authname, timesecs, extra) | |
277 inm = self.get_header('If-None-Match') | |
278 if (not inm or inm != etag): | |
279 self.send_header('ETag', etag) | |
280 else: | |
281 self.send_response(304) | |
282 self.end_headers() | |
283 raise RequestDone | |
284 | |
285 def redirect(self, url, permanent=False): | |
286 """Send a redirect to the client, forwarding to the specified URL. The | |
287 `url` may be relative or absolute, relative URLs will be translated | |
288 appropriately. | |
289 """ | |
290 if self.session: | |
291 self.session.save() # has to be done before the redirect is sent | |
292 | |
293 if permanent: | |
294 status = 301 # 'Moved Permanently' | |
295 elif self.method == 'POST': | |
296 status = 303 # 'See Other' -- safe to use in response to a POST | |
297 else: | |
298 status = 302 # 'Found' -- normal temporary redirect | |
299 | |
300 self.send_response(status) | |
301 if not url.startswith('http://') and not url.startswith('https://'): | |
302 # Make sure the URL is absolute | |
303 url = urlparse.urlunparse((self.scheme, | |
304 urlparse.urlparse(self.base_url)[1], | |
305 url, None, None, None)) | |
306 self.send_header('Location', url) | |
307 self.send_header('Content-Type', 'text/plain') | |
308 self.send_header('Pragma', 'no-cache') | |
309 self.send_header('Cache-control', 'no-cache') | |
310 self.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') | |
311 self.end_headers() | |
312 | |
313 if self.method != 'HEAD': | |
314 self.write('Redirecting...') | |
315 raise RequestDone | |
316 | |
317 def display(self, template, content_type='text/html', status=200): | |
318 """Render the response using the ClearSilver template given by the | |
319 `template` parameter, which can be either the name of the template file, | |
320 or an already parsed `neo_cs.CS` object. | |
321 """ | |
322 assert self.hdf, 'HDF dataset not available' | |
323 if self.args.has_key('hdfdump'): | |
324 # FIXME: the administrator should probably be able to disable HDF | |
325 # dumps | |
326 content_type = 'text/plain' | |
327 data = str(self.hdf) | |
328 else: | |
329 data = self.hdf.render(template) | |
330 | |
331 self.send_response(status) | |
332 self.send_header('Cache-control', 'must-revalidate') | |
333 self.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') | |
334 self.send_header('Content-Type', content_type + ';charset=utf-8') | |
335 self.send_header('Content-Length', len(data)) | |
336 self.end_headers() | |
337 | |
338 if self.method != 'HEAD': | |
339 self.write(data) | |
340 raise RequestDone | |
341 | |
342 def send_error(self, exc_info, template='error.cs', | |
343 content_type='text/html', status=500): | |
344 if self.hdf: | |
345 if self.args.has_key('hdfdump'): | |
346 # FIXME: the administrator should probably be able to disable HDF | |
347 # dumps | |
348 content_type = 'text/plain' | |
349 data = str(self.hdf) | |
350 else: | |
351 data = self.hdf.render(template) | |
352 else: | |
353 content_type = 'text/plain' | |
354 data = get_last_traceback() | |
355 | |
356 self.send_response(status) | |
357 self._outheaders = [] | |
358 self.send_header('Cache-control', 'must-revalidate') | |
359 self.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') | |
360 self.send_header('Content-Type', content_type + ';charset=utf-8') | |
361 self.send_header('Content-Length', len(data)) | |
362 self._send_cookie_headers() | |
363 | |
364 self._write = self._start_response(self._status, self._outheaders, | |
365 exc_info) | |
366 | |
367 if self.method != 'HEAD': | |
368 self.write(data) | |
369 raise RequestDone | |
370 | |
371 def send_file(self, path, mimetype=None): | |
372 """Send a local file to the browser. | |
373 | |
374 This method includes the "Last-Modified", "Content-Type" and | |
375 "Content-Length" headers in the response, corresponding to the file | |
376 attributes. It also checks the last modification time of the local file | |
377 against the "If-Modified-Since" provided by the user agent, and sends a | |
378 "304 Not Modified" response if it matches. | |
379 """ | |
380 if not os.path.isfile(path): | |
381 raise HTTPNotFound("File %s not found" % path) | |
382 | |
383 stat = os.stat(path) | |
384 last_modified = http_date(stat.st_mtime) | |
385 if last_modified == self.get_header('If-Modified-Since'): | |
386 self.send_response(304) | |
387 self.end_headers() | |
388 raise RequestDone | |
389 | |
390 if not mimetype: | |
391 mimetype = mimetypes.guess_type(path)[0] or \ | |
392 'application/octet-stream' | |
393 | |
394 self.send_response(200) | |
395 self.send_header('Content-Type', mimetype) | |
396 self.send_header('Content-Length', stat.st_size) | |
397 self.send_header('Last-Modified', last_modified) | |
398 self.end_headers() | |
399 | |
400 if self.method != 'HEAD': | |
401 self._response = file(path, 'rb') | |
402 file_wrapper = self.environ.get('wsgi.file_wrapper') | |
403 if file_wrapper: | |
404 self._response = file_wrapper(self._response, 4096) | |
405 raise RequestDone | |
406 | |
407 def read(self, size=None): | |
408 """Read the specified number of bytes from the request body.""" | |
409 fileobj = self.environ['wsgi.input'] | |
410 if size is None: | |
411 size = int(self.get_header('Content-Length', -1)) | |
412 data = fileobj.read(size) | |
413 return data | |
414 | |
415 def write(self, data): | |
416 """Write the given data to the response body. | |
417 | |
418 `data` can be either a `str` or an `unicode` string. | |
419 If it's the latter, the unicode string will be encoded | |
420 using the charset specified in the ''Content-Type'' header | |
421 or 'utf-8' otherwise. | |
422 """ | |
423 if not self._write: | |
424 self.end_headers() | |
425 if isinstance(data, unicode): | |
426 data = data.encode(self._outcharset or 'utf-8') | |
427 self._write(data) | |
428 | |
429 | |
430 class IAuthenticator(Interface): | |
431 """Extension point interface for components that can provide the name | |
432 of the remote user.""" | |
433 | |
434 def authenticate(req): | |
435 """Return the name of the remote user, or `None` if the identity of the | |
436 user is unknown.""" | |
437 | |
438 | |
439 class IRequestHandler(Interface): | |
440 """Extension point interface for request handlers.""" | |
441 | |
442 # implementing classes should set this property to `True` if they | |
443 # don't need session and authentication related information | |
444 anonymous_request = False | |
445 | |
446 # implementing classes should set this property to `False` if they | |
447 # don't need the HDF data and don't produce content using a template | |
448 use_template = True | |
449 | |
450 def match_request(req): | |
451 """Return whether the handler wants to process the given request.""" | |
452 | |
453 def process_request(req): | |
454 """Process the request. Should return a (template_name, content_type) | |
455 tuple, where `template` is the ClearSilver template to use (either | |
456 a `neo_cs.CS` object, or the file name of the template), and | |
457 `content_type` is the MIME type of the content. If `content_type` is | |
458 `None`, "text/html" is assumed. | |
459 | |
460 Note that if template processing should not occur, this method can | |
461 simply send the response itself and not return anything. | |
462 """ | |
463 | |
464 | |
465 class IRequestFilter(Interface): | |
466 """Extension point interface for components that want to filter HTTP | |
467 requests, before and/or after they are processed by the main handler.""" | |
468 | |
469 def pre_process_request(req, handler): | |
470 """Do any pre-processing the request might need; typically adding | |
471 values to req.hdf, or redirecting. | |
472 | |
473 Always returns the request handler, even if unchanged. | |
474 """ | |
475 | |
476 def post_process_request(req, template, content_type): | |
477 """Do any post-processing the request might need; typically adding | |
478 values to req.hdf, or changing template or mime type. | |
479 | |
480 Always returns a tuple of (template, content_type), even if | |
481 unchanged. | |
482 """ |