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