##// END OF EJS Templates
hgweb: change how dispatch path is reported...
Gregory Szorc -
r36914:d0b0fedb default
parent child Browse files
Show More
@@ -1,449 +1,453 b''
1 1 # hgweb/hgweb_mod.py - Web interface for a repository.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import contextlib
12 12 import os
13 13
14 14 from .common import (
15 15 ErrorResponse,
16 16 HTTP_BAD_REQUEST,
17 17 cspvalues,
18 18 permhooks,
19 19 statusmessage,
20 20 )
21 21
22 22 from .. import (
23 23 encoding,
24 24 error,
25 25 formatter,
26 26 hg,
27 27 hook,
28 28 profiling,
29 29 pycompat,
30 30 repoview,
31 31 templatefilters,
32 32 templater,
33 33 ui as uimod,
34 34 util,
35 35 wireprotoserver,
36 36 )
37 37
38 38 from . import (
39 39 request as requestmod,
40 40 webcommands,
41 41 webutil,
42 42 wsgicgi,
43 43 )
44 44
45 45 archivespecs = util.sortdict((
46 46 ('zip', ('application/zip', 'zip', '.zip', None)),
47 47 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
48 48 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
49 49 ))
50 50
51 51 def getstyle(req, configfn, templatepath):
52 52 styles = (
53 53 req.qsparams.get('style', None),
54 54 configfn('web', 'style'),
55 55 'paper',
56 56 )
57 57 return styles, templater.stylemap(styles, templatepath)
58 58
59 59 def makebreadcrumb(url, prefix=''):
60 60 '''Return a 'URL breadcrumb' list
61 61
62 62 A 'URL breadcrumb' is a list of URL-name pairs,
63 63 corresponding to each of the path items on a URL.
64 64 This can be used to create path navigation entries.
65 65 '''
66 66 if url.endswith('/'):
67 67 url = url[:-1]
68 68 if prefix:
69 69 url = '/' + prefix + url
70 70 relpath = url
71 71 if relpath.startswith('/'):
72 72 relpath = relpath[1:]
73 73
74 74 breadcrumb = []
75 75 urlel = url
76 76 pathitems = [''] + relpath.split('/')
77 77 for pathel in reversed(pathitems):
78 78 if not pathel or not urlel:
79 79 break
80 80 breadcrumb.append({'url': urlel, 'name': pathel})
81 81 urlel = os.path.dirname(urlel)
82 82 return reversed(breadcrumb)
83 83
84 84 class requestcontext(object):
85 85 """Holds state/context for an individual request.
86 86
87 87 Servers can be multi-threaded. Holding state on the WSGI application
88 88 is prone to race conditions. Instances of this class exist to hold
89 89 mutable and race-free state for requests.
90 90 """
91 91 def __init__(self, app, repo, req, res):
92 92 self.repo = repo
93 93 self.reponame = app.reponame
94 94 self.req = req
95 95 self.res = res
96 96
97 97 self.archivespecs = archivespecs
98 98
99 99 self.maxchanges = self.configint('web', 'maxchanges')
100 100 self.stripecount = self.configint('web', 'stripes')
101 101 self.maxshortchanges = self.configint('web', 'maxshortchanges')
102 102 self.maxfiles = self.configint('web', 'maxfiles')
103 103 self.allowpull = self.configbool('web', 'allow-pull')
104 104
105 105 # we use untrusted=False to prevent a repo owner from using
106 106 # web.templates in .hg/hgrc to get access to any file readable
107 107 # by the user running the CGI script
108 108 self.templatepath = self.config('web', 'templates', untrusted=False)
109 109
110 110 # This object is more expensive to build than simple config values.
111 111 # It is shared across requests. The app will replace the object
112 112 # if it is updated. Since this is a reference and nothing should
113 113 # modify the underlying object, it should be constant for the lifetime
114 114 # of the request.
115 115 self.websubtable = app.websubtable
116 116
117 117 self.csp, self.nonce = cspvalues(self.repo.ui)
118 118
119 119 # Trust the settings from the .hg/hgrc files by default.
120 120 def config(self, section, name, default=uimod._unset, untrusted=True):
121 121 return self.repo.ui.config(section, name, default,
122 122 untrusted=untrusted)
123 123
124 124 def configbool(self, section, name, default=uimod._unset, untrusted=True):
125 125 return self.repo.ui.configbool(section, name, default,
126 126 untrusted=untrusted)
127 127
128 128 def configint(self, section, name, default=uimod._unset, untrusted=True):
129 129 return self.repo.ui.configint(section, name, default,
130 130 untrusted=untrusted)
131 131
132 132 def configlist(self, section, name, default=uimod._unset, untrusted=True):
133 133 return self.repo.ui.configlist(section, name, default,
134 134 untrusted=untrusted)
135 135
136 136 def archivelist(self, nodeid):
137 137 allowed = self.configlist('web', 'allow_archive')
138 138 for typ, spec in self.archivespecs.iteritems():
139 139 if typ in allowed or self.configbool('web', 'allow%s' % typ):
140 140 yield {'type': typ, 'extension': spec[2], 'node': nodeid}
141 141
142 142 def templater(self, req):
143 143 # determine scheme, port and server name
144 144 # this is needed to create absolute urls
145 145 logourl = self.config('web', 'logourl')
146 146 logoimg = self.config('web', 'logoimg')
147 147 staticurl = (self.config('web', 'staticurl')
148 148 or req.apppath + '/static/')
149 149 if not staticurl.endswith('/'):
150 150 staticurl += '/'
151 151
152 152 # some functions for the templater
153 153
154 154 def motd(**map):
155 155 yield self.config('web', 'motd')
156 156
157 157 # figure out which style to use
158 158
159 159 vars = {}
160 160 styles, (style, mapfile) = getstyle(req, self.config,
161 161 self.templatepath)
162 162 if style == styles[0]:
163 163 vars['style'] = style
164 164
165 165 sessionvars = webutil.sessionvars(vars, '?')
166 166
167 167 if not self.reponame:
168 168 self.reponame = (self.config('web', 'name', '')
169 169 or req.reponame
170 170 or req.apppath
171 171 or self.repo.root)
172 172
173 173 def websubfilter(text):
174 174 return templatefilters.websub(text, self.websubtable)
175 175
176 176 # create the templater
177 177 # TODO: export all keywords: defaults = templatekw.keywords.copy()
178 178 defaults = {
179 179 'url': req.apppath + '/',
180 180 'logourl': logourl,
181 181 'logoimg': logoimg,
182 182 'staticurl': staticurl,
183 183 'urlbase': req.advertisedbaseurl,
184 184 'repo': self.reponame,
185 185 'encoding': encoding.encoding,
186 186 'motd': motd,
187 187 'sessionvars': sessionvars,
188 188 'pathdef': makebreadcrumb(req.apppath),
189 189 'style': style,
190 190 'nonce': self.nonce,
191 191 }
192 192 tres = formatter.templateresources(self.repo.ui, self.repo)
193 193 tmpl = templater.templater.frommapfile(mapfile,
194 194 filters={'websub': websubfilter},
195 195 defaults=defaults,
196 196 resources=tres)
197 197 return tmpl
198 198
199 199 def sendtemplate(self, name, **kwargs):
200 200 """Helper function to send a response generated from a template."""
201 201 self.res.setbodygen(self.tmpl(name, **kwargs))
202 202 return self.res.sendresponse()
203 203
204 204 class hgweb(object):
205 205 """HTTP server for individual repositories.
206 206
207 207 Instances of this class serve HTTP responses for a particular
208 208 repository.
209 209
210 210 Instances are typically used as WSGI applications.
211 211
212 212 Some servers are multi-threaded. On these servers, there may
213 213 be multiple active threads inside __call__.
214 214 """
215 215 def __init__(self, repo, name=None, baseui=None):
216 216 if isinstance(repo, str):
217 217 if baseui:
218 218 u = baseui.copy()
219 219 else:
220 220 u = uimod.ui.load()
221 221 r = hg.repository(u, repo)
222 222 else:
223 223 # we trust caller to give us a private copy
224 224 r = repo
225 225
226 226 r.ui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
227 227 r.baseui.setconfig('ui', 'report_untrusted', 'off', 'hgweb')
228 228 r.ui.setconfig('ui', 'nontty', 'true', 'hgweb')
229 229 r.baseui.setconfig('ui', 'nontty', 'true', 'hgweb')
230 230 # resolve file patterns relative to repo root
231 231 r.ui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
232 232 r.baseui.setconfig('ui', 'forcecwd', r.root, 'hgweb')
233 233 # displaying bundling progress bar while serving feel wrong and may
234 234 # break some wsgi implementation.
235 235 r.ui.setconfig('progress', 'disable', 'true', 'hgweb')
236 236 r.baseui.setconfig('progress', 'disable', 'true', 'hgweb')
237 237 self._repos = [hg.cachedlocalrepo(self._webifyrepo(r))]
238 238 self._lastrepo = self._repos[0]
239 239 hook.redirect(True)
240 240 self.reponame = name
241 241
242 242 def _webifyrepo(self, repo):
243 243 repo = getwebview(repo)
244 244 self.websubtable = webutil.getwebsubs(repo)
245 245 return repo
246 246
247 247 @contextlib.contextmanager
248 248 def _obtainrepo(self):
249 249 """Obtain a repo unique to the caller.
250 250
251 251 Internally we maintain a stack of cachedlocalrepo instances
252 252 to be handed out. If one is available, we pop it and return it,
253 253 ensuring it is up to date in the process. If one is not available,
254 254 we clone the most recently used repo instance and return it.
255 255
256 256 It is currently possible for the stack to grow without bounds
257 257 if the server allows infinite threads. However, servers should
258 258 have a thread limit, thus establishing our limit.
259 259 """
260 260 if self._repos:
261 261 cached = self._repos.pop()
262 262 r, created = cached.fetch()
263 263 else:
264 264 cached = self._lastrepo.copy()
265 265 r, created = cached.fetch()
266 266 if created:
267 267 r = self._webifyrepo(r)
268 268
269 269 self._lastrepo = cached
270 270 self.mtime = cached.mtime
271 271 try:
272 272 yield r
273 273 finally:
274 274 self._repos.append(cached)
275 275
276 276 def run(self):
277 277 """Start a server from CGI environment.
278 278
279 279 Modern servers should be using WSGI and should avoid this
280 280 method, if possible.
281 281 """
282 282 if not encoding.environ.get('GATEWAY_INTERFACE',
283 283 '').startswith("CGI/1."):
284 284 raise RuntimeError("This function is only intended to be "
285 285 "called while running as a CGI script.")
286 286 wsgicgi.launch(self)
287 287
288 288 def __call__(self, env, respond):
289 289 """Run the WSGI application.
290 290
291 291 This may be called by multiple threads.
292 292 """
293 293 req = requestmod.wsgirequest(env, respond)
294 294 return self.run_wsgi(req)
295 295
296 296 def run_wsgi(self, wsgireq):
297 297 """Internal method to run the WSGI application.
298 298
299 299 This is typically only called by Mercurial. External consumers
300 300 should be using instances of this class as the WSGI application.
301 301 """
302 302 with self._obtainrepo() as repo:
303 303 profile = repo.ui.configbool('profiling', 'enabled')
304 304 with profiling.profile(repo.ui, enabled=profile):
305 305 for r in self._runwsgi(wsgireq, repo):
306 306 yield r
307 307
308 308 def _runwsgi(self, wsgireq, repo):
309 309 req = wsgireq.req
310 310 res = wsgireq.res
311 311 rctx = requestcontext(self, repo, req, res)
312 312
313 313 # This state is global across all threads.
314 314 encoding.encoding = rctx.config('web', 'encoding')
315 315 rctx.repo.ui.environ = wsgireq.env
316 316
317 317 if rctx.csp:
318 318 # hgwebdir may have added CSP header. Since we generate our own,
319 319 # replace it.
320 320 res.headers['Content-Security-Policy'] = rctx.csp
321 321
322 322 handled = wireprotoserver.handlewsgirequest(
323 323 rctx, req, res, self.check_perm)
324 324 if handled:
325 325 return res.sendresponse()
326 326
327 if req.havepathinfo:
327 # Old implementations of hgweb supported dispatching the request via
328 # the initial query string parameter instead of using PATH_INFO.
329 # If PATH_INFO is present (signaled by ``req.dispatchpath`` having
330 # a value), we use it. Otherwise fall back to the query string.
331 if req.dispatchpath is not None:
328 332 query = req.dispatchpath
329 333 else:
330 334 query = req.querystring.partition('&')[0].partition(';')[0]
331 335
332 336 # translate user-visible url structure to internal structure
333 337
334 338 args = query.split('/', 2)
335 339 if 'cmd' not in req.qsparams and args and args[0]:
336 340 cmd = args.pop(0)
337 341 style = cmd.rfind('-')
338 342 if style != -1:
339 343 req.qsparams['style'] = cmd[:style]
340 344 cmd = cmd[style + 1:]
341 345
342 346 # avoid accepting e.g. style parameter as command
343 347 if util.safehasattr(webcommands, cmd):
344 348 req.qsparams['cmd'] = cmd
345 349
346 350 if cmd == 'static':
347 351 req.qsparams['file'] = '/'.join(args)
348 352 else:
349 353 if args and args[0]:
350 354 node = args.pop(0).replace('%2F', '/')
351 355 req.qsparams['node'] = node
352 356 if args:
353 357 if 'file' in req.qsparams:
354 358 del req.qsparams['file']
355 359 for a in args:
356 360 req.qsparams.add('file', a)
357 361
358 362 ua = req.headers.get('User-Agent', '')
359 363 if cmd == 'rev' and 'mercurial' in ua:
360 364 req.qsparams['style'] = 'raw'
361 365
362 366 if cmd == 'archive':
363 367 fn = req.qsparams['node']
364 368 for type_, spec in rctx.archivespecs.iteritems():
365 369 ext = spec[2]
366 370 if fn.endswith(ext):
367 371 req.qsparams['node'] = fn[:-len(ext)]
368 372 req.qsparams['type'] = type_
369 373 else:
370 374 cmd = req.qsparams.get('cmd', '')
371 375
372 376 # process the web interface request
373 377
374 378 try:
375 379 rctx.tmpl = rctx.templater(req)
376 380 ctype = rctx.tmpl('mimetype', encoding=encoding.encoding)
377 381 ctype = templater.stringify(ctype)
378 382
379 383 # check read permissions non-static content
380 384 if cmd != 'static':
381 385 self.check_perm(rctx, req, None)
382 386
383 387 if cmd == '':
384 388 req.qsparams['cmd'] = rctx.tmpl.cache['default']
385 389 cmd = req.qsparams['cmd']
386 390
387 391 # Don't enable caching if using a CSP nonce because then it wouldn't
388 392 # be a nonce.
389 393 if rctx.configbool('web', 'cache') and not rctx.nonce:
390 394 tag = 'W/"%d"' % self.mtime
391 395 if req.headers.get('If-None-Match') == tag:
392 396 res.status = '304 Not Modified'
393 397 # Response body not allowed on 304.
394 398 res.setbodybytes('')
395 399 return res.sendresponse()
396 400
397 401 res.headers['ETag'] = tag
398 402
399 403 if cmd not in webcommands.__all__:
400 404 msg = 'no such method: %s' % cmd
401 405 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
402 406 else:
403 407 # Set some globals appropriate for web handlers. Commands can
404 408 # override easily enough.
405 409 res.status = '200 Script output follows'
406 410 res.headers['Content-Type'] = ctype
407 411 return getattr(webcommands, cmd)(rctx)
408 412
409 413 except (error.LookupError, error.RepoLookupError) as err:
410 414 msg = pycompat.bytestr(err)
411 415 if (util.safehasattr(err, 'name') and
412 416 not isinstance(err, error.ManifestLookupError)):
413 417 msg = 'revision not found: %s' % err.name
414 418
415 419 res.status = '404 Not Found'
416 420 res.headers['Content-Type'] = ctype
417 421 return rctx.sendtemplate('error', error=msg)
418 422 except (error.RepoError, error.RevlogError) as e:
419 423 res.status = '500 Internal Server Error'
420 424 res.headers['Content-Type'] = ctype
421 425 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
422 426 except ErrorResponse as e:
423 427 res.status = statusmessage(e.code, pycompat.bytestr(e))
424 428 res.headers['Content-Type'] = ctype
425 429 return rctx.sendtemplate('error', error=pycompat.bytestr(e))
426 430
427 431 def check_perm(self, rctx, req, op):
428 432 for permhook in permhooks:
429 433 permhook(rctx, req, op)
430 434
431 435 def getwebview(repo):
432 436 """The 'web.view' config controls changeset filter to hgweb. Possible
433 437 values are ``served``, ``visible`` and ``all``. Default is ``served``.
434 438 The ``served`` filter only shows changesets that can be pulled from the
435 439 hgweb instance. The``visible`` filter includes secret changesets but
436 440 still excludes "hidden" one.
437 441
438 442 See the repoview module for details.
439 443
440 444 The option has been around undocumented since Mercurial 2.5, but no
441 445 user ever asked about it. So we better keep it undocumented for now."""
442 446 # experimental config: web.view
443 447 viewconfig = repo.ui.config('web', 'view', untrusted=True)
444 448 if viewconfig == 'all':
445 449 return repo.unfiltered()
446 450 elif viewconfig in repoview.filtertable:
447 451 return repo.filtered(viewconfig)
448 452 else:
449 453 return repo.filtered('served')
@@ -1,662 +1,668 b''
1 1 # hgweb/request.py - An http request from either CGI or the standalone server.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import errno
12 12 import socket
13 13 import wsgiref.headers as wsgiheaders
14 14 #import wsgiref.validate
15 15
16 16 from .common import (
17 17 ErrorResponse,
18 18 statusmessage,
19 19 )
20 20
21 21 from ..thirdparty import (
22 22 attr,
23 23 )
24 24 from .. import (
25 25 error,
26 26 pycompat,
27 27 util,
28 28 )
29 29
30 30 class multidict(object):
31 31 """A dict like object that can store multiple values for a key.
32 32
33 33 Used to store parsed request parameters.
34 34
35 35 This is inspired by WebOb's class of the same name.
36 36 """
37 37 def __init__(self):
38 38 # Stores (key, value) 2-tuples. This isn't the most efficient. But we
39 39 # don't rely on parameters that much, so it shouldn't be a perf issue.
40 40 # we can always add dict for fast lookups.
41 41 self._items = []
42 42
43 43 def __getitem__(self, key):
44 44 """Returns the last set value for a key."""
45 45 for k, v in reversed(self._items):
46 46 if k == key:
47 47 return v
48 48
49 49 raise KeyError(key)
50 50
51 51 def __setitem__(self, key, value):
52 52 """Replace a values for a key with a new value."""
53 53 try:
54 54 del self[key]
55 55 except KeyError:
56 56 pass
57 57
58 58 self._items.append((key, value))
59 59
60 60 def __delitem__(self, key):
61 61 """Delete all values for a key."""
62 62 oldlen = len(self._items)
63 63
64 64 self._items[:] = [(k, v) for k, v in self._items if k != key]
65 65
66 66 if oldlen == len(self._items):
67 67 raise KeyError(key)
68 68
69 69 def __contains__(self, key):
70 70 return any(k == key for k, v in self._items)
71 71
72 72 def __len__(self):
73 73 return len(self._items)
74 74
75 75 def get(self, key, default=None):
76 76 try:
77 77 return self.__getitem__(key)
78 78 except KeyError:
79 79 return default
80 80
81 81 def add(self, key, value):
82 82 """Add a new value for a key. Does not replace existing values."""
83 83 self._items.append((key, value))
84 84
85 85 def getall(self, key):
86 86 """Obtains all values for a key."""
87 87 return [v for k, v in self._items if k == key]
88 88
89 89 def getone(self, key):
90 90 """Obtain a single value for a key.
91 91
92 92 Raises KeyError if key not defined or it has multiple values set.
93 93 """
94 94 vals = self.getall(key)
95 95
96 96 if not vals:
97 97 raise KeyError(key)
98 98
99 99 if len(vals) > 1:
100 100 raise KeyError('multiple values for %r' % key)
101 101
102 102 return vals[0]
103 103
104 104 def asdictoflists(self):
105 105 d = {}
106 106 for k, v in self._items:
107 107 if k in d:
108 108 d[k].append(v)
109 109 else:
110 110 d[k] = [v]
111 111
112 112 return d
113 113
114 114 @attr.s(frozen=True)
115 115 class parsedrequest(object):
116 116 """Represents a parsed WSGI request.
117 117
118 118 Contains both parsed parameters as well as a handle on the input stream.
119 119 """
120 120
121 121 # Request method.
122 122 method = attr.ib()
123 123 # Full URL for this request.
124 124 url = attr.ib()
125 125 # URL without any path components. Just <proto>://<host><port>.
126 126 baseurl = attr.ib()
127 127 # Advertised URL. Like ``url`` and ``baseurl`` but uses SERVER_NAME instead
128 128 # of HTTP: Host header for hostname. This is likely what clients used.
129 129 advertisedurl = attr.ib()
130 130 advertisedbaseurl = attr.ib()
131 131 # URL scheme (part before ``://``). e.g. ``http`` or ``https``.
132 132 urlscheme = attr.ib()
133 133 # Value of REMOTE_USER, if set, or None.
134 134 remoteuser = attr.ib()
135 135 # Value of REMOTE_HOST, if set, or None.
136 136 remotehost = attr.ib()
137 137 # WSGI application path.
138 138 apppath = attr.ib()
139 139 # List of path parts to be used for dispatch.
140 140 dispatchparts = attr.ib()
141 # URL path component (no query string) used for dispatch.
141 # URL path component (no query string) used for dispatch. Can be
142 # ``None`` to signal no path component given to the request, an
143 # empty string to signal a request to the application's root URL,
144 # or a string not beginning with ``/`` containing the requested
145 # path under the application.
142 146 dispatchpath = attr.ib()
143 # Whether there is a path component to this request. This can be true
144 # when ``dispatchpath`` is empty due to REPO_NAME muckery.
145 havepathinfo = attr.ib()
146 147 # The name of the repository being accessed.
147 148 reponame = attr.ib()
148 149 # Raw query string (part after "?" in URL).
149 150 querystring = attr.ib()
150 151 # multidict of query string parameters.
151 152 qsparams = attr.ib()
152 153 # wsgiref.headers.Headers instance. Operates like a dict with case
153 154 # insensitive keys.
154 155 headers = attr.ib()
155 156 # Request body input stream.
156 157 bodyfh = attr.ib()
157 158
158 159 def parserequestfromenv(env, bodyfh, reponame=None):
159 160 """Parse URL components from environment variables.
160 161
161 162 WSGI defines request attributes via environment variables. This function
162 163 parses the environment variables into a data structure.
163 164
164 165 If ``reponame`` is defined, the leading path components matching that
165 166 string are effectively shifted from ``PATH_INFO`` to ``SCRIPT_NAME``.
166 167 This simulates the world view of a WSGI application that processes
167 168 requests from the base URL of a repo.
168 169 """
169 170 # PEP-0333 defines the WSGI spec and is a useful reference for this code.
170 171
171 172 # We first validate that the incoming object conforms with the WSGI spec.
172 173 # We only want to be dealing with spec-conforming WSGI implementations.
173 174 # TODO enable this once we fix internal violations.
174 175 #wsgiref.validate.check_environ(env)
175 176
176 177 # PEP-0333 states that environment keys and values are native strings
177 178 # (bytes on Python 2 and str on Python 3). The code points for the Unicode
178 179 # strings on Python 3 must be between \00000-\000FF. We deal with bytes
179 180 # in Mercurial, so mass convert string keys and values to bytes.
180 181 if pycompat.ispy3:
181 182 env = {k.encode('latin-1'): v for k, v in env.iteritems()}
182 183 env = {k: v.encode('latin-1') if isinstance(v, str) else v
183 184 for k, v in env.iteritems()}
184 185
185 186 # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
186 187 # the environment variables.
187 188 # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
188 189 # how URLs are reconstructed.
189 190 fullurl = env['wsgi.url_scheme'] + '://'
190 191 advertisedfullurl = fullurl
191 192
192 193 def addport(s):
193 194 if env['wsgi.url_scheme'] == 'https':
194 195 if env['SERVER_PORT'] != '443':
195 196 s += ':' + env['SERVER_PORT']
196 197 else:
197 198 if env['SERVER_PORT'] != '80':
198 199 s += ':' + env['SERVER_PORT']
199 200
200 201 return s
201 202
202 203 if env.get('HTTP_HOST'):
203 204 fullurl += env['HTTP_HOST']
204 205 else:
205 206 fullurl += env['SERVER_NAME']
206 207 fullurl = addport(fullurl)
207 208
208 209 advertisedfullurl += env['SERVER_NAME']
209 210 advertisedfullurl = addport(advertisedfullurl)
210 211
211 212 baseurl = fullurl
212 213 advertisedbaseurl = advertisedfullurl
213 214
214 215 fullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
215 216 advertisedfullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
216 217 fullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
217 218 advertisedfullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
218 219
219 220 if env.get('QUERY_STRING'):
220 221 fullurl += '?' + env['QUERY_STRING']
221 222 advertisedfullurl += '?' + env['QUERY_STRING']
222 223
223 224 # If ``reponame`` is defined, that must be a prefix on PATH_INFO
224 225 # that represents the repository being dispatched to. When computing
225 226 # the dispatch info, we ignore these leading path components.
226 227
227 228 apppath = env.get('SCRIPT_NAME', '')
228 229
229 230 if reponame:
230 231 repoprefix = '/' + reponame.strip('/')
231 232
232 233 if not env.get('PATH_INFO'):
233 234 raise error.ProgrammingError('reponame requires PATH_INFO')
234 235
235 236 if not env['PATH_INFO'].startswith(repoprefix):
236 237 raise error.ProgrammingError('PATH_INFO does not begin with repo '
237 238 'name: %s (%s)' % (env['PATH_INFO'],
238 239 reponame))
239 240
240 241 dispatchpath = env['PATH_INFO'][len(repoprefix):]
241 242
242 243 if dispatchpath and not dispatchpath.startswith('/'):
243 244 raise error.ProgrammingError('reponame prefix of PATH_INFO does '
244 245 'not end at path delimiter: %s (%s)' %
245 246 (env['PATH_INFO'], reponame))
246 247
247 248 apppath = apppath.rstrip('/') + repoprefix
248 249 dispatchparts = dispatchpath.strip('/').split('/')
249 elif env.get('PATH_INFO', '').strip('/'):
250 dispatchparts = env['PATH_INFO'].strip('/').split('/')
250 dispatchpath = '/'.join(dispatchparts)
251
252 elif 'PATH_INFO' in env:
253 if env['PATH_INFO'].strip('/'):
254 dispatchparts = env['PATH_INFO'].strip('/').split('/')
255 dispatchpath = '/'.join(dispatchparts)
256 else:
257 dispatchparts = []
258 dispatchpath = ''
251 259 else:
252 260 dispatchparts = []
253
254 dispatchpath = '/'.join(dispatchparts)
261 dispatchpath = None
255 262
256 263 querystring = env.get('QUERY_STRING', '')
257 264
258 265 # We store as a list so we have ordering information. We also store as
259 266 # a dict to facilitate fast lookup.
260 267 qsparams = multidict()
261 268 for k, v in util.urlreq.parseqsl(querystring, keep_blank_values=True):
262 269 qsparams.add(k, v)
263 270
264 271 # HTTP_* keys contain HTTP request headers. The Headers structure should
265 272 # perform case normalization for us. We just rewrite underscore to dash
266 273 # so keys match what likely went over the wire.
267 274 headers = []
268 275 for k, v in env.iteritems():
269 276 if k.startswith('HTTP_'):
270 277 headers.append((k[len('HTTP_'):].replace('_', '-'), v))
271 278
272 279 headers = wsgiheaders.Headers(headers)
273 280
274 281 # This is kind of a lie because the HTTP header wasn't explicitly
275 282 # sent. But for all intents and purposes it should be OK to lie about
276 283 # this, since a consumer will either either value to determine how many
277 284 # bytes are available to read.
278 285 if 'CONTENT_LENGTH' in env and 'HTTP_CONTENT_LENGTH' not in env:
279 286 headers['Content-Length'] = env['CONTENT_LENGTH']
280 287
281 288 # TODO do this once we remove wsgirequest.inp, otherwise we could have
282 289 # multiple readers from the underlying input stream.
283 290 #bodyfh = env['wsgi.input']
284 291 #if 'Content-Length' in headers:
285 292 # bodyfh = util.cappedreader(bodyfh, int(headers['Content-Length']))
286 293
287 294 return parsedrequest(method=env['REQUEST_METHOD'],
288 295 url=fullurl, baseurl=baseurl,
289 296 advertisedurl=advertisedfullurl,
290 297 advertisedbaseurl=advertisedbaseurl,
291 298 urlscheme=env['wsgi.url_scheme'],
292 299 remoteuser=env.get('REMOTE_USER'),
293 300 remotehost=env.get('REMOTE_HOST'),
294 301 apppath=apppath,
295 302 dispatchparts=dispatchparts, dispatchpath=dispatchpath,
296 havepathinfo='PATH_INFO' in env,
297 303 reponame=reponame,
298 304 querystring=querystring,
299 305 qsparams=qsparams,
300 306 headers=headers,
301 307 bodyfh=bodyfh)
302 308
303 309 class offsettrackingwriter(object):
304 310 """A file object like object that is append only and tracks write count.
305 311
306 312 Instances are bound to a callable. This callable is called with data
307 313 whenever a ``write()`` is attempted.
308 314
309 315 Instances track the amount of written data so they can answer ``tell()``
310 316 requests.
311 317
312 318 The intent of this class is to wrap the ``write()`` function returned by
313 319 a WSGI ``start_response()`` function. Since ``write()`` is a callable and
314 320 not a file object, it doesn't implement other file object methods.
315 321 """
316 322 def __init__(self, writefn):
317 323 self._write = writefn
318 324 self._offset = 0
319 325
320 326 def write(self, s):
321 327 res = self._write(s)
322 328 # Some Python objects don't report the number of bytes written.
323 329 if res is None:
324 330 self._offset += len(s)
325 331 else:
326 332 self._offset += res
327 333
328 334 def flush(self):
329 335 pass
330 336
331 337 def tell(self):
332 338 return self._offset
333 339
334 340 class wsgiresponse(object):
335 341 """Represents a response to a WSGI request.
336 342
337 343 A response consists of a status line, headers, and a body.
338 344
339 345 Consumers must populate the ``status`` and ``headers`` fields and
340 346 make a call to a ``setbody*()`` method before the response can be
341 347 issued.
342 348
343 349 When it is time to start sending the response over the wire,
344 350 ``sendresponse()`` is called. It handles emitting the header portion
345 351 of the response message. It then yields chunks of body data to be
346 352 written to the peer. Typically, the WSGI application itself calls
347 353 and returns the value from ``sendresponse()``.
348 354 """
349 355
350 356 def __init__(self, req, startresponse):
351 357 """Create an empty response tied to a specific request.
352 358
353 359 ``req`` is a ``parsedrequest``. ``startresponse`` is the
354 360 ``start_response`` function passed to the WSGI application.
355 361 """
356 362 self._req = req
357 363 self._startresponse = startresponse
358 364
359 365 self.status = None
360 366 self.headers = wsgiheaders.Headers([])
361 367
362 368 self._bodybytes = None
363 369 self._bodygen = None
364 370 self._bodywillwrite = False
365 371 self._started = False
366 372 self._bodywritefn = None
367 373
368 374 def _verifybody(self):
369 375 if (self._bodybytes is not None or self._bodygen is not None
370 376 or self._bodywillwrite):
371 377 raise error.ProgrammingError('cannot define body multiple times')
372 378
373 379 def setbodybytes(self, b):
374 380 """Define the response body as static bytes.
375 381
376 382 The empty string signals that there is no response body.
377 383 """
378 384 self._verifybody()
379 385 self._bodybytes = b
380 386 self.headers['Content-Length'] = '%d' % len(b)
381 387
382 388 def setbodygen(self, gen):
383 389 """Define the response body as a generator of bytes."""
384 390 self._verifybody()
385 391 self._bodygen = gen
386 392
387 393 def setbodywillwrite(self):
388 394 """Signal an intent to use write() to emit the response body.
389 395
390 396 **This is the least preferred way to send a body.**
391 397
392 398 It is preferred for WSGI applications to emit a generator of chunks
393 399 constituting the response body. However, some consumers can't emit
394 400 data this way. So, WSGI provides a way to obtain a ``write(data)``
395 401 function that can be used to synchronously perform an unbuffered
396 402 write.
397 403
398 404 Calling this function signals an intent to produce the body in this
399 405 manner.
400 406 """
401 407 self._verifybody()
402 408 self._bodywillwrite = True
403 409
404 410 def sendresponse(self):
405 411 """Send the generated response to the client.
406 412
407 413 Before this is called, ``status`` must be set and one of
408 414 ``setbodybytes()`` or ``setbodygen()`` must be called.
409 415
410 416 Calling this method multiple times is not allowed.
411 417 """
412 418 if self._started:
413 419 raise error.ProgrammingError('sendresponse() called multiple times')
414 420
415 421 self._started = True
416 422
417 423 if not self.status:
418 424 raise error.ProgrammingError('status line not defined')
419 425
420 426 if (self._bodybytes is None and self._bodygen is None
421 427 and not self._bodywillwrite):
422 428 raise error.ProgrammingError('response body not defined')
423 429
424 430 # RFC 7232 Section 4.1 states that a 304 MUST generate one of
425 431 # {Cache-Control, Content-Location, Date, ETag, Expires, Vary}
426 432 # and SHOULD NOT generate other headers unless they could be used
427 433 # to guide cache updates. Furthermore, RFC 7230 Section 3.3.2
428 434 # states that no response body can be issued. Content-Length can
429 435 # be sent. But if it is present, it should be the size of the response
430 436 # that wasn't transferred.
431 437 if self.status.startswith('304 '):
432 438 # setbodybytes('') will set C-L to 0. This doesn't conform with the
433 439 # spec. So remove it.
434 440 if self.headers.get('Content-Length') == '0':
435 441 del self.headers['Content-Length']
436 442
437 443 # Strictly speaking, this is too strict. But until it causes
438 444 # problems, let's be strict.
439 445 badheaders = {k for k in self.headers.keys()
440 446 if k.lower() not in ('date', 'etag', 'expires',
441 447 'cache-control',
442 448 'content-location',
443 449 'vary')}
444 450 if badheaders:
445 451 raise error.ProgrammingError(
446 452 'illegal header on 304 response: %s' %
447 453 ', '.join(sorted(badheaders)))
448 454
449 455 if self._bodygen is not None or self._bodywillwrite:
450 456 raise error.ProgrammingError("must use setbodybytes('') with "
451 457 "304 responses")
452 458
453 459 # Various HTTP clients (notably httplib) won't read the HTTP response
454 460 # until the HTTP request has been sent in full. If servers (us) send a
455 461 # response before the HTTP request has been fully sent, the connection
456 462 # may deadlock because neither end is reading.
457 463 #
458 464 # We work around this by "draining" the request data before
459 465 # sending any response in some conditions.
460 466 drain = False
461 467 close = False
462 468
463 469 # If the client sent Expect: 100-continue, we assume it is smart enough
464 470 # to deal with the server sending a response before reading the request.
465 471 # (httplib doesn't do this.)
466 472 if self._req.headers.get('Expect', '').lower() == '100-continue':
467 473 pass
468 474 # Only tend to request methods that have bodies. Strictly speaking,
469 475 # we should sniff for a body. But this is fine for our existing
470 476 # WSGI applications.
471 477 elif self._req.method not in ('POST', 'PUT'):
472 478 pass
473 479 else:
474 480 # If we don't know how much data to read, there's no guarantee
475 481 # that we can drain the request responsibly. The WSGI
476 482 # specification only says that servers *should* ensure the
477 483 # input stream doesn't overrun the actual request. So there's
478 484 # no guarantee that reading until EOF won't corrupt the stream
479 485 # state.
480 486 if not isinstance(self._req.bodyfh, util.cappedreader):
481 487 close = True
482 488 else:
483 489 # We /could/ only drain certain HTTP response codes. But 200 and
484 490 # non-200 wire protocol responses both require draining. Since
485 491 # we have a capped reader in place for all situations where we
486 492 # drain, it is safe to read from that stream. We'll either do
487 493 # a drain or no-op if we're already at EOF.
488 494 drain = True
489 495
490 496 if close:
491 497 self.headers['Connection'] = 'Close'
492 498
493 499 if drain:
494 500 assert isinstance(self._req.bodyfh, util.cappedreader)
495 501 while True:
496 502 chunk = self._req.bodyfh.read(32768)
497 503 if not chunk:
498 504 break
499 505
500 506 write = self._startresponse(pycompat.sysstr(self.status),
501 507 self.headers.items())
502 508
503 509 if self._bodybytes:
504 510 yield self._bodybytes
505 511 elif self._bodygen:
506 512 for chunk in self._bodygen:
507 513 yield chunk
508 514 elif self._bodywillwrite:
509 515 self._bodywritefn = write
510 516 else:
511 517 error.ProgrammingError('do not know how to send body')
512 518
513 519 def getbodyfile(self):
514 520 """Obtain a file object like object representing the response body.
515 521
516 522 For this to work, you must call ``setbodywillwrite()`` and then
517 523 ``sendresponse()`` first. ``sendresponse()`` is a generator and the
518 524 function won't run to completion unless the generator is advanced. The
519 525 generator yields not items. The easiest way to consume it is with
520 526 ``list(res.sendresponse())``, which should resolve to an empty list -
521 527 ``[]``.
522 528 """
523 529 if not self._bodywillwrite:
524 530 raise error.ProgrammingError('must call setbodywillwrite() first')
525 531
526 532 if not self._started:
527 533 raise error.ProgrammingError('must call sendresponse() first; did '
528 534 'you remember to consume it since it '
529 535 'is a generator?')
530 536
531 537 assert self._bodywritefn
532 538 return offsettrackingwriter(self._bodywritefn)
533 539
534 540 class wsgirequest(object):
535 541 """Higher-level API for a WSGI request.
536 542
537 543 WSGI applications are invoked with 2 arguments. They are used to
538 544 instantiate instances of this class, which provides higher-level APIs
539 545 for obtaining request parameters, writing HTTP output, etc.
540 546 """
541 547 def __init__(self, wsgienv, start_response):
542 548 version = wsgienv[r'wsgi.version']
543 549 if (version < (1, 0)) or (version >= (2, 0)):
544 550 raise RuntimeError("Unknown and unsupported WSGI version %d.%d"
545 551 % version)
546 552
547 553 inp = wsgienv[r'wsgi.input']
548 554
549 555 if r'HTTP_CONTENT_LENGTH' in wsgienv:
550 556 inp = util.cappedreader(inp, int(wsgienv[r'HTTP_CONTENT_LENGTH']))
551 557 elif r'CONTENT_LENGTH' in wsgienv:
552 558 inp = util.cappedreader(inp, int(wsgienv[r'CONTENT_LENGTH']))
553 559
554 560 self.err = wsgienv[r'wsgi.errors']
555 561 self.threaded = wsgienv[r'wsgi.multithread']
556 562 self.multiprocess = wsgienv[r'wsgi.multiprocess']
557 563 self.run_once = wsgienv[r'wsgi.run_once']
558 564 self.env = wsgienv
559 565 self.req = parserequestfromenv(wsgienv, inp)
560 566 self.res = wsgiresponse(self.req, start_response)
561 567 self._start_response = start_response
562 568 self.server_write = None
563 569 self.headers = []
564 570
565 571 def respond(self, status, type, filename=None, body=None):
566 572 if not isinstance(type, str):
567 573 type = pycompat.sysstr(type)
568 574 if self._start_response is not None:
569 575 self.headers.append((r'Content-Type', type))
570 576 if filename:
571 577 filename = (filename.rpartition('/')[-1]
572 578 .replace('\\', '\\\\').replace('"', '\\"'))
573 579 self.headers.append(('Content-Disposition',
574 580 'inline; filename="%s"' % filename))
575 581 if body is not None:
576 582 self.headers.append((r'Content-Length', str(len(body))))
577 583
578 584 for k, v in self.headers:
579 585 if not isinstance(v, str):
580 586 raise TypeError('header value must be string: %r' % (v,))
581 587
582 588 if isinstance(status, ErrorResponse):
583 589 self.headers.extend(status.headers)
584 590 status = statusmessage(status.code, pycompat.bytestr(status))
585 591 elif status == 200:
586 592 status = '200 Script output follows'
587 593 elif isinstance(status, int):
588 594 status = statusmessage(status)
589 595
590 596 # Various HTTP clients (notably httplib) won't read the HTTP
591 597 # response until the HTTP request has been sent in full. If servers
592 598 # (us) send a response before the HTTP request has been fully sent,
593 599 # the connection may deadlock because neither end is reading.
594 600 #
595 601 # We work around this by "draining" the request data before
596 602 # sending any response in some conditions.
597 603 drain = False
598 604 close = False
599 605
600 606 # If the client sent Expect: 100-continue, we assume it is smart
601 607 # enough to deal with the server sending a response before reading
602 608 # the request. (httplib doesn't do this.)
603 609 if self.env.get(r'HTTP_EXPECT', r'').lower() == r'100-continue':
604 610 pass
605 611 # Only tend to request methods that have bodies. Strictly speaking,
606 612 # we should sniff for a body. But this is fine for our existing
607 613 # WSGI applications.
608 614 elif self.env[r'REQUEST_METHOD'] not in (r'POST', r'PUT'):
609 615 pass
610 616 else:
611 617 # If we don't know how much data to read, there's no guarantee
612 618 # that we can drain the request responsibly. The WSGI
613 619 # specification only says that servers *should* ensure the
614 620 # input stream doesn't overrun the actual request. So there's
615 621 # no guarantee that reading until EOF won't corrupt the stream
616 622 # state.
617 623 if not isinstance(self.req.bodyfh, util.cappedreader):
618 624 close = True
619 625 else:
620 626 # We /could/ only drain certain HTTP response codes. But 200
621 627 # and non-200 wire protocol responses both require draining.
622 628 # Since we have a capped reader in place for all situations
623 629 # where we drain, it is safe to read from that stream. We'll
624 630 # either do a drain or no-op if we're already at EOF.
625 631 drain = True
626 632
627 633 if close:
628 634 self.headers.append((r'Connection', r'Close'))
629 635
630 636 if drain:
631 637 assert isinstance(self.req.bodyfh, util.cappedreader)
632 638 while True:
633 639 chunk = self.req.bodyfh.read(32768)
634 640 if not chunk:
635 641 break
636 642
637 643 self.server_write = self._start_response(
638 644 pycompat.sysstr(status), self.headers)
639 645 self._start_response = None
640 646 self.headers = []
641 647 if body is not None:
642 648 self.write(body)
643 649 self.server_write = None
644 650
645 651 def write(self, thing):
646 652 if thing:
647 653 try:
648 654 self.server_write(thing)
649 655 except socket.error as inst:
650 656 if inst[0] != errno.ECONNRESET:
651 657 raise
652 658
653 659 def flush(self):
654 660 return None
655 661
656 662 def wsgiapplication(app_maker):
657 663 '''For compatibility with old CGI scripts. A plain hgweb() or hgwebdir()
658 664 can and should now be used as a WSGI application.'''
659 665 application = app_maker()
660 666 def run_wsgi(env, respond):
661 667 return application(env, respond)
662 668 return run_wsgi
@@ -1,259 +1,247 b''
1 1 from __future__ import absolute_import, print_function
2 2
3 3 import unittest
4 4
5 5 from mercurial.hgweb import (
6 6 request as requestmod,
7 7 )
8 8 from mercurial import (
9 9 error,
10 10 )
11 11
12 12 DEFAULT_ENV = {
13 13 r'REQUEST_METHOD': r'GET',
14 14 r'SERVER_NAME': r'testserver',
15 15 r'SERVER_PORT': r'80',
16 16 r'SERVER_PROTOCOL': r'http',
17 17 r'wsgi.version': (1, 0),
18 18 r'wsgi.url_scheme': r'http',
19 19 r'wsgi.input': None,
20 20 r'wsgi.errors': None,
21 21 r'wsgi.multithread': False,
22 22 r'wsgi.multiprocess': True,
23 23 r'wsgi.run_once': False,
24 24 }
25 25
26 26 def parse(env, bodyfh=None, reponame=None, extra=None):
27 27 env = dict(env)
28 28 env.update(extra or {})
29 29
30 30 return requestmod.parserequestfromenv(env, bodyfh, reponame=reponame)
31 31
32 32 class ParseRequestTests(unittest.TestCase):
33 33 def testdefault(self):
34 34 r = parse(DEFAULT_ENV)
35 35 self.assertEqual(r.url, b'http://testserver')
36 36 self.assertEqual(r.baseurl, b'http://testserver')
37 37 self.assertEqual(r.advertisedurl, b'http://testserver')
38 38 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
39 39 self.assertEqual(r.urlscheme, b'http')
40 40 self.assertEqual(r.method, b'GET')
41 41 self.assertIsNone(r.remoteuser)
42 42 self.assertIsNone(r.remotehost)
43 43 self.assertEqual(r.apppath, b'')
44 44 self.assertEqual(r.dispatchparts, [])
45 self.assertEqual(r.dispatchpath, b'')
46 self.assertFalse(r.havepathinfo)
45 self.assertIsNone(r.dispatchpath)
47 46 self.assertIsNone(r.reponame)
48 47 self.assertEqual(r.querystring, b'')
49 48 self.assertEqual(len(r.qsparams), 0)
50 49 self.assertEqual(len(r.headers), 0)
51 50
52 51 def testcustomport(self):
53 52 r = parse(DEFAULT_ENV, extra={
54 53 r'SERVER_PORT': r'8000',
55 54 })
56 55
57 56 self.assertEqual(r.url, b'http://testserver:8000')
58 57 self.assertEqual(r.baseurl, b'http://testserver:8000')
59 58 self.assertEqual(r.advertisedurl, b'http://testserver:8000')
60 59 self.assertEqual(r.advertisedbaseurl, b'http://testserver:8000')
61 60
62 61 r = parse(DEFAULT_ENV, extra={
63 62 r'SERVER_PORT': r'4000',
64 63 r'wsgi.url_scheme': r'https',
65 64 })
66 65
67 66 self.assertEqual(r.url, b'https://testserver:4000')
68 67 self.assertEqual(r.baseurl, b'https://testserver:4000')
69 68 self.assertEqual(r.advertisedurl, b'https://testserver:4000')
70 69 self.assertEqual(r.advertisedbaseurl, b'https://testserver:4000')
71 70
72 71 def testhttphost(self):
73 72 r = parse(DEFAULT_ENV, extra={
74 73 r'HTTP_HOST': r'altserver',
75 74 })
76 75
77 76 self.assertEqual(r.url, b'http://altserver')
78 77 self.assertEqual(r.baseurl, b'http://altserver')
79 78 self.assertEqual(r.advertisedurl, b'http://testserver')
80 79 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
81 80
82 81 def testscriptname(self):
83 82 r = parse(DEFAULT_ENV, extra={
84 83 r'SCRIPT_NAME': r'',
85 84 })
86 85
87 86 self.assertEqual(r.url, b'http://testserver')
88 87 self.assertEqual(r.baseurl, b'http://testserver')
89 88 self.assertEqual(r.advertisedurl, b'http://testserver')
90 89 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
91 90 self.assertEqual(r.apppath, b'')
92 91 self.assertEqual(r.dispatchparts, [])
93 self.assertEqual(r.dispatchpath, b'')
94 self.assertFalse(r.havepathinfo)
92 self.assertIsNone(r.dispatchpath)
95 93
96 94 r = parse(DEFAULT_ENV, extra={
97 95 r'SCRIPT_NAME': r'/script',
98 96 })
99 97
100 98 self.assertEqual(r.url, b'http://testserver/script')
101 99 self.assertEqual(r.baseurl, b'http://testserver')
102 100 self.assertEqual(r.advertisedurl, b'http://testserver/script')
103 101 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
104 102 self.assertEqual(r.apppath, b'/script')
105 103 self.assertEqual(r.dispatchparts, [])
106 self.assertEqual(r.dispatchpath, b'')
107 self.assertFalse(r.havepathinfo)
104 self.assertIsNone(r.dispatchpath)
108 105
109 106 r = parse(DEFAULT_ENV, extra={
110 107 r'SCRIPT_NAME': r'/multiple words',
111 108 })
112 109
113 110 self.assertEqual(r.url, b'http://testserver/multiple%20words')
114 111 self.assertEqual(r.baseurl, b'http://testserver')
115 112 self.assertEqual(r.advertisedurl, b'http://testserver/multiple%20words')
116 113 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
117 114 self.assertEqual(r.apppath, b'/multiple words')
118 115 self.assertEqual(r.dispatchparts, [])
119 self.assertEqual(r.dispatchpath, b'')
120 self.assertFalse(r.havepathinfo)
116 self.assertIsNone(r.dispatchpath)
121 117
122 118 def testpathinfo(self):
123 119 r = parse(DEFAULT_ENV, extra={
124 120 r'PATH_INFO': r'',
125 121 })
126 122
127 123 self.assertEqual(r.url, b'http://testserver')
128 124 self.assertEqual(r.baseurl, b'http://testserver')
129 125 self.assertEqual(r.advertisedurl, b'http://testserver')
130 126 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
131 127 self.assertEqual(r.apppath, b'')
132 128 self.assertEqual(r.dispatchparts, [])
133 129 self.assertEqual(r.dispatchpath, b'')
134 self.assertTrue(r.havepathinfo)
135 130
136 131 r = parse(DEFAULT_ENV, extra={
137 132 r'PATH_INFO': r'/pathinfo',
138 133 })
139 134
140 135 self.assertEqual(r.url, b'http://testserver/pathinfo')
141 136 self.assertEqual(r.baseurl, b'http://testserver')
142 137 self.assertEqual(r.advertisedurl, b'http://testserver/pathinfo')
143 138 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
144 139 self.assertEqual(r.apppath, b'')
145 140 self.assertEqual(r.dispatchparts, [b'pathinfo'])
146 141 self.assertEqual(r.dispatchpath, b'pathinfo')
147 self.assertTrue(r.havepathinfo)
148 142
149 143 r = parse(DEFAULT_ENV, extra={
150 144 r'PATH_INFO': r'/one/two/',
151 145 })
152 146
153 147 self.assertEqual(r.url, b'http://testserver/one/two/')
154 148 self.assertEqual(r.baseurl, b'http://testserver')
155 149 self.assertEqual(r.advertisedurl, b'http://testserver/one/two/')
156 150 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
157 151 self.assertEqual(r.apppath, b'')
158 152 self.assertEqual(r.dispatchparts, [b'one', b'two'])
159 153 self.assertEqual(r.dispatchpath, b'one/two')
160 self.assertTrue(r.havepathinfo)
161 154
162 155 def testscriptandpathinfo(self):
163 156 r = parse(DEFAULT_ENV, extra={
164 157 r'SCRIPT_NAME': r'/script',
165 158 r'PATH_INFO': r'/pathinfo',
166 159 })
167 160
168 161 self.assertEqual(r.url, b'http://testserver/script/pathinfo')
169 162 self.assertEqual(r.baseurl, b'http://testserver')
170 163 self.assertEqual(r.advertisedurl, b'http://testserver/script/pathinfo')
171 164 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
172 165 self.assertEqual(r.apppath, b'/script')
173 166 self.assertEqual(r.dispatchparts, [b'pathinfo'])
174 167 self.assertEqual(r.dispatchpath, b'pathinfo')
175 self.assertTrue(r.havepathinfo)
176 168
177 169 r = parse(DEFAULT_ENV, extra={
178 170 r'SCRIPT_NAME': r'/script1/script2',
179 171 r'PATH_INFO': r'/path1/path2',
180 172 })
181 173
182 174 self.assertEqual(r.url,
183 175 b'http://testserver/script1/script2/path1/path2')
184 176 self.assertEqual(r.baseurl, b'http://testserver')
185 177 self.assertEqual(r.advertisedurl,
186 178 b'http://testserver/script1/script2/path1/path2')
187 179 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
188 180 self.assertEqual(r.apppath, b'/script1/script2')
189 181 self.assertEqual(r.dispatchparts, [b'path1', b'path2'])
190 182 self.assertEqual(r.dispatchpath, b'path1/path2')
191 self.assertTrue(r.havepathinfo)
192 183
193 184 r = parse(DEFAULT_ENV, extra={
194 185 r'HTTP_HOST': r'hostserver',
195 186 r'SCRIPT_NAME': r'/script',
196 187 r'PATH_INFO': r'/pathinfo',
197 188 })
198 189
199 190 self.assertEqual(r.url, b'http://hostserver/script/pathinfo')
200 191 self.assertEqual(r.baseurl, b'http://hostserver')
201 192 self.assertEqual(r.advertisedurl, b'http://testserver/script/pathinfo')
202 193 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
203 194 self.assertEqual(r.apppath, b'/script')
204 195 self.assertEqual(r.dispatchparts, [b'pathinfo'])
205 196 self.assertEqual(r.dispatchpath, b'pathinfo')
206 self.assertTrue(r.havepathinfo)
207 197
208 198 def testreponame(self):
209 199 """repository path components get stripped from URL."""
210 200
211 201 with self.assertRaisesRegexp(error.ProgrammingError,
212 202 b'reponame requires PATH_INFO'):
213 203 parse(DEFAULT_ENV, reponame=b'repo')
214 204
215 205 with self.assertRaisesRegexp(error.ProgrammingError,
216 206 b'PATH_INFO does not begin with repo '
217 207 b'name'):
218 208 parse(DEFAULT_ENV, reponame=b'repo', extra={
219 209 r'PATH_INFO': r'/pathinfo',
220 210 })
221 211
222 212 with self.assertRaisesRegexp(error.ProgrammingError,
223 213 b'reponame prefix of PATH_INFO'):
224 214 parse(DEFAULT_ENV, reponame=b'repo', extra={
225 215 r'PATH_INFO': r'/repoextra/path',
226 216 })
227 217
228 218 r = parse(DEFAULT_ENV, reponame=b'repo', extra={
229 219 r'PATH_INFO': r'/repo/path1/path2',
230 220 })
231 221
232 222 self.assertEqual(r.url, b'http://testserver/repo/path1/path2')
233 223 self.assertEqual(r.baseurl, b'http://testserver')
234 224 self.assertEqual(r.advertisedurl, b'http://testserver/repo/path1/path2')
235 225 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
236 226 self.assertEqual(r.apppath, b'/repo')
237 227 self.assertEqual(r.dispatchparts, [b'path1', b'path2'])
238 228 self.assertEqual(r.dispatchpath, b'path1/path2')
239 self.assertTrue(r.havepathinfo)
240 229 self.assertEqual(r.reponame, b'repo')
241 230
242 231 r = parse(DEFAULT_ENV, reponame=b'prefix/repo', extra={
243 232 r'PATH_INFO': r'/prefix/repo/path1/path2',
244 233 })
245 234
246 235 self.assertEqual(r.url, b'http://testserver/prefix/repo/path1/path2')
247 236 self.assertEqual(r.baseurl, b'http://testserver')
248 237 self.assertEqual(r.advertisedurl,
249 238 b'http://testserver/prefix/repo/path1/path2')
250 239 self.assertEqual(r.advertisedbaseurl, b'http://testserver')
251 240 self.assertEqual(r.apppath, b'/prefix/repo')
252 241 self.assertEqual(r.dispatchparts, [b'path1', b'path2'])
253 242 self.assertEqual(r.dispatchpath, b'path1/path2')
254 self.assertTrue(r.havepathinfo)
255 243 self.assertEqual(r.reponame, b'prefix/repo')
256 244
257 245 if __name__ == '__main__':
258 246 import silenttestrunner
259 247 silenttestrunner.main(__name__)
General Comments 0
You need to be logged in to leave comments. Login now