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