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 """
Copyright (C) 2012-2017 Edgewall Software