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