Package buildbot :: Package status :: Package web :: Module base
[frames] | no frames]

Source Code for Module buildbot.status.web.base

  1  # This file is part of Buildbot.  Buildbot is free software: you can 
  2  # redistribute it and/or modify it under the terms of the GNU General Public 
  3  # License as published by the Free Software Foundation, version 2. 
  4  # 
  5  # This program is distributed in the hope that it will be useful, but WITHOUT 
  6  # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 
  7  # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more 
  8  # details. 
  9  # 
 10  # You should have received a copy of the GNU General Public License along with 
 11  # this program; if not, write to the Free Software Foundation, Inc., 51 
 12  # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 
 13  # 
 14  # Copyright Buildbot Team Members 
 15   
 16   
 17  import urlparse, urllib, time, re 
 18  import os, cgi, sys, locale 
 19  import jinja2 
 20  from zope.interface import Interface 
 21  from twisted.internet import defer 
 22  from twisted.web import resource, static, server 
 23  from twisted.python import log 
 24  from buildbot.status import builder, buildstep, build 
 25  from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, SKIPPED 
 26  from buildbot.status.results import EXCEPTION, RETRY 
 27  from buildbot import version, util 
 28  from buildbot.process.properties import Properties 
 29   
30 -class ITopBox(Interface):
31 """I represent a box in the top row of the waterfall display: the one 32 which shows the status of the last build for each builder."""
33 - def getBox(self, request):
34 """Return a Box instance, which can produce a <td> cell. 35 """
36
37 -class ICurrentBox(Interface):
38 """I represent the 'current activity' box, just above the builder name."""
39 - def getBox(self, status):
40 """Return a Box instance, which can produce a <td> cell. 41 """
42
43 -class IBox(Interface):
44 """I represent a box in the waterfall display."""
45 - def getBox(self, request):
46 """Return a Box instance, which wraps an Event and can produce a <td> 47 cell. 48 """
49
50 -class IHTMLLog(Interface):
51 pass
52 53 css_classes = {SUCCESS: "success", 54 WARNINGS: "warnings", 55 FAILURE: "failure", 56 SKIPPED: "skipped", 57 EXCEPTION: "exception", 58 RETRY: "retry", 59 None: "", 60 } 61 62
63 -def getAndCheckProperties(req):
64 """ 65 Fetch custom build properties from the HTTP request of a "Force build" or 66 "Resubmit build" HTML form. 67 Check the names for valid strings, and return None if a problem is found. 68 Return a new Properties object containing each property found in req. 69 """ 70 master = req.site.buildbot_service.master 71 pname_validate = master.config.validation['property_name'] 72 pval_validate = master.config.validation['property_value'] 73 properties = Properties() 74 i = 1 75 while True: 76 pname = req.args.get("property%dname" % i, [""])[0] 77 pvalue = req.args.get("property%dvalue" % i, [""])[0] 78 if not pname: 79 break 80 if not pname_validate.match(pname) \ 81 or not pval_validate.match(pvalue): 82 log.msg("bad property name='%s', value='%s'" % (pname, pvalue)) 83 return None 84 properties.setProperty(pname, pvalue, "Force Build Form") 85 i = i + 1 86 87 return properties
88
89 -def build_get_class(b):
90 """ 91 Return the class to use for a finished build or buildstep, 92 based on the result. 93 """ 94 # FIXME: this getResults duplicity might need to be fixed 95 result = b.getResults() 96 if isinstance(b, build.BuildStatus): 97 result = b.getResults() 98 elif isinstance(b, buildstep.BuildStepStatus): 99 result = b.getResults()[0] 100 # after forcing a build, b.getResults() returns ((None, []), []), ugh 101 if isinstance(result, tuple): 102 result = result[0] 103 else: 104 raise TypeError, "%r is not a BuildStatus or BuildStepStatus" % b 105 106 if result == None: 107 # FIXME: this happens when a buildstep is running ? 108 return "running" 109 return builder.Results[result]
110
111 -def path_to_root(request):
112 # /waterfall : ['waterfall'] -> '' 113 # /somewhere/lower : ['somewhere', 'lower'] -> '../' 114 # /somewhere/indexy/ : ['somewhere', 'indexy', ''] -> '../../' 115 # / : [] -> '' 116 if request.prepath: 117 segs = len(request.prepath) - 1 118 else: 119 segs = 0 120 root = "../" * segs 121 return root
122
123 -def path_to_authfail(request):
124 return path_to_root(request) + "authfail"
125
126 -def path_to_authzfail(request):
127 return path_to_root(request) + "authzfail"
128
129 -def path_to_builder(request, builderstatus):
130 return (path_to_root(request) + 131 "builders/" + 132 urllib.quote(builderstatus.getName(), safe=''))
133
134 -def path_to_build(request, buildstatus):
135 return (path_to_builder(request, buildstatus.getBuilder()) + 136 "/builds/%d" % buildstatus.getNumber())
137
138 -def path_to_step(request, stepstatus):
139 return (path_to_build(request, stepstatus.getBuild()) + 140 "/steps/%s" % urllib.quote(stepstatus.getName(), safe=''))
141
142 -def path_to_slave(request, slave):
143 return (path_to_root(request) + 144 "buildslaves/" + 145 urllib.quote(slave.getName(), safe=''))
146
147 -def path_to_change(request, change):
148 return (path_to_root(request) + 149 "changes/%s" % change.number)
150
151 -class Box:
152 # a Box wraps an Event. The Box has HTML <td> parameters that Events 153 # lack, and it has a base URL to which each File's name is relative. 154 # Events don't know about HTML. 155 spacer = False
156 - def __init__(self, text=[], class_=None, urlbase=None, 157 **parms):
158 self.text = text 159 self.class_ = class_ 160 self.urlbase = urlbase 161 self.show_idle = 0 162 if parms.has_key('show_idle'): 163 del parms['show_idle'] 164 self.show_idle = 1 165 166 self.parms = parms
167 # parms is a dict of HTML parameters for the <td> element that will 168 # represent this Event in the waterfall display. 169
170 - def td(self, **props):
171 props.update(self.parms) 172 text = self.text 173 if not text and self.show_idle: 174 text = ["[idle]"] 175 props['class'] = self.class_ 176 props['text'] = text; 177 return props
178 179
180 -class AccessorMixin(object):
181 - def getStatus(self, request):
182 return request.site.buildbot_service.getStatus()
183
184 - def getPageTitle(self, request):
185 return self.pageTitle
186
187 - def getAuthz(self, request):
188 return request.site.buildbot_service.authz
189
190 - def getBuildmaster(self, request):
191 return request.site.buildbot_service.master
192 193
194 -class ContextMixin(AccessorMixin):
195 - def getContext(self, request):
196 status = self.getStatus(request) 197 rootpath = path_to_root(request) 198 locale_enc = locale.getdefaultlocale()[1] 199 if locale_enc is not None: 200 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc) 201 else: 202 locale_tz = unicode(time.tzname[time.localtime()[-1]]) 203 return dict(title_url = status.getTitleURL(), 204 title = status.getTitle(), 205 stylesheet = rootpath + 'default.css', 206 path_to_root = rootpath, 207 version = version, 208 time = time.strftime("%a %d %b %Y %H:%M:%S", 209 time.localtime(util.now())), 210 tz = locale_tz, 211 metatags = [], 212 pageTitle = self.getPageTitle(request), 213 welcomeurl = rootpath, 214 authz = self.getAuthz(request), 215 request = request, 216 alert_msg = request.args.get("alert_msg", [""])[0], 217 )
218 219
220 -class ActionResource(resource.Resource, AccessorMixin):
221 """A resource that performs some action, then redirects to a new URL.""" 222 223 isLeaf = 1 224
225 - def getChild(self, name, request):
226 return self
227
228 - def performAction(self, request):
229 """ 230 Perform the action, and return the URL to redirect to 231 232 @param request: the web request 233 @returns: URL via Deferred 234 can also return (URL, alert_msg) to display simple 235 feedback to user in case of failure 236 """
237
238 - def render(self, request):
239 d = defer.maybeDeferred(lambda : self.performAction(request)) 240 def redirect(url): 241 if isinstance(url, tuple): 242 url, alert_msg = url 243 if alert_msg: 244 url += "?alert_msg="+urllib.quote(alert_msg, safe='') 245 request.redirect(url) 246 request.write("see <a href='%s'>%s</a>" % (url,url)) 247 try: 248 request.finish() 249 except RuntimeError: 250 # this occurs when the client has already disconnected; ignore 251 # it (see #2027) 252 log.msg("http client disconnected before results were sent")
253 d.addCallback(redirect) 254 255 def fail(f): 256 request.processingFailed(f) 257 return None # processingFailed will log this for us
258 d.addErrback(fail) 259 return server.NOT_DONE_YET 260
261 -class HtmlResource(resource.Resource, ContextMixin):
262 # this is a cheap sort of template thingy 263 contentType = "text/html; charset=utf-8" 264 pageTitle = "Buildbot" 265 addSlash = False # adapted from Nevow 266
267 - def getChild(self, path, request):
268 if self.addSlash and path == "" and len(request.postpath) == 0: 269 return self 270 return resource.Resource.getChild(self, path, request)
271 272
273 - def content(self, req, context):
274 """ 275 Generate content using the standard layout and the result of the C{body} 276 method. 277 278 This is suitable for the case where a resource just wants to generate 279 the body of a page. It depends on another method, C{body}, being 280 defined to accept the request object and return a C{str}. C{render} 281 will call this method and to generate the response body. 282 """ 283 body = self.body(req) 284 context['content'] = body 285 template = req.site.buildbot_service.templates.get_template( 286 "empty.html") 287 return template.render(**context)
288 289
290 - def render(self, request):
291 # tell the WebStatus about the HTTPChannel that got opened, so they 292 # can close it if we get reconfigured and the WebStatus goes away. 293 # They keep a weakref to this, since chances are good that it will be 294 # closed by the browser or by us before we get reconfigured. See 295 # ticket #102 for details. 296 if hasattr(request, "channel"): 297 # web.distrib.Request has no .channel 298 request.site.buildbot_service.registerChannel(request.channel) 299 300 # Our pages no longer require that their URL end in a slash. Instead, 301 # they all use request.childLink() or some equivalent which takes the 302 # last path component into account. This clause is left here for 303 # historical and educational purposes. 304 if False and self.addSlash and request.prepath[-1] != '': 305 # this is intended to behave like request.URLPath().child('') 306 # but we need a relative URL, since we might be living behind a 307 # reverse proxy 308 # 309 # note that the Location: header (as used in redirects) are 310 # required to have absolute URIs, and my attempt to handle 311 # reverse-proxies gracefully violates rfc2616. This frequently 312 # works, but single-component paths sometimes break. The best 313 # strategy is to avoid these redirects whenever possible by using 314 # HREFs with trailing slashes, and only use the redirects for 315 # manually entered URLs. 316 url = request.prePathURL() 317 scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 318 new_url = request.prepath[-1] + "/" 319 if query: 320 new_url += "?" + query 321 request.redirect(new_url) 322 return '' 323 324 ctx = self.getContext(request) 325 326 d = defer.maybeDeferred(lambda : self.content(request, ctx)) 327 def handle(data): 328 if isinstance(data, unicode): 329 data = data.encode("utf-8") 330 request.setHeader("content-type", self.contentType) 331 if request.method == "HEAD": 332 request.setHeader("content-length", len(data)) 333 return '' 334 return data
335 d.addCallback(handle) 336 def ok(data): 337 request.write(data) 338 try: 339 request.finish() 340 except RuntimeError: 341 # this occurs when the client has already disconnected; ignore 342 # it (see #2027) 343 log.msg("http client disconnected before results were sent")
344 def fail(f): 345 request.processingFailed(f) 346 return None # processingFailed will log this for us 347 d.addCallbacks(ok, fail) 348 return server.NOT_DONE_YET 349
350 -class StaticHTML(HtmlResource):
351 - def __init__(self, body, pageTitle):
352 HtmlResource.__init__(self) 353 self.bodyHTML = body 354 self.pageTitle = pageTitle
355 - def content(self, request, cxt):
356 cxt['content'] = self.bodyHTML 357 cxt['pageTitle'] = self.pageTitle 358 template = request.site.buildbot_service.templates.get_template("empty.html") 359 return template.render(**cxt)
360 361 # DirectoryLister isn't available in Twisted-2.5.0, and isn't compatible with what 362 # we need until 9.0.0, so we just skip this particular feature. 363 have_DirectoryLister = False 364 if hasattr(static, 'DirectoryLister'): 365 have_DirectoryLister = True
366 - class DirectoryLister(static.DirectoryLister, ContextMixin):
367 """This variant of the static.DirectoryLister uses a template 368 for rendering.""" 369 370 pageTitle = 'BuildBot' 371
372 - def render(self, request):
373 cxt = self.getContext(request) 374 375 if self.dirs is None: 376 directory = os.listdir(self.path) 377 directory.sort() 378 else: 379 directory = self.dirs 380 381 dirs, files = self._getFilesAndDirectories(directory) 382 383 cxt['path'] = cgi.escape(urllib.unquote(request.uri)) 384 cxt['directories'] = dirs 385 cxt['files'] = files 386 template = request.site.buildbot_service.templates.get_template("directory.html") 387 data = template.render(**cxt) 388 if isinstance(data, unicode): 389 data = data.encode("utf-8") 390 return data
391
392 -class StaticFile(static.File):
393 """This class adds support for templated directory 394 views.""" 395
396 - def directoryListing(self):
397 if have_DirectoryLister: 398 return DirectoryLister(self.path, 399 self.listNames(), 400 self.contentTypes, 401 self.contentEncodings, 402 self.defaultType) 403 else: 404 return static.Data(""" 405 Directory Listings require Twisted-9.0.0 or later 406 """, "text/plain")
407 408 409 MINUTE = 60 410 HOUR = 60*MINUTE 411 DAY = 24*HOUR 412 WEEK = 7*DAY 413 MONTH = 30*DAY 414
415 -def plural(word, words, num):
416 if int(num) == 1: 417 return "%d %s" % (num, word) 418 else: 419 return "%d %s" % (num, words)
420
421 -def abbreviate_age(age):
422 if age <= 90: 423 return "%s ago" % plural("second", "seconds", age) 424 if age < 90*MINUTE: 425 return "about %s ago" % plural("minute", "minutes", age / MINUTE) 426 if age < DAY: 427 return "about %s ago" % plural("hour", "hours", age / HOUR) 428 if age < 2*WEEK: 429 return "about %s ago" % plural("day", "days", age / DAY) 430 if age < 2*MONTH: 431 return "about %s ago" % plural("week", "weeks", age / WEEK) 432 return "a long time ago"
433 434
435 -class BuildLineMixin:
436 LINE_TIME_FORMAT = "%b %d %H:%M" 437
438 - def get_line_values(self, req, build, include_builder=True):
439 ''' 440 Collect the data needed for each line display 441 ''' 442 builder_name = build.getBuilder().getName() 443 results = build.getResults() 444 text = build.getText() 445 rev = str(build.getProperty("got_revision", "??")) 446 css_class = css_classes.get(results, "") 447 repo = build.getSourceStamp().repository 448 449 if type(text) == list: 450 text = " ".join(text) 451 452 values = {'class': css_class, 453 'builder_name': builder_name, 454 'buildnum': build.getNumber(), 455 'results': css_class, 456 'text': " ".join(build.getText()), 457 'buildurl': path_to_build(req, build), 458 'builderurl': path_to_builder(req, build.getBuilder()), 459 'rev': rev, 460 'rev_repo' : repo, 461 'time': time.strftime(self.LINE_TIME_FORMAT, 462 time.localtime(build.getTimes()[0])), 463 'text': text, 464 'include_builder': include_builder 465 } 466 return values
467
468 -def map_branches(branches):
469 # when the query args say "trunk", present that to things like 470 # IBuilderStatus.generateFinishedBuilds as None, since that's the 471 # convention in use. But also include 'trunk', because some VC systems 472 # refer to it that way. In the long run we should clean this up better, 473 # maybe with Branch objects or something. 474 if "trunk" in branches: 475 return branches + [None] 476 return branches
477 478 479 # jinja utilities 480
481 -def createJinjaEnv(revlink=None, changecommentlink=None, 482 repositories=None, projects=None):
483 ''' Create a jinja environment changecommentlink is used to 484 render HTML in the WebStatus and for mail changes 485 486 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable 487 @param changecommentlink: see changelinkfilter() 488 489 @type revlink: C{None}, format-string, dict (repository -> format string) or callable 490 @param revlink: see revlinkfilter() 491 492 @type repositories: C{None} or dict (string -> url) 493 @param repositories: an (optinal) mapping from repository identifiers 494 (as given by Change sources) to URLs. Is used to create a link 495 on every place where a repository is listed in the WebStatus. 496 497 @type projects: C{None} or dict (string -> url) 498 @param projects: similar to repositories, but for projects. 499 ''' 500 501 # See http://buildbot.net/trac/ticket/658 502 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)' 503 504 default_loader = jinja2.PackageLoader('buildbot.status.web', 'templates') 505 root = os.path.join(os.getcwd(), 'templates') 506 loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(root), 507 default_loader]) 508 env = jinja2.Environment(loader=loader, 509 extensions=['jinja2.ext.i18n'], 510 trim_blocks=True, 511 undefined=AlmostStrictUndefined) 512 513 env.install_null_translations() # needed until we have a proper i18n backend 514 515 env.filters.update(dict( 516 urlencode = urllib.quote, 517 email = emailfilter, 518 user = userfilter, 519 shortrev = shortrevfilter(revlink, env), 520 revlink = revlinkfilter(revlink, env), 521 changecomment = changelinkfilter(changecommentlink), 522 repolink = dictlinkfilter(repositories), 523 projectlink = dictlinkfilter(projects) 524 )) 525 526 return env
527
528 -def emailfilter(value):
529 ''' Escape & obfuscate e-mail addresses 530 531 replacing @ with <span style="display:none> reportedly works well against web-spiders 532 and the next level is to use rot-13 (or something) and decode in javascript ''' 533 534 user = jinja2.escape(value) 535 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@') 536 output = user.replace('@', obfuscator) 537 return output
538 539
540 -def userfilter(value):
541 ''' Hide e-mail address from user name when viewing changes 542 543 We still include the (obfuscated) e-mail so that we can show 544 it on mouse-over or similar etc 545 ''' 546 r = re.compile('(.*) +<(.*)>') 547 m = r.search(value) 548 if m: 549 user = jinja2.escape(m.group(1)) 550 email = emailfilter(m.group(2)) 551 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email)) 552 else: 553 return emailfilter(value) # filter for emails here for safety
554
555 -def _revlinkcfg(replace, templates):
556 '''Helper function that returns suitable macros and functions 557 for building revision links depending on replacement mechanism 558 ''' 559 560 assert not replace or callable(replace) or isinstance(replace, dict) or \ 561 isinstance(replace, str) or isinstance(replace, unicode) 562 563 if not replace: 564 return lambda rev, repo: None 565 else: 566 if callable(replace): 567 return lambda rev, repo: replace(rev, repo) 568 elif isinstance(replace, dict): # TODO: test for [] instead 569 def filter(rev, repo): 570 url = replace.get(repo) 571 if url: 572 return url % urllib.quote(rev) 573 else: 574 return None
575 576 return filter 577 else: 578 return lambda rev, repo: replace % urllib.quote(rev) 579 580 assert False, '_replace has a bad type, but we should never get here' 581 582
583 -def _revlinkmacros(replace, templates):
584 '''return macros for use with revision links, depending 585 on whether revlinks are configured or not''' 586 587 macros = templates.get_template("revmacros.html").module 588 589 if not replace: 590 id = macros.id 591 short = macros.shorten 592 else: 593 id = macros.id_replace 594 short = macros.shorten_replace 595 596 return (id, short)
597 598
599 -def shortrevfilter(replace, templates):
600 ''' Returns a function which shortens the revisison string 601 to 12-chars (chosen as this is the Mercurial short-id length) 602 and add link if replacement string is set. 603 604 (The full id is still visible in HTML, for mouse-over events etc.) 605 606 @param replace: see revlinkfilter() 607 @param templates: a jinja2 environment 608 ''' 609 610 url_f = _revlinkcfg(replace, templates) 611 612 def filter(rev, repo): 613 if not rev: 614 return u'' 615 616 id_html, short_html = _revlinkmacros(replace, templates) 617 rev = unicode(rev) 618 url = url_f(rev, repo) 619 rev = jinja2.escape(rev) 620 shortrev = rev[:12] # TODO: customize this depending on vc type 621 622 if shortrev == rev: 623 if url: 624 return id_html(rev=rev, url=url) 625 else: 626 return rev 627 else: 628 if url: 629 return short_html(short=shortrev, rev=rev, url=url) 630 else: 631 return shortrev + '...'
632 633 return filter 634 635
636 -def revlinkfilter(replace, templates):
637 ''' Returns a function which adds an url link to a 638 revision identifiers. 639 640 Takes same params as shortrevfilter() 641 642 @param replace: either a python format string with an %s, 643 or a dict mapping repositories to format strings, 644 or a callable taking (revision, repository) arguments 645 and return an URL (or None, if no URL is available), 646 or None, in which case revisions do not get decorated 647 with links 648 649 @param templates: a jinja2 environment 650 ''' 651 652 url_f = _revlinkcfg(replace, templates) 653 654 def filter(rev, repo): 655 if not rev: 656 return u'' 657 658 rev = unicode(rev) 659 url = url_f(rev, repo) 660 if url: 661 id_html, _ = _revlinkmacros(replace, templates) 662 return id_html(rev=rev, url=url) 663 else: 664 return jinja2.escape(rev)
665 666 return filter 667 668
669 -def changelinkfilter(changelink):
670 ''' Returns function that does regex search/replace in 671 comments to add links to bug ids and similar. 672 673 @param changelink: 674 Either C{None} 675 or: a tuple (2 or 3 elements) 676 1. a regex to match what we look for 677 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute 678 3. (optional) an title string with regex ref regex 679 or: a dict mapping projects to above tuples 680 (no links will be added if the project isn't found) 681 or: a callable taking (changehtml, project) args 682 (where the changetext is HTML escaped in the 683 form of a jinja2.Markup instance) and 684 returning another jinja2.Markup instance with 685 the same change text plus any HTML tags added to it. 686 ''' 687 688 assert not changelink or isinstance(changelink, dict) or \ 689 isinstance(changelink, tuple) or callable(changelink) 690 691 def replace_from_tuple(t): 692 search, url_replace = t[:2] 693 if len(t) == 3: 694 title_replace = t[2] 695 else: 696 title_replace = '' 697 698 search_re = re.compile(search) 699 700 def replacement_unmatched(text): 701 return jinja2.escape(text)
702 def replacement_matched(mo): 703 # expand things *after* application of the regular expressions 704 url = jinja2.escape(mo.expand(url_replace)) 705 title = jinja2.escape(mo.expand(title_replace)) 706 body = jinja2.escape(mo.group()) 707 if title: 708 return '<a href="%s" title="%s">%s</a>' % (url, title, body) 709 else: 710 return '<a href="%s">%s</a>' % (url, body) 711 712 def filter(text, project): 713 # now, we need to split the string into matched and unmatched portions, 714 # quoting the unmatched portions directly and quoting the components of 715 # the 'a' element for the matched portions. We can't use re.split here, 716 # because the user-supplied patterns may have multiple groups. 717 html = [] 718 last_idx = 0 719 for mo in search_re.finditer(text): 720 html.append(replacement_unmatched(text[last_idx:mo.start()])) 721 html.append(replacement_matched(mo)) 722 last_idx = mo.end() 723 html.append(replacement_unmatched(text[last_idx:])) 724 return jinja2.Markup(''.join(html)) 725 726 return filter 727 728 if not changelink: 729 return lambda text, project: jinja2.escape(text) 730 731 elif isinstance(changelink, dict): 732 def dict_filter(text, project): 733 # TODO: Optimize and cache return value from replace_from_tuple so 734 # we only compile regex once per project, not per view 735 736 t = changelink.get(project) 737 if t: 738 return replace_from_tuple(t)(text, project) 739 else: 740 return cgi.escape(text) 741 742 return dict_filter 743 744 elif isinstance(changelink, tuple): 745 return replace_from_tuple(changelink) 746 747 elif callable(changelink): 748 def callable_filter(text, project): 749 text = jinja2.escape(text) 750 return changelink(text, project) 751 752 return callable_filter 753 754 assert False, 'changelink has unsupported type, but that is checked before' 755 756
757 -def dictlinkfilter(links):
758 '''A filter that encloses the given value in a link tag 759 given that the value exists in the dictionary''' 760 761 assert not links or callable(links) or isinstance(links, dict) 762 763 if not links: 764 return jinja2.escape 765 766 def filter(key): 767 if callable(links): 768 url = links(key) 769 else: 770 url = links.get(key) 771 772 safe_key = jinja2.escape(key) 773 774 if url: 775 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key)) 776 else: 777 return safe_key
778 779 return filter 780
781 -class AlmostStrictUndefined(jinja2.StrictUndefined):
782 ''' An undefined that allows boolean testing but 783 fails properly on every other use. 784 785 Much better than the default Undefined, but not 786 fully as strict as StrictUndefined '''
787 - def __nonzero__(self):
788 return False
789