##// END OF EJS Templates
webcommands: document "manifest" web command
Gregory Szorc -
r24090:a86b2922 default
parent child Browse files
Show More
@@ -1,1250 +1,1265
1 1 #
2 2 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 import os, mimetypes, re, cgi, copy
9 9 import webutil
10 10 from mercurial import error, encoding, archival, templater, templatefilters
11 11 from mercurial.node import short, hex
12 12 from mercurial import util
13 13 from common import paritygen, staticfile, get_contact, ErrorResponse
14 14 from common import HTTP_OK, HTTP_FORBIDDEN, HTTP_NOT_FOUND
15 15 from mercurial import graphmod, patch
16 16 from mercurial import scmutil
17 17 from mercurial.i18n import _
18 18 from mercurial.error import ParseError, RepoLookupError, Abort
19 19 from mercurial import revset
20 20
21 21 __all__ = []
22 22 commands = {}
23 23
24 24 class webcommand(object):
25 25 """Decorator used to register a web command handler.
26 26
27 27 The decorator takes as its positional arguments the name/path the
28 28 command should be accessible under.
29 29
30 30 Usage:
31 31
32 32 @webcommand('mycommand')
33 33 def mycommand(web, req, tmpl):
34 34 pass
35 35 """
36 36
37 37 def __init__(self, name):
38 38 self.name = name
39 39
40 40 def __call__(self, func):
41 41 __all__.append(self.name)
42 42 commands[self.name] = func
43 43 return func
44 44
45 45 @webcommand('log')
46 46 def log(web, req, tmpl):
47 47 """
48 48 /log[/{revision}[/{path}]]
49 49 --------------------------
50 50
51 51 Show repository or file history.
52 52
53 53 For URLs of the form ``/log/{revision}``, a list of changesets starting at
54 54 the specified changeset identifier is shown. If ``{revision}`` is not
55 55 defined, the default is ``tip``. This form is equivalent to the
56 56 ``changelog`` handler.
57 57
58 58 For URLs of the form ``/log/{revision}/{file}``, the history for a specific
59 59 file will be shown. This form is equivalent to the ``filelog`` handler.
60 60 """
61 61
62 62 if 'file' in req.form and req.form['file'][0]:
63 63 return filelog(web, req, tmpl)
64 64 else:
65 65 return changelog(web, req, tmpl)
66 66
67 67 @webcommand('rawfile')
68 68 def rawfile(web, req, tmpl):
69 69 guessmime = web.configbool('web', 'guessmime', False)
70 70
71 71 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
72 72 if not path:
73 73 content = manifest(web, req, tmpl)
74 74 req.respond(HTTP_OK, web.ctype)
75 75 return content
76 76
77 77 try:
78 78 fctx = webutil.filectx(web.repo, req)
79 79 except error.LookupError, inst:
80 80 try:
81 81 content = manifest(web, req, tmpl)
82 82 req.respond(HTTP_OK, web.ctype)
83 83 return content
84 84 except ErrorResponse:
85 85 raise inst
86 86
87 87 path = fctx.path()
88 88 text = fctx.data()
89 89 mt = 'application/binary'
90 90 if guessmime:
91 91 mt = mimetypes.guess_type(path)[0]
92 92 if mt is None:
93 93 mt = util.binary(text) and 'application/binary' or 'text/plain'
94 94 if mt.startswith('text/'):
95 95 mt += '; charset="%s"' % encoding.encoding
96 96
97 97 req.respond(HTTP_OK, mt, path, body=text)
98 98 return []
99 99
100 100 def _filerevision(web, tmpl, fctx):
101 101 f = fctx.path()
102 102 text = fctx.data()
103 103 parity = paritygen(web.stripecount)
104 104
105 105 if util.binary(text):
106 106 mt = mimetypes.guess_type(f)[0] or 'application/octet-stream'
107 107 text = '(binary:%s)' % mt
108 108
109 109 def lines():
110 110 for lineno, t in enumerate(text.splitlines(True)):
111 111 yield {"line": t,
112 112 "lineid": "l%d" % (lineno + 1),
113 113 "linenumber": "% 6d" % (lineno + 1),
114 114 "parity": parity.next()}
115 115
116 116 return tmpl("filerevision",
117 117 file=f,
118 118 path=webutil.up(f),
119 119 text=lines(),
120 120 rev=fctx.rev(),
121 121 node=fctx.hex(),
122 122 author=fctx.user(),
123 123 date=fctx.date(),
124 124 desc=fctx.description(),
125 125 extra=fctx.extra(),
126 126 branch=webutil.nodebranchnodefault(fctx),
127 127 parent=webutil.parents(fctx),
128 128 child=webutil.children(fctx),
129 129 rename=webutil.renamelink(fctx),
130 130 permissions=fctx.manifest().flags(f))
131 131
132 132 @webcommand('file')
133 133 def file(web, req, tmpl):
134 134 """
135 135 /file/{revision}[/{path}]
136 136 -------------------------
137 137
138 138 Show information about a directory or file in the repository.
139 139
140 140 Info about the ``path`` given as a URL parameter will be rendered.
141 141
142 142 If ``path`` is a directory, information about the entries in that
143 143 directory will be rendered. This form is equivalent to the ``manifest``
144 144 handler.
145 145
146 146 If ``path`` is a file, information about that file will be shown via
147 147 the ``filerevision`` template.
148 148
149 149 If ``path`` is not defined, information about the root directory will
150 150 be rendered.
151 151 """
152 152 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
153 153 if not path:
154 154 return manifest(web, req, tmpl)
155 155 try:
156 156 return _filerevision(web, tmpl, webutil.filectx(web.repo, req))
157 157 except error.LookupError, inst:
158 158 try:
159 159 return manifest(web, req, tmpl)
160 160 except ErrorResponse:
161 161 raise inst
162 162
163 163 def _search(web, req, tmpl):
164 164 MODE_REVISION = 'rev'
165 165 MODE_KEYWORD = 'keyword'
166 166 MODE_REVSET = 'revset'
167 167
168 168 def revsearch(ctx):
169 169 yield ctx
170 170
171 171 def keywordsearch(query):
172 172 lower = encoding.lower
173 173 qw = lower(query).split()
174 174
175 175 def revgen():
176 176 cl = web.repo.changelog
177 177 for i in xrange(len(web.repo) - 1, 0, -100):
178 178 l = []
179 179 for j in cl.revs(max(0, i - 99), i):
180 180 ctx = web.repo[j]
181 181 l.append(ctx)
182 182 l.reverse()
183 183 for e in l:
184 184 yield e
185 185
186 186 for ctx in revgen():
187 187 miss = 0
188 188 for q in qw:
189 189 if not (q in lower(ctx.user()) or
190 190 q in lower(ctx.description()) or
191 191 q in lower(" ".join(ctx.files()))):
192 192 miss = 1
193 193 break
194 194 if miss:
195 195 continue
196 196
197 197 yield ctx
198 198
199 199 def revsetsearch(revs):
200 200 for r in revs:
201 201 yield web.repo[r]
202 202
203 203 searchfuncs = {
204 204 MODE_REVISION: (revsearch, 'exact revision search'),
205 205 MODE_KEYWORD: (keywordsearch, 'literal keyword search'),
206 206 MODE_REVSET: (revsetsearch, 'revset expression search'),
207 207 }
208 208
209 209 def getsearchmode(query):
210 210 try:
211 211 ctx = web.repo[query]
212 212 except (error.RepoError, error.LookupError):
213 213 # query is not an exact revision pointer, need to
214 214 # decide if it's a revset expression or keywords
215 215 pass
216 216 else:
217 217 return MODE_REVISION, ctx
218 218
219 219 revdef = 'reverse(%s)' % query
220 220 try:
221 221 tree, pos = revset.parse(revdef)
222 222 except ParseError:
223 223 # can't parse to a revset tree
224 224 return MODE_KEYWORD, query
225 225
226 226 if revset.depth(tree) <= 2:
227 227 # no revset syntax used
228 228 return MODE_KEYWORD, query
229 229
230 230 if util.any((token, (value or '')[:3]) == ('string', 're:')
231 231 for token, value, pos in revset.tokenize(revdef)):
232 232 return MODE_KEYWORD, query
233 233
234 234 funcsused = revset.funcsused(tree)
235 235 if not funcsused.issubset(revset.safesymbols):
236 236 return MODE_KEYWORD, query
237 237
238 238 mfunc = revset.match(web.repo.ui, revdef)
239 239 try:
240 240 revs = mfunc(web.repo, revset.spanset(web.repo))
241 241 return MODE_REVSET, revs
242 242 # ParseError: wrongly placed tokens, wrongs arguments, etc
243 243 # RepoLookupError: no such revision, e.g. in 'revision:'
244 244 # Abort: bookmark/tag not exists
245 245 # LookupError: ambiguous identifier, e.g. in '(bc)' on a large repo
246 246 except (ParseError, RepoLookupError, Abort, LookupError):
247 247 return MODE_KEYWORD, query
248 248
249 249 def changelist(**map):
250 250 count = 0
251 251
252 252 for ctx in searchfunc[0](funcarg):
253 253 count += 1
254 254 n = ctx.node()
255 255 showtags = webutil.showtag(web.repo, tmpl, 'changelogtag', n)
256 256 files = webutil.listfilediffs(tmpl, ctx.files(), n, web.maxfiles)
257 257
258 258 yield tmpl('searchentry',
259 259 parity=parity.next(),
260 260 author=ctx.user(),
261 261 parent=webutil.parents(ctx),
262 262 child=webutil.children(ctx),
263 263 changelogtag=showtags,
264 264 desc=ctx.description(),
265 265 extra=ctx.extra(),
266 266 date=ctx.date(),
267 267 files=files,
268 268 rev=ctx.rev(),
269 269 node=hex(n),
270 270 tags=webutil.nodetagsdict(web.repo, n),
271 271 bookmarks=webutil.nodebookmarksdict(web.repo, n),
272 272 inbranch=webutil.nodeinbranch(web.repo, ctx),
273 273 branches=webutil.nodebranchdict(web.repo, ctx))
274 274
275 275 if count >= revcount:
276 276 break
277 277
278 278 query = req.form['rev'][0]
279 279 revcount = web.maxchanges
280 280 if 'revcount' in req.form:
281 281 try:
282 282 revcount = int(req.form.get('revcount', [revcount])[0])
283 283 revcount = max(revcount, 1)
284 284 tmpl.defaults['sessionvars']['revcount'] = revcount
285 285 except ValueError:
286 286 pass
287 287
288 288 lessvars = copy.copy(tmpl.defaults['sessionvars'])
289 289 lessvars['revcount'] = max(revcount / 2, 1)
290 290 lessvars['rev'] = query
291 291 morevars = copy.copy(tmpl.defaults['sessionvars'])
292 292 morevars['revcount'] = revcount * 2
293 293 morevars['rev'] = query
294 294
295 295 mode, funcarg = getsearchmode(query)
296 296
297 297 if 'forcekw' in req.form:
298 298 showforcekw = ''
299 299 showunforcekw = searchfuncs[mode][1]
300 300 mode = MODE_KEYWORD
301 301 funcarg = query
302 302 else:
303 303 if mode != MODE_KEYWORD:
304 304 showforcekw = searchfuncs[MODE_KEYWORD][1]
305 305 else:
306 306 showforcekw = ''
307 307 showunforcekw = ''
308 308
309 309 searchfunc = searchfuncs[mode]
310 310
311 311 tip = web.repo['tip']
312 312 parity = paritygen(web.stripecount)
313 313
314 314 return tmpl('search', query=query, node=tip.hex(),
315 315 entries=changelist, archives=web.archivelist("tip"),
316 316 morevars=morevars, lessvars=lessvars,
317 317 modedesc=searchfunc[1],
318 318 showforcekw=showforcekw, showunforcekw=showunforcekw)
319 319
320 320 @webcommand('changelog')
321 321 def changelog(web, req, tmpl, shortlog=False):
322 322 """
323 323 /changelog[/{revision}]
324 324 -----------------------
325 325
326 326 Show information about multiple changesets.
327 327
328 328 If the optional ``revision`` URL argument is absent, information about
329 329 all changesets starting at ``tip`` will be rendered. If the ``revision``
330 330 argument is present, changesets will be shown starting from the specified
331 331 revision.
332 332
333 333 If ``revision`` is absent, the ``rev`` query string argument may be
334 334 defined. This will perform a search for changesets.
335 335
336 336 The argument for ``rev`` can be a single revision, a revision set,
337 337 or a literal keyword to search for in changeset data (equivalent to
338 338 :hg:`log -k`.
339 339
340 340 The ``revcount`` query string argument defines the maximum numbers of
341 341 changesets to render.
342 342
343 343 For non-searches, the ``changelog`` template will be rendered.
344 344 """
345 345
346 346 query = ''
347 347 if 'node' in req.form:
348 348 ctx = webutil.changectx(web.repo, req)
349 349 elif 'rev' in req.form:
350 350 return _search(web, req, tmpl)
351 351 else:
352 352 ctx = web.repo['tip']
353 353
354 354 def changelist():
355 355 revs = []
356 356 if pos != -1:
357 357 revs = web.repo.changelog.revs(pos, 0)
358 358 curcount = 0
359 359 for rev in revs:
360 360 curcount += 1
361 361 if curcount > revcount + 1:
362 362 break
363 363
364 364 entry = webutil.changelistentry(web, web.repo[rev], tmpl)
365 365 entry['parity'] = parity.next()
366 366 yield entry
367 367
368 368 revcount = shortlog and web.maxshortchanges or web.maxchanges
369 369 if 'revcount' in req.form:
370 370 try:
371 371 revcount = int(req.form.get('revcount', [revcount])[0])
372 372 revcount = max(revcount, 1)
373 373 tmpl.defaults['sessionvars']['revcount'] = revcount
374 374 except ValueError:
375 375 pass
376 376
377 377 lessvars = copy.copy(tmpl.defaults['sessionvars'])
378 378 lessvars['revcount'] = max(revcount / 2, 1)
379 379 morevars = copy.copy(tmpl.defaults['sessionvars'])
380 380 morevars['revcount'] = revcount * 2
381 381
382 382 count = len(web.repo)
383 383 pos = ctx.rev()
384 384 parity = paritygen(web.stripecount)
385 385
386 386 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
387 387
388 388 entries = list(changelist())
389 389 latestentry = entries[:1]
390 390 if len(entries) > revcount:
391 391 nextentry = entries[-1:]
392 392 entries = entries[:-1]
393 393 else:
394 394 nextentry = []
395 395
396 396 return tmpl(shortlog and 'shortlog' or 'changelog', changenav=changenav,
397 397 node=ctx.hex(), rev=pos, changesets=count,
398 398 entries=entries,
399 399 latestentry=latestentry, nextentry=nextentry,
400 400 archives=web.archivelist("tip"), revcount=revcount,
401 401 morevars=morevars, lessvars=lessvars, query=query)
402 402
403 403 @webcommand('shortlog')
404 404 def shortlog(web, req, tmpl):
405 405 """
406 406 /shortlog
407 407 ---------
408 408
409 409 Show basic information about a set of changesets.
410 410
411 411 This accepts the same parameters as the ``changelog`` handler. The only
412 412 difference is the ``shortlog`` template will be rendered instead of the
413 413 ``changelog`` template.
414 414 """
415 415 return changelog(web, req, tmpl, shortlog=True)
416 416
417 417 @webcommand('changeset')
418 418 def changeset(web, req, tmpl):
419 419 """
420 420 /changeset[/{revision}]
421 421 -----------------------
422 422
423 423 Show information about a single changeset.
424 424
425 425 A URL path argument is the changeset identifier to show. See ``hg help
426 426 revisions`` for possible values. If not defined, the ``tip`` changeset
427 427 will be shown.
428 428
429 429 The ``changeset`` template is rendered. Contents of the ``changesettag``,
430 430 ``changesetbookmark``, ``filenodelink``, ``filenolink``, and the many
431 431 templates related to diffs may all be used to produce the output.
432 432 """
433 433 ctx = webutil.changectx(web.repo, req)
434 434 basectx = webutil.basechangectx(web.repo, req)
435 435 if basectx is None:
436 436 basectx = ctx.p1()
437 437 showtags = webutil.showtag(web.repo, tmpl, 'changesettag', ctx.node())
438 438 showbookmarks = webutil.showbookmark(web.repo, tmpl, 'changesetbookmark',
439 439 ctx.node())
440 440 showbranch = webutil.nodebranchnodefault(ctx)
441 441
442 442 files = []
443 443 parity = paritygen(web.stripecount)
444 444 for blockno, f in enumerate(ctx.files()):
445 445 template = f in ctx and 'filenodelink' or 'filenolink'
446 446 files.append(tmpl(template,
447 447 node=ctx.hex(), file=f, blockno=blockno + 1,
448 448 parity=parity.next()))
449 449
450 450 style = web.config('web', 'style', 'paper')
451 451 if 'style' in req.form:
452 452 style = req.form['style'][0]
453 453
454 454 parity = paritygen(web.stripecount)
455 455 diffs = webutil.diffs(web.repo, tmpl, ctx, basectx, None, parity, style)
456 456
457 457 parity = paritygen(web.stripecount)
458 458 diffstatgen = webutil.diffstatgen(ctx, basectx)
459 459 diffstat = webutil.diffstat(tmpl, ctx, diffstatgen, parity)
460 460
461 461 return tmpl('changeset',
462 462 diff=diffs,
463 463 rev=ctx.rev(),
464 464 node=ctx.hex(),
465 465 parent=tuple(webutil.parents(ctx)),
466 466 child=webutil.children(ctx),
467 467 basenode=basectx.hex(),
468 468 changesettag=showtags,
469 469 changesetbookmark=showbookmarks,
470 470 changesetbranch=showbranch,
471 471 author=ctx.user(),
472 472 desc=ctx.description(),
473 473 extra=ctx.extra(),
474 474 date=ctx.date(),
475 475 files=files,
476 476 diffsummary=lambda **x: webutil.diffsummary(diffstatgen),
477 477 diffstat=diffstat,
478 478 archives=web.archivelist(ctx.hex()),
479 479 tags=webutil.nodetagsdict(web.repo, ctx.node()),
480 480 bookmarks=webutil.nodebookmarksdict(web.repo, ctx.node()),
481 481 branch=webutil.nodebranchnodefault(ctx),
482 482 inbranch=webutil.nodeinbranch(web.repo, ctx),
483 483 branches=webutil.nodebranchdict(web.repo, ctx))
484 484
485 485 rev = webcommand('rev')(changeset)
486 486
487 487 def decodepath(path):
488 488 """Hook for mapping a path in the repository to a path in the
489 489 working copy.
490 490
491 491 Extensions (e.g., largefiles) can override this to remap files in
492 492 the virtual file system presented by the manifest command below."""
493 493 return path
494 494
495 495 @webcommand('manifest')
496 496 def manifest(web, req, tmpl):
497 """
498 /manifest[/{revision}[/{path}]]
499 -------------------------------
500
501 Show information about a directory.
502
503 If the URL path arguments are defined, information about the root
504 directory for the ``tip`` changeset will be shown.
505
506 Because this handler can only show information for directories, it
507 is recommended to use the ``file`` handler instead, as it can handle both
508 directories and files.
509
510 The ``manifest`` template will be rendered for this handler.
511 """
497 512 ctx = webutil.changectx(web.repo, req)
498 513 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
499 514 mf = ctx.manifest()
500 515 node = ctx.node()
501 516
502 517 files = {}
503 518 dirs = {}
504 519 parity = paritygen(web.stripecount)
505 520
506 521 if path and path[-1] != "/":
507 522 path += "/"
508 523 l = len(path)
509 524 abspath = "/" + path
510 525
511 526 for full, n in mf.iteritems():
512 527 # the virtual path (working copy path) used for the full
513 528 # (repository) path
514 529 f = decodepath(full)
515 530
516 531 if f[:l] != path:
517 532 continue
518 533 remain = f[l:]
519 534 elements = remain.split('/')
520 535 if len(elements) == 1:
521 536 files[remain] = full
522 537 else:
523 538 h = dirs # need to retain ref to dirs (root)
524 539 for elem in elements[0:-1]:
525 540 if elem not in h:
526 541 h[elem] = {}
527 542 h = h[elem]
528 543 if len(h) > 1:
529 544 break
530 545 h[None] = None # denotes files present
531 546
532 547 if mf and not files and not dirs:
533 548 raise ErrorResponse(HTTP_NOT_FOUND, 'path not found: ' + path)
534 549
535 550 def filelist(**map):
536 551 for f in sorted(files):
537 552 full = files[f]
538 553
539 554 fctx = ctx.filectx(full)
540 555 yield {"file": full,
541 556 "parity": parity.next(),
542 557 "basename": f,
543 558 "date": fctx.date(),
544 559 "size": fctx.size(),
545 560 "permissions": mf.flags(full)}
546 561
547 562 def dirlist(**map):
548 563 for d in sorted(dirs):
549 564
550 565 emptydirs = []
551 566 h = dirs[d]
552 567 while isinstance(h, dict) and len(h) == 1:
553 568 k, v = h.items()[0]
554 569 if v:
555 570 emptydirs.append(k)
556 571 h = v
557 572
558 573 path = "%s%s" % (abspath, d)
559 574 yield {"parity": parity.next(),
560 575 "path": path,
561 576 "emptydirs": "/".join(emptydirs),
562 577 "basename": d}
563 578
564 579 return tmpl("manifest",
565 580 rev=ctx.rev(),
566 581 node=hex(node),
567 582 path=abspath,
568 583 up=webutil.up(abspath),
569 584 upparity=parity.next(),
570 585 fentries=filelist,
571 586 dentries=dirlist,
572 587 archives=web.archivelist(hex(node)),
573 588 tags=webutil.nodetagsdict(web.repo, node),
574 589 bookmarks=webutil.nodebookmarksdict(web.repo, node),
575 590 inbranch=webutil.nodeinbranch(web.repo, ctx),
576 591 branches=webutil.nodebranchdict(web.repo, ctx))
577 592
578 593 @webcommand('tags')
579 594 def tags(web, req, tmpl):
580 595 """
581 596 /tags
582 597 -----
583 598
584 599 Show information about tags.
585 600
586 601 No arguments are accepted.
587 602
588 603 The ``tags`` template is rendered.
589 604 """
590 605 i = list(reversed(web.repo.tagslist()))
591 606 parity = paritygen(web.stripecount)
592 607
593 608 def entries(notip, latestonly, **map):
594 609 t = i
595 610 if notip:
596 611 t = [(k, n) for k, n in i if k != "tip"]
597 612 if latestonly:
598 613 t = t[:1]
599 614 for k, n in t:
600 615 yield {"parity": parity.next(),
601 616 "tag": k,
602 617 "date": web.repo[n].date(),
603 618 "node": hex(n)}
604 619
605 620 return tmpl("tags",
606 621 node=hex(web.repo.changelog.tip()),
607 622 entries=lambda **x: entries(False, False, **x),
608 623 entriesnotip=lambda **x: entries(True, False, **x),
609 624 latestentry=lambda **x: entries(True, True, **x))
610 625
611 626 @webcommand('bookmarks')
612 627 def bookmarks(web, req, tmpl):
613 628 """
614 629 /bookmarks
615 630 ----------
616 631
617 632 Show information about bookmarks.
618 633
619 634 No arguments are accepted.
620 635
621 636 The ``bookmarks`` template is rendered.
622 637 """
623 638 i = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
624 639 parity = paritygen(web.stripecount)
625 640
626 641 def entries(latestonly, **map):
627 642 if latestonly:
628 643 t = [min(i)]
629 644 else:
630 645 t = sorted(i)
631 646 for k, n in t:
632 647 yield {"parity": parity.next(),
633 648 "bookmark": k,
634 649 "date": web.repo[n].date(),
635 650 "node": hex(n)}
636 651
637 652 return tmpl("bookmarks",
638 653 node=hex(web.repo.changelog.tip()),
639 654 entries=lambda **x: entries(latestonly=False, **x),
640 655 latestentry=lambda **x: entries(latestonly=True, **x))
641 656
642 657 @webcommand('branches')
643 658 def branches(web, req, tmpl):
644 659 """
645 660 /branches
646 661 ---------
647 662
648 663 Show information about branches.
649 664
650 665 All known branches are contained in the output, even closed branches.
651 666
652 667 No arguments are accepted.
653 668
654 669 The ``branches`` template is rendered.
655 670 """
656 671 tips = []
657 672 heads = web.repo.heads()
658 673 parity = paritygen(web.stripecount)
659 674 sortkey = lambda item: (not item[1], item[0].rev())
660 675
661 676 def entries(limit, **map):
662 677 count = 0
663 678 if not tips:
664 679 for tag, hs, tip, closed in web.repo.branchmap().iterbranches():
665 680 tips.append((web.repo[tip], closed))
666 681 for ctx, closed in sorted(tips, key=sortkey, reverse=True):
667 682 if limit > 0 and count >= limit:
668 683 return
669 684 count += 1
670 685 if closed:
671 686 status = 'closed'
672 687 elif ctx.node() not in heads:
673 688 status = 'inactive'
674 689 else:
675 690 status = 'open'
676 691 yield {'parity': parity.next(),
677 692 'branch': ctx.branch(),
678 693 'status': status,
679 694 'node': ctx.hex(),
680 695 'date': ctx.date()}
681 696
682 697 return tmpl('branches', node=hex(web.repo.changelog.tip()),
683 698 entries=lambda **x: entries(0, **x),
684 699 latestentry=lambda **x: entries(1, **x))
685 700
686 701 @webcommand('summary')
687 702 def summary(web, req, tmpl):
688 703 i = reversed(web.repo.tagslist())
689 704
690 705 def tagentries(**map):
691 706 parity = paritygen(web.stripecount)
692 707 count = 0
693 708 for k, n in i:
694 709 if k == "tip": # skip tip
695 710 continue
696 711
697 712 count += 1
698 713 if count > 10: # limit to 10 tags
699 714 break
700 715
701 716 yield tmpl("tagentry",
702 717 parity=parity.next(),
703 718 tag=k,
704 719 node=hex(n),
705 720 date=web.repo[n].date())
706 721
707 722 def bookmarks(**map):
708 723 parity = paritygen(web.stripecount)
709 724 marks = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
710 725 for k, n in sorted(marks)[:10]: # limit to 10 bookmarks
711 726 yield {'parity': parity.next(),
712 727 'bookmark': k,
713 728 'date': web.repo[n].date(),
714 729 'node': hex(n)}
715 730
716 731 def branches(**map):
717 732 parity = paritygen(web.stripecount)
718 733
719 734 b = web.repo.branchmap()
720 735 l = [(-web.repo.changelog.rev(tip), tip, tag)
721 736 for tag, heads, tip, closed in b.iterbranches()]
722 737 for r, n, t in sorted(l):
723 738 yield {'parity': parity.next(),
724 739 'branch': t,
725 740 'node': hex(n),
726 741 'date': web.repo[n].date()}
727 742
728 743 def changelist(**map):
729 744 parity = paritygen(web.stripecount, offset=start - end)
730 745 l = [] # build a list in forward order for efficiency
731 746 revs = []
732 747 if start < end:
733 748 revs = web.repo.changelog.revs(start, end - 1)
734 749 for i in revs:
735 750 ctx = web.repo[i]
736 751 n = ctx.node()
737 752 hn = hex(n)
738 753
739 754 l.append(tmpl(
740 755 'shortlogentry',
741 756 parity=parity.next(),
742 757 author=ctx.user(),
743 758 desc=ctx.description(),
744 759 extra=ctx.extra(),
745 760 date=ctx.date(),
746 761 rev=i,
747 762 node=hn,
748 763 tags=webutil.nodetagsdict(web.repo, n),
749 764 bookmarks=webutil.nodebookmarksdict(web.repo, n),
750 765 inbranch=webutil.nodeinbranch(web.repo, ctx),
751 766 branches=webutil.nodebranchdict(web.repo, ctx)))
752 767
753 768 l.reverse()
754 769 yield l
755 770
756 771 tip = web.repo['tip']
757 772 count = len(web.repo)
758 773 start = max(0, count - web.maxchanges)
759 774 end = min(count, start + web.maxchanges)
760 775
761 776 return tmpl("summary",
762 777 desc=web.config("web", "description", "unknown"),
763 778 owner=get_contact(web.config) or "unknown",
764 779 lastchange=tip.date(),
765 780 tags=tagentries,
766 781 bookmarks=bookmarks,
767 782 branches=branches,
768 783 shortlog=changelist,
769 784 node=tip.hex(),
770 785 archives=web.archivelist("tip"))
771 786
772 787 @webcommand('filediff')
773 788 def filediff(web, req, tmpl):
774 789 fctx, ctx = None, None
775 790 try:
776 791 fctx = webutil.filectx(web.repo, req)
777 792 except LookupError:
778 793 ctx = webutil.changectx(web.repo, req)
779 794 path = webutil.cleanpath(web.repo, req.form['file'][0])
780 795 if path not in ctx.files():
781 796 raise
782 797
783 798 if fctx is not None:
784 799 n = fctx.node()
785 800 path = fctx.path()
786 801 ctx = fctx.changectx()
787 802 else:
788 803 n = ctx.node()
789 804 # path already defined in except clause
790 805
791 806 parity = paritygen(web.stripecount)
792 807 style = web.config('web', 'style', 'paper')
793 808 if 'style' in req.form:
794 809 style = req.form['style'][0]
795 810
796 811 diffs = webutil.diffs(web.repo, tmpl, ctx, None, [path], parity, style)
797 812 rename = fctx and webutil.renamelink(fctx) or []
798 813 ctx = fctx and fctx or ctx
799 814 return tmpl("filediff",
800 815 file=path,
801 816 node=hex(n),
802 817 rev=ctx.rev(),
803 818 date=ctx.date(),
804 819 desc=ctx.description(),
805 820 extra=ctx.extra(),
806 821 author=ctx.user(),
807 822 rename=rename,
808 823 branch=webutil.nodebranchnodefault(ctx),
809 824 parent=webutil.parents(ctx),
810 825 child=webutil.children(ctx),
811 826 diff=diffs)
812 827
813 828 diff = webcommand('diff')(filediff)
814 829
815 830 @webcommand('comparison')
816 831 def comparison(web, req, tmpl):
817 832 ctx = webutil.changectx(web.repo, req)
818 833 if 'file' not in req.form:
819 834 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
820 835 path = webutil.cleanpath(web.repo, req.form['file'][0])
821 836 rename = path in ctx and webutil.renamelink(ctx[path]) or []
822 837
823 838 parsecontext = lambda v: v == 'full' and -1 or int(v)
824 839 if 'context' in req.form:
825 840 context = parsecontext(req.form['context'][0])
826 841 else:
827 842 context = parsecontext(web.config('web', 'comparisoncontext', '5'))
828 843
829 844 def filelines(f):
830 845 if util.binary(f.data()):
831 846 mt = mimetypes.guess_type(f.path())[0]
832 847 if not mt:
833 848 mt = 'application/octet-stream'
834 849 return [_('(binary file %s, hash: %s)') % (mt, hex(f.filenode()))]
835 850 return f.data().splitlines()
836 851
837 852 parent = ctx.p1()
838 853 leftrev = parent.rev()
839 854 leftnode = parent.node()
840 855 rightrev = ctx.rev()
841 856 rightnode = ctx.node()
842 857 if path in ctx:
843 858 fctx = ctx[path]
844 859 rightlines = filelines(fctx)
845 860 if path not in parent:
846 861 leftlines = ()
847 862 else:
848 863 pfctx = parent[path]
849 864 leftlines = filelines(pfctx)
850 865 else:
851 866 rightlines = ()
852 867 fctx = ctx.parents()[0][path]
853 868 leftlines = filelines(fctx)
854 869
855 870 comparison = webutil.compare(tmpl, context, leftlines, rightlines)
856 871 return tmpl('filecomparison',
857 872 file=path,
858 873 node=hex(ctx.node()),
859 874 rev=ctx.rev(),
860 875 date=ctx.date(),
861 876 desc=ctx.description(),
862 877 extra=ctx.extra(),
863 878 author=ctx.user(),
864 879 rename=rename,
865 880 branch=webutil.nodebranchnodefault(ctx),
866 881 parent=webutil.parents(fctx),
867 882 child=webutil.children(fctx),
868 883 leftrev=leftrev,
869 884 leftnode=hex(leftnode),
870 885 rightrev=rightrev,
871 886 rightnode=hex(rightnode),
872 887 comparison=comparison)
873 888
874 889 @webcommand('annotate')
875 890 def annotate(web, req, tmpl):
876 891 fctx = webutil.filectx(web.repo, req)
877 892 f = fctx.path()
878 893 parity = paritygen(web.stripecount)
879 894 diffopts = patch.difffeatureopts(web.repo.ui, untrusted=True,
880 895 section='annotate', whitespace=True)
881 896
882 897 def annotate(**map):
883 898 last = None
884 899 if util.binary(fctx.data()):
885 900 mt = (mimetypes.guess_type(fctx.path())[0]
886 901 or 'application/octet-stream')
887 902 lines = enumerate([((fctx.filectx(fctx.filerev()), 1),
888 903 '(binary:%s)' % mt)])
889 904 else:
890 905 lines = enumerate(fctx.annotate(follow=True, linenumber=True,
891 906 diffopts=diffopts))
892 907 for lineno, ((f, targetline), l) in lines:
893 908 fnode = f.filenode()
894 909
895 910 if last != fnode:
896 911 last = fnode
897 912
898 913 yield {"parity": parity.next(),
899 914 "node": f.hex(),
900 915 "rev": f.rev(),
901 916 "author": f.user(),
902 917 "desc": f.description(),
903 918 "extra": f.extra(),
904 919 "file": f.path(),
905 920 "targetline": targetline,
906 921 "line": l,
907 922 "lineid": "l%d" % (lineno + 1),
908 923 "linenumber": "% 6d" % (lineno + 1),
909 924 "revdate": f.date()}
910 925
911 926 return tmpl("fileannotate",
912 927 file=f,
913 928 annotate=annotate,
914 929 path=webutil.up(f),
915 930 rev=fctx.rev(),
916 931 node=fctx.hex(),
917 932 author=fctx.user(),
918 933 date=fctx.date(),
919 934 desc=fctx.description(),
920 935 extra=fctx.extra(),
921 936 rename=webutil.renamelink(fctx),
922 937 branch=webutil.nodebranchnodefault(fctx),
923 938 parent=webutil.parents(fctx),
924 939 child=webutil.children(fctx),
925 940 permissions=fctx.manifest().flags(f))
926 941
927 942 @webcommand('filelog')
928 943 def filelog(web, req, tmpl):
929 944
930 945 try:
931 946 fctx = webutil.filectx(web.repo, req)
932 947 f = fctx.path()
933 948 fl = fctx.filelog()
934 949 except error.LookupError:
935 950 f = webutil.cleanpath(web.repo, req.form['file'][0])
936 951 fl = web.repo.file(f)
937 952 numrevs = len(fl)
938 953 if not numrevs: # file doesn't exist at all
939 954 raise
940 955 rev = webutil.changectx(web.repo, req).rev()
941 956 first = fl.linkrev(0)
942 957 if rev < first: # current rev is from before file existed
943 958 raise
944 959 frev = numrevs - 1
945 960 while fl.linkrev(frev) > rev:
946 961 frev -= 1
947 962 fctx = web.repo.filectx(f, fl.linkrev(frev))
948 963
949 964 revcount = web.maxshortchanges
950 965 if 'revcount' in req.form:
951 966 try:
952 967 revcount = int(req.form.get('revcount', [revcount])[0])
953 968 revcount = max(revcount, 1)
954 969 tmpl.defaults['sessionvars']['revcount'] = revcount
955 970 except ValueError:
956 971 pass
957 972
958 973 lessvars = copy.copy(tmpl.defaults['sessionvars'])
959 974 lessvars['revcount'] = max(revcount / 2, 1)
960 975 morevars = copy.copy(tmpl.defaults['sessionvars'])
961 976 morevars['revcount'] = revcount * 2
962 977
963 978 count = fctx.filerev() + 1
964 979 start = max(0, fctx.filerev() - revcount + 1) # first rev on this page
965 980 end = min(count, start + revcount) # last rev on this page
966 981 parity = paritygen(web.stripecount, offset=start - end)
967 982
968 983 def entries():
969 984 l = []
970 985
971 986 repo = web.repo
972 987 revs = fctx.filelog().revs(start, end - 1)
973 988 for i in revs:
974 989 iterfctx = fctx.filectx(i)
975 990
976 991 l.append({"parity": parity.next(),
977 992 "filerev": i,
978 993 "file": f,
979 994 "node": iterfctx.hex(),
980 995 "author": iterfctx.user(),
981 996 "date": iterfctx.date(),
982 997 "rename": webutil.renamelink(iterfctx),
983 998 "parent": webutil.parents(iterfctx),
984 999 "child": webutil.children(iterfctx),
985 1000 "desc": iterfctx.description(),
986 1001 "extra": iterfctx.extra(),
987 1002 "tags": webutil.nodetagsdict(repo, iterfctx.node()),
988 1003 "bookmarks": webutil.nodebookmarksdict(
989 1004 repo, iterfctx.node()),
990 1005 "branch": webutil.nodebranchnodefault(iterfctx),
991 1006 "inbranch": webutil.nodeinbranch(repo, iterfctx),
992 1007 "branches": webutil.nodebranchdict(repo, iterfctx)})
993 1008 for e in reversed(l):
994 1009 yield e
995 1010
996 1011 entries = list(entries())
997 1012 latestentry = entries[:1]
998 1013
999 1014 revnav = webutil.filerevnav(web.repo, fctx.path())
1000 1015 nav = revnav.gen(end - 1, revcount, count)
1001 1016 return tmpl("filelog", file=f, node=fctx.hex(), nav=nav,
1002 1017 entries=entries,
1003 1018 latestentry=latestentry,
1004 1019 revcount=revcount, morevars=morevars, lessvars=lessvars)
1005 1020
1006 1021 @webcommand('archive')
1007 1022 def archive(web, req, tmpl):
1008 1023 type_ = req.form.get('type', [None])[0]
1009 1024 allowed = web.configlist("web", "allow_archive")
1010 1025 key = req.form['node'][0]
1011 1026
1012 1027 if type_ not in web.archives:
1013 1028 msg = 'Unsupported archive type: %s' % type_
1014 1029 raise ErrorResponse(HTTP_NOT_FOUND, msg)
1015 1030
1016 1031 if not ((type_ in allowed or
1017 1032 web.configbool("web", "allow" + type_, False))):
1018 1033 msg = 'Archive type not allowed: %s' % type_
1019 1034 raise ErrorResponse(HTTP_FORBIDDEN, msg)
1020 1035
1021 1036 reponame = re.sub(r"\W+", "-", os.path.basename(web.reponame))
1022 1037 cnode = web.repo.lookup(key)
1023 1038 arch_version = key
1024 1039 if cnode == key or key == 'tip':
1025 1040 arch_version = short(cnode)
1026 1041 name = "%s-%s" % (reponame, arch_version)
1027 1042
1028 1043 ctx = webutil.changectx(web.repo, req)
1029 1044 pats = []
1030 1045 matchfn = scmutil.match(ctx, [])
1031 1046 file = req.form.get('file', None)
1032 1047 if file:
1033 1048 pats = ['path:' + file[0]]
1034 1049 matchfn = scmutil.match(ctx, pats, default='path')
1035 1050 if pats:
1036 1051 files = [f for f in ctx.manifest().keys() if matchfn(f)]
1037 1052 if not files:
1038 1053 raise ErrorResponse(HTTP_NOT_FOUND,
1039 1054 'file(s) not found: %s' % file[0])
1040 1055
1041 1056 mimetype, artype, extension, encoding = web.archive_specs[type_]
1042 1057 headers = [
1043 1058 ('Content-Disposition', 'attachment; filename=%s%s' % (name, extension))
1044 1059 ]
1045 1060 if encoding:
1046 1061 headers.append(('Content-Encoding', encoding))
1047 1062 req.headers.extend(headers)
1048 1063 req.respond(HTTP_OK, mimetype)
1049 1064
1050 1065 archival.archive(web.repo, req, cnode, artype, prefix=name,
1051 1066 matchfn=matchfn,
1052 1067 subrepos=web.configbool("web", "archivesubrepos"))
1053 1068 return []
1054 1069
1055 1070
1056 1071 @webcommand('static')
1057 1072 def static(web, req, tmpl):
1058 1073 fname = req.form['file'][0]
1059 1074 # a repo owner may set web.static in .hg/hgrc to get any file
1060 1075 # readable by the user running the CGI script
1061 1076 static = web.config("web", "static", None, untrusted=False)
1062 1077 if not static:
1063 1078 tp = web.templatepath or templater.templatepaths()
1064 1079 if isinstance(tp, str):
1065 1080 tp = [tp]
1066 1081 static = [os.path.join(p, 'static') for p in tp]
1067 1082 staticfile(static, fname, req)
1068 1083 return []
1069 1084
1070 1085 @webcommand('graph')
1071 1086 def graph(web, req, tmpl):
1072 1087
1073 1088 ctx = webutil.changectx(web.repo, req)
1074 1089 rev = ctx.rev()
1075 1090
1076 1091 bg_height = 39
1077 1092 revcount = web.maxshortchanges
1078 1093 if 'revcount' in req.form:
1079 1094 try:
1080 1095 revcount = int(req.form.get('revcount', [revcount])[0])
1081 1096 revcount = max(revcount, 1)
1082 1097 tmpl.defaults['sessionvars']['revcount'] = revcount
1083 1098 except ValueError:
1084 1099 pass
1085 1100
1086 1101 lessvars = copy.copy(tmpl.defaults['sessionvars'])
1087 1102 lessvars['revcount'] = max(revcount / 2, 1)
1088 1103 morevars = copy.copy(tmpl.defaults['sessionvars'])
1089 1104 morevars['revcount'] = revcount * 2
1090 1105
1091 1106 count = len(web.repo)
1092 1107 pos = rev
1093 1108
1094 1109 uprev = min(max(0, count - 1), rev + revcount)
1095 1110 downrev = max(0, rev - revcount)
1096 1111 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
1097 1112
1098 1113 tree = []
1099 1114 if pos != -1:
1100 1115 allrevs = web.repo.changelog.revs(pos, 0)
1101 1116 revs = []
1102 1117 for i in allrevs:
1103 1118 revs.append(i)
1104 1119 if len(revs) >= revcount:
1105 1120 break
1106 1121
1107 1122 # We have to feed a baseset to dagwalker as it is expecting smartset
1108 1123 # object. This does not have a big impact on hgweb performance itself
1109 1124 # since hgweb graphing code is not itself lazy yet.
1110 1125 dag = graphmod.dagwalker(web.repo, revset.baseset(revs))
1111 1126 # As we said one line above... not lazy.
1112 1127 tree = list(graphmod.colored(dag, web.repo))
1113 1128
1114 1129 def getcolumns(tree):
1115 1130 cols = 0
1116 1131 for (id, type, ctx, vtx, edges) in tree:
1117 1132 if type != graphmod.CHANGESET:
1118 1133 continue
1119 1134 cols = max(cols, max([edge[0] for edge in edges] or [0]),
1120 1135 max([edge[1] for edge in edges] or [0]))
1121 1136 return cols
1122 1137
1123 1138 def graphdata(usetuples, **map):
1124 1139 data = []
1125 1140
1126 1141 row = 0
1127 1142 for (id, type, ctx, vtx, edges) in tree:
1128 1143 if type != graphmod.CHANGESET:
1129 1144 continue
1130 1145 node = str(ctx)
1131 1146 age = templatefilters.age(ctx.date())
1132 1147 desc = templatefilters.firstline(ctx.description())
1133 1148 desc = cgi.escape(templatefilters.nonempty(desc))
1134 1149 user = cgi.escape(templatefilters.person(ctx.user()))
1135 1150 branch = cgi.escape(ctx.branch())
1136 1151 try:
1137 1152 branchnode = web.repo.branchtip(branch)
1138 1153 except error.RepoLookupError:
1139 1154 branchnode = None
1140 1155 branch = branch, branchnode == ctx.node()
1141 1156
1142 1157 if usetuples:
1143 1158 data.append((node, vtx, edges, desc, user, age, branch,
1144 1159 [cgi.escape(x) for x in ctx.tags()],
1145 1160 [cgi.escape(x) for x in ctx.bookmarks()]))
1146 1161 else:
1147 1162 edgedata = [{'col': edge[0], 'nextcol': edge[1],
1148 1163 'color': (edge[2] - 1) % 6 + 1,
1149 1164 'width': edge[3], 'bcolor': edge[4]}
1150 1165 for edge in edges]
1151 1166
1152 1167 data.append(
1153 1168 {'node': node,
1154 1169 'col': vtx[0],
1155 1170 'color': (vtx[1] - 1) % 6 + 1,
1156 1171 'edges': edgedata,
1157 1172 'row': row,
1158 1173 'nextrow': row + 1,
1159 1174 'desc': desc,
1160 1175 'user': user,
1161 1176 'age': age,
1162 1177 'bookmarks': webutil.nodebookmarksdict(
1163 1178 web.repo, ctx.node()),
1164 1179 'branches': webutil.nodebranchdict(web.repo, ctx),
1165 1180 'inbranch': webutil.nodeinbranch(web.repo, ctx),
1166 1181 'tags': webutil.nodetagsdict(web.repo, ctx.node())})
1167 1182
1168 1183 row += 1
1169 1184
1170 1185 return data
1171 1186
1172 1187 cols = getcolumns(tree)
1173 1188 rows = len(tree)
1174 1189 canvasheight = (rows + 1) * bg_height - 27
1175 1190
1176 1191 return tmpl('graph', rev=rev, revcount=revcount, uprev=uprev,
1177 1192 lessvars=lessvars, morevars=morevars, downrev=downrev,
1178 1193 cols=cols, rows=rows,
1179 1194 canvaswidth=(cols + 1) * bg_height,
1180 1195 truecanvasheight=rows * bg_height,
1181 1196 canvasheight=canvasheight, bg_height=bg_height,
1182 1197 jsdata=lambda **x: graphdata(True, **x),
1183 1198 nodes=lambda **x: graphdata(False, **x),
1184 1199 node=ctx.hex(), changenav=changenav)
1185 1200
1186 1201 def _getdoc(e):
1187 1202 doc = e[0].__doc__
1188 1203 if doc:
1189 1204 doc = _(doc).split('\n')[0]
1190 1205 else:
1191 1206 doc = _('(no help text available)')
1192 1207 return doc
1193 1208
1194 1209 @webcommand('help')
1195 1210 def help(web, req, tmpl):
1196 1211 """
1197 1212 /help[/{topic}]
1198 1213 ---------------
1199 1214
1200 1215 Render help documentation.
1201 1216
1202 1217 This web command is roughly equivalent to :hg:`help`. If a ``topic``
1203 1218 is defined, that help topic will be rendered. If not, an index of
1204 1219 available help topics will be rendered.
1205 1220
1206 1221 The ``help`` template will be rendered when requesting help for a topic.
1207 1222 ``helptopics`` will be rendered for the index of help topics.
1208 1223 """
1209 1224 from mercurial import commands # avoid cycle
1210 1225 from mercurial import help as helpmod # avoid cycle
1211 1226
1212 1227 topicname = req.form.get('node', [None])[0]
1213 1228 if not topicname:
1214 1229 def topics(**map):
1215 1230 for entries, summary, _doc in helpmod.helptable:
1216 1231 yield {'topic': entries[0], 'summary': summary}
1217 1232
1218 1233 early, other = [], []
1219 1234 primary = lambda s: s.split('|')[0]
1220 1235 for c, e in commands.table.iteritems():
1221 1236 doc = _getdoc(e)
1222 1237 if 'DEPRECATED' in doc or c.startswith('debug'):
1223 1238 continue
1224 1239 cmd = primary(c)
1225 1240 if cmd.startswith('^'):
1226 1241 early.append((cmd[1:], doc))
1227 1242 else:
1228 1243 other.append((cmd, doc))
1229 1244
1230 1245 early.sort()
1231 1246 other.sort()
1232 1247
1233 1248 def earlycommands(**map):
1234 1249 for c, doc in early:
1235 1250 yield {'topic': c, 'summary': doc}
1236 1251
1237 1252 def othercommands(**map):
1238 1253 for c, doc in other:
1239 1254 yield {'topic': c, 'summary': doc}
1240 1255
1241 1256 return tmpl('helptopics', topics=topics, earlycommands=earlycommands,
1242 1257 othercommands=othercommands, title='Index')
1243 1258
1244 1259 u = webutil.wsgiui()
1245 1260 u.verbose = True
1246 1261 try:
1247 1262 doc = helpmod.help_(u, topicname)
1248 1263 except error.UnknownCommand:
1249 1264 raise ErrorResponse(HTTP_NOT_FOUND)
1250 1265 return tmpl('help', topic=topicname, doc=doc)
General Comments 0
You need to be logged in to leave comments. Login now