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

Source Code for Module buildbot.status.web.base

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