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

Source Code for Module buildbot.status.web.waterfall

  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  from zope.interface import implements 
 18  from twisted.python import log, components 
 19  import urllib 
 20   
 21  import time, locale 
 22  import operator 
 23   
 24  from buildbot import interfaces, util 
 25  from buildbot.status import builder 
 26   
 27  from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \ 
 28       ITopBox, build_get_class, path_to_build, path_to_step, path_to_root, \ 
 29       map_branches 
 30   
 31   
32 -def earlier(old, new):
33 # minimum of two things, but "None" counts as +infinity 34 if old: 35 if new < old: 36 return new 37 return old 38 return new
39
40 -def later(old, new):
41 # maximum of two things, but "None" counts as -infinity 42 if old: 43 if new > old: 44 return new 45 return old 46 return new
47 48
49 -class CurrentBox(components.Adapter):
50 # this provides the "current activity" box, just above the builder name 51 implements(ICurrentBox) 52
53 - def formatETA(self, prefix, eta):
54 if eta is None: 55 return [] 56 if eta < 60: 57 return ["< 1 min"] 58 eta_parts = ["~"] 59 eta_secs = eta 60 if eta_secs > 3600: 61 eta_parts.append("%d hrs" % (eta_secs / 3600)) 62 eta_secs %= 3600 63 if eta_secs > 60: 64 eta_parts.append("%d mins" % (eta_secs / 60)) 65 eta_secs %= 60 66 abstime = time.strftime("%H:%M", time.localtime(util.now()+eta)) 67 return [prefix, " ".join(eta_parts), "at %s" % abstime]
68
69 - def getBox(self, status):
70 # getState() returns offline, idle, or building 71 state, builds = self.original.getState() 72 73 # look for upcoming builds. We say the state is "waiting" if the 74 # builder is otherwise idle and there is a scheduler which tells us a 75 # build will be performed some time in the near future. TODO: this 76 # functionality used to be in BuilderStatus.. maybe this code should 77 # be merged back into it. 78 upcoming = [] 79 builderName = self.original.getName() 80 for s in status.getSchedulers(): 81 if builderName in s.listBuilderNames(): 82 upcoming.extend(s.getPendingBuildTimes()) 83 if state == "idle" and upcoming: 84 state = "waiting" 85 86 if state == "building": 87 text = ["building"] 88 if builds: 89 for b in builds: 90 eta = b.getETA() 91 text.extend(self.formatETA("ETA in", eta)) 92 elif state == "offline": 93 text = ["offline"] 94 elif state == "idle": 95 text = ["idle"] 96 elif state == "waiting": 97 text = ["waiting"] 98 else: 99 # just in case I add a state and forget to update this 100 text = [state] 101 102 # TODO: for now, this pending/upcoming stuff is in the "current 103 # activity" box, but really it should go into a "next activity" row 104 # instead. The only times it should show up in "current activity" is 105 # when the builder is otherwise idle. 106 107 # are any builds pending? (waiting for a slave to be free) 108 pbs = self.original.getPendingBuilds() 109 if pbs: 110 text.append("%d pending" % len(pbs)) 111 for t in upcoming: 112 eta = t - util.now() 113 text.extend(self.formatETA("next in", eta)) 114 return Box(text, class_="Activity " + state)
115 116 components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox) 117 118
119 -class BuildTopBox(components.Adapter):
120 # this provides a per-builder box at the very top of the display, 121 # showing the results of the most recent build 122 implements(IBox) 123
124 - def getBox(self, req):
125 assert interfaces.IBuilderStatus(self.original) 126 branches = [b for b in req.args.get("branch", []) if b] 127 builder = self.original 128 builds = list(builder.generateFinishedBuilds(map_branches(branches), 129 num_builds=1)) 130 if not builds: 131 return Box(["none"], class_="LastBuild") 132 b = builds[0] 133 url = path_to_build(req, b) 134 text = b.getText() 135 tests_failed = b.getSummaryStatistic('tests-failed', operator.add, 0) 136 if tests_failed: text.extend(["Failed tests: %d" % tests_failed]) 137 # TODO: maybe add logs? 138 class_ = build_get_class(b) 139 return Box(text, urlbase=url, class_="LastBuild %s" % class_)
140 components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox) 141
142 -class BuildBox(components.Adapter):
143 # this provides the yellow "starting line" box for each build 144 implements(IBox) 145
146 - def getBox(self, req):
147 b = self.original 148 number = b.getNumber() 149 url = path_to_build(req, b) 150 reason = b.getReason() 151 template = req.site.buildbot_service.templates.get_template("box_macros.html") 152 text = template.module.build_box(reason=reason,url=url,number=number) 153 class_ = "start" 154 if b.isFinished() and not b.getSteps(): 155 # the steps have been pruned, so there won't be any indication 156 # of whether it succeeded or failed. 157 class_ = build_get_class(b) 158 return Box([text], class_="BuildStep " + class_)
159 components.registerAdapter(BuildBox, builder.BuildStatus, IBox) 160
161 -class StepBox(components.Adapter):
162 implements(IBox) 163
164 - def getBox(self, req):
165 urlbase = path_to_step(req, self.original) 166 text = self.original.getText() 167 if text is None: 168 log.msg("getText() gave None", urlbase) 169 text = [] 170 text = text[:] 171 logs = self.original.getLogs() 172 173 cxt = dict(text=text, logs=[], urls=[]) 174 175 for num in range(len(logs)): 176 name = logs[num].getName() 177 if logs[num].hasContents(): 178 url = urlbase + "/logs/%s" % urllib.quote(name) 179 else: 180 url = None 181 cxt['logs'].append(dict(name=name, url=url)) 182 183 for name, target in self.original.getURLs().items(): 184 cxt['urls'].append(dict(link=target,name=name)) 185 186 template = req.site.buildbot_service.templates.get_template("box_macros.html") 187 text = template.module.step_box(**cxt) 188 189 class_ = "BuildStep " + build_get_class(self.original) 190 return Box(text, class_=class_)
191 components.registerAdapter(StepBox, builder.BuildStepStatus, IBox) 192 193
194 -class EventBox(components.Adapter):
195 implements(IBox) 196
197 - def getBox(self, req):
198 text = self.original.getText() 199 class_ = "Event" 200 return Box(text, class_=class_)
201 components.registerAdapter(EventBox, builder.Event, IBox) 202 203
204 -class Spacer:
205 implements(interfaces.IStatusEvent) 206
207 - def __init__(self, start, finish):
208 self.started = start 209 self.finished = finish
210
211 - def getTimes(self):
212 return (self.started, self.finished)
213 - def getText(self):
214 return []
215
216 -class SpacerBox(components.Adapter):
217 implements(IBox) 218
219 - def getBox(self, req):
220 #b = Box(["spacer"], "white") 221 b = Box([]) 222 b.spacer = True 223 return b
224 components.registerAdapter(SpacerBox, Spacer, IBox) 225
226 -def insertGaps(g, showEvents, lastEventTime, idleGap=2):
227 debug = False 228 229 e = g.next() 230 starts, finishes = e.getTimes() 231 if debug: log.msg("E0", starts, finishes) 232 if finishes == 0: 233 finishes = starts 234 if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \ 235 (finishes, idleGap, lastEventTime)) 236 if finishes is not None and finishes + idleGap < lastEventTime: 237 if debug: log.msg(" spacer0") 238 yield Spacer(finishes, lastEventTime) 239 240 followingEventStarts = starts 241 if debug: log.msg(" fES0", starts) 242 yield e 243 244 while 1: 245 e = g.next() 246 if not showEvents and isinstance(e, builder.Event): 247 continue 248 starts, finishes = e.getTimes() 249 if debug: log.msg("E2", starts, finishes) 250 if finishes == 0: 251 finishes = starts 252 if finishes is not None and finishes + idleGap < followingEventStarts: 253 # there is a gap between the end of this event and the beginning 254 # of the next one. Insert an idle event so the waterfall display 255 # shows a gap here. 256 if debug: 257 log.msg(" finishes=%s, gap=%s, fES=%s" % \ 258 (finishes, idleGap, followingEventStarts)) 259 yield Spacer(finishes, followingEventStarts) 260 yield e 261 followingEventStarts = starts 262 if debug: log.msg(" fES1", starts)
263 264
265 -class WaterfallHelp(HtmlResource):
266 title = "Waterfall Help" 267
268 - def __init__(self, categories=None):
269 HtmlResource.__init__(self) 270 self.categories = categories
271
272 - def content(self, request, cxt):
273 status = self.getStatus(request) 274 275 cxt['show_events_checked'] = request.args.get("show_events", ["false"])[0].lower() == "true" 276 cxt['branches'] = [b for b in request.args.get("branch", []) if b] 277 cxt['failures_only'] = request.args.get("failures_only", ["false"])[0].lower() == "true" 278 cxt['committers'] = [c for c in request.args.get("committer", []) if c] 279 280 # this has a set of toggle-buttons to let the user choose the 281 # builders 282 show_builders = request.args.get("show", []) 283 show_builders.extend(request.args.get("builder", [])) 284 cxt['show_builders'] = show_builders 285 cxt['all_builders'] = status.getBuilderNames(categories=self.categories) 286 287 # a couple of radio-button selectors for refresh time will appear 288 # just after that text 289 times = [("none", "None"), 290 ("60", "60 seconds"), 291 ("300", "5 minutes"), 292 ("600", "10 minutes"), 293 ] 294 current_reload_time = request.args.get("reload", ["none"]) 295 if current_reload_time: 296 current_reload_time = current_reload_time[0] 297 if current_reload_time not in [t[0] for t in times]: 298 times.insert(0, (current_reload_time, current_reload_time) ) 299 300 cxt['times'] = times 301 cxt['current_reload_time'] = current_reload_time 302 303 template = request.site.buildbot_service.templates.get_template("waterfallhelp.html") 304 return template.render(**cxt)
305 306
307 -class WaterfallStatusResource(HtmlResource):
308 """This builds the main status page, with the waterfall display, and 309 all child pages.""" 310
311 - def __init__(self, categories=None, num_events=200, num_events_max=None):
312 HtmlResource.__init__(self) 313 self.categories = categories 314 self.num_events=num_events 315 self.num_events_max=num_events_max 316 self.putChild("help", WaterfallHelp(categories))
317
318 - def getTitle(self, request):
319 status = self.getStatus(request) 320 p = status.getProjectName() 321 if p: 322 return "BuildBot: %s" % p 323 else: 324 return "BuildBot"
325
326 - def getChangeManager(self, request):
327 # TODO: this wants to go away, access it through IStatus 328 return request.site.buildbot_service.getChangeSvc()
329
330 - def get_reload_time(self, request):
331 if "reload" in request.args: 332 try: 333 reload_time = int(request.args["reload"][0]) 334 return max(reload_time, 15) 335 except ValueError: 336 pass 337 return None
338
339 - def isSuccess(self, builderStatus):
340 # Helper function to return True if the builder is not failing. 341 # The function will return false if the current state is "offline", 342 # the last build was not successful, or if a step from the current 343 # build(s) failed. 344 345 # Make sure the builder is online. 346 if builderStatus.getState()[0] == 'offline': 347 return False 348 349 # Look at the last finished build to see if it was success or not. 350 lastBuild = builderStatus.getLastFinishedBuild() 351 if lastBuild and lastBuild.getResults() != builder.SUCCESS: 352 return False 353 354 # Check all the current builds to see if one step is already 355 # failing. 356 currentBuilds = builderStatus.getCurrentBuilds() 357 if currentBuilds: 358 for build in currentBuilds: 359 for step in build.getSteps(): 360 if step.getResults()[0] == builder.FAILURE: 361 return False 362 363 # The last finished build was successful, and all the current builds 364 # don't have any failed steps. 365 return True
366
367 - def content(self, request, ctx):
368 status = self.getStatus(request) 369 ctx['refresh'] = self.get_reload_time(request) 370 371 # we start with all Builders available to this Waterfall: this is 372 # limited by the config-file -time categories= argument, and defaults 373 # to all defined Builders. 374 allBuilderNames = status.getBuilderNames(categories=self.categories) 375 builders = [status.getBuilder(name) for name in allBuilderNames] 376 377 # but if the URL has one or more builder= arguments (or the old show= 378 # argument, which is still accepted for backwards compatibility), we 379 # use that set of builders instead. We still don't show anything 380 # outside the config-file time set limited by categories=. 381 showBuilders = request.args.get("show", []) 382 showBuilders.extend(request.args.get("builder", [])) 383 if showBuilders: 384 builders = [b for b in builders if b.name in showBuilders] 385 386 # now, if the URL has one or category= arguments, use them as a 387 # filter: only show those builders which belong to one of the given 388 # categories. 389 showCategories = request.args.get("category", []) 390 if showCategories: 391 builders = [b for b in builders if b.category in showCategories] 392 393 # If the URL has the failures_only=true argument, we remove all the 394 # builders that are not currently red or won't be turning red at the end 395 # of their current run. 396 failuresOnly = request.args.get("failures_only", ["false"])[0] 397 if failuresOnly.lower() == "true": 398 builders = [b for b in builders if not self.isSuccess(b)] 399 400 (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \ 401 self.buildGrid(request, builders) 402 403 # start the table: top-header material 404 locale_enc = locale.getdefaultlocale()[1] 405 if locale_enc is not None: 406 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc) 407 else: 408 locale_tz = unicode(time.tzname[time.localtime()[-1]]) 409 ctx['tz'] = locale_tz 410 ctx['changes_url'] = request.childLink("../changes") 411 412 bn = ctx['builders'] = [] 413 414 for name in builderNames: 415 builder = status.getBuilder(name) 416 top_box = ITopBox(builder).getBox(request) 417 current_box = ICurrentBox(builder).getBox(status) 418 bn.append({'name': name, 419 'url': request.childLink("../builders/%s" % urllib.quote(name, safe='')), 420 'top': top_box.text, 421 'top_class': top_box.class_, 422 'status': current_box.text, 423 'status_class': current_box.class_, 424 }) 425 426 ctx.update(self.phase2(request, changeNames + builderNames, timestamps, eventGrid, 427 sourceEvents)) 428 429 def with_args(req, remove_args=[], new_args=[], new_path=None): 430 # sigh, nevow makes this sort of manipulation easier 431 newargs = req.args.copy() 432 for argname in remove_args: 433 newargs[argname] = [] 434 if "branch" in newargs: 435 newargs["branch"] = [b for b in newargs["branch"] if b] 436 for k,v in new_args: 437 if k in newargs: 438 newargs[k].append(v) 439 else: 440 newargs[k] = [v] 441 newquery = "&amp;".join(["%s=%s" % (urllib.quote(k), urllib.quote(v)) 442 for k in newargs 443 for v in newargs[k] 444 ]) 445 if new_path: 446 new_url = new_path 447 elif req.prepath: 448 new_url = req.prepath[-1] 449 else: 450 new_url = '' 451 if newquery: 452 new_url += "?" + newquery 453 return new_url
454 455 if timestamps: 456 bottom = timestamps[-1] 457 ctx['nextpage'] = with_args(request, ["last_time"], 458 [("last_time", str(int(bottom)))]) 459 460 461 helpurl = path_to_root(request) + "waterfall/help" 462 ctx['help_url'] = with_args(request, new_path=helpurl) 463 464 if self.get_reload_time(request) is not None: 465 ctx['no_reload_page'] = with_args(request, remove_args=["reload"]) 466 467 template = request.site.buildbot_service.templates.get_template("waterfall.html") 468 data = template.render(**ctx) 469 return data
470
471 - def buildGrid(self, request, builders):
472 debug = False 473 # TODO: see if we can use a cached copy 474 475 showEvents = False 476 if request.args.get("show_events", ["false"])[0].lower() == "true": 477 showEvents = True 478 filterCategories = request.args.get('category', []) 479 filterBranches = [b for b in request.args.get("branch", []) if b] 480 filterBranches = map_branches(filterBranches) 481 filterCommitters = [c for c in request.args.get("committer", []) if c] 482 maxTime = int(request.args.get("last_time", [util.now()])[0]) 483 if "show_time" in request.args: 484 minTime = maxTime - int(request.args["show_time"][0]) 485 elif "first_time" in request.args: 486 minTime = int(request.args["first_time"][0]) 487 elif filterBranches or filterCommitters: 488 minTime = util.now() - 24 * 60 * 60 489 else: 490 minTime = 0 491 spanLength = 10 # ten-second chunks 492 req_events=int(request.args.get("num_events", [self.num_events])[0]) 493 if self.num_events_max and req_events > self.num_events_max: 494 maxPageLen = self.num_events_max 495 else: 496 maxPageLen = req_events 497 498 # first step is to walk backwards in time, asking each column 499 # (commit, all builders) if they have any events there. Build up the 500 # array of events, and stop when we have a reasonable number. 501 502 commit_source = self.getChangeManager(request) 503 504 lastEventTime = util.now() 505 sources = [commit_source] + builders 506 changeNames = ["changes"] 507 builderNames = map(lambda builder: builder.getName(), builders) 508 sourceNames = changeNames + builderNames 509 sourceEvents = [] 510 sourceGenerators = [] 511 512 def get_event_from(g): 513 try: 514 while True: 515 e = g.next() 516 # e might be builder.BuildStepStatus, 517 # builder.BuildStatus, builder.Event, 518 # waterfall.Spacer(builder.Event), or changes.Change . 519 # The showEvents=False flag means we should hide 520 # builder.Event . 521 if not showEvents and isinstance(e, builder.Event): 522 continue 523 break 524 event = interfaces.IStatusEvent(e) 525 if debug: 526 log.msg("gen %s gave1 %s" % (g, event.getText())) 527 except StopIteration: 528 event = None 529 return event
530 531 for s in sources: 532 gen = insertGaps(s.eventGenerator(filterBranches, 533 filterCategories, 534 filterCommitters, 535 minTime), 536 showEvents, 537 lastEventTime) 538 sourceGenerators.append(gen) 539 # get the first event 540 sourceEvents.append(get_event_from(gen)) 541 eventGrid = [] 542 timestamps = [] 543 544 lastEventTime = 0 545 for e in sourceEvents: 546 if e and e.getTimes()[0] > lastEventTime: 547 lastEventTime = e.getTimes()[0] 548 if lastEventTime == 0: 549 lastEventTime = util.now() 550 551 spanStart = lastEventTime - spanLength 552 debugGather = 0 553 554 while 1: 555 if debugGather: log.msg("checking (%s,]" % spanStart) 556 # the tableau of potential events is in sourceEvents[]. The 557 # window crawls backwards, and we examine one source at a time. 558 # If the source's top-most event is in the window, is it pushed 559 # onto the events[] array and the tableau is refilled. This 560 # continues until the tableau event is not in the window (or is 561 # missing). 562 563 spanEvents = [] # for all sources, in this span. row of eventGrid 564 firstTimestamp = None # timestamp of first event in the span 565 lastTimestamp = None # last pre-span event, for next span 566 567 for c in range(len(sourceGenerators)): 568 events = [] # for this source, in this span. cell of eventGrid 569 event = sourceEvents[c] 570 while event and spanStart < event.getTimes()[0]: 571 # to look at windows that don't end with the present, 572 # condition the .append on event.time <= spanFinish 573 if not IBox(event, None): 574 log.msg("BAD EVENT", event, event.getText()) 575 assert 0 576 if debug: 577 log.msg("pushing", event.getText(), event) 578 events.append(event) 579 starts, finishes = event.getTimes() 580 firstTimestamp = earlier(firstTimestamp, starts) 581 event = get_event_from(sourceGenerators[c]) 582 if debug: 583 log.msg("finished span") 584 585 if event: 586 # this is the last pre-span event for this source 587 lastTimestamp = later(lastTimestamp, 588 event.getTimes()[0]) 589 if debugGather: 590 log.msg(" got %s from %s" % (events, sourceNames[c])) 591 sourceEvents[c] = event # refill the tableau 592 spanEvents.append(events) 593 594 # only show events older than maxTime. This makes it possible to 595 # visit a page that shows what it would be like to scroll off the 596 # bottom of this one. 597 if firstTimestamp is not None and firstTimestamp <= maxTime: 598 eventGrid.append(spanEvents) 599 timestamps.append(firstTimestamp) 600 601 if lastTimestamp: 602 spanStart = lastTimestamp - spanLength 603 else: 604 # no more events 605 break 606 if minTime is not None and lastTimestamp < minTime: 607 break 608 609 if len(timestamps) > maxPageLen: 610 break 611 612 613 # now loop 614 615 # loop is finished. now we have eventGrid[] and timestamps[] 616 if debugGather: log.msg("finished loop") 617 assert(len(timestamps) == len(eventGrid)) 618 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) 619
620 - def phase2(self, request, sourceNames, timestamps, eventGrid, 621 sourceEvents):
622 623 if not timestamps: 624 return dict(grid=[], gridlen=0) 625 626 # first pass: figure out the height of the chunks, populate grid 627 grid = [] 628 for i in range(1+len(sourceNames)): 629 grid.append([]) 630 # grid is a list of columns, one for the timestamps, and one per 631 # event source. Each column is exactly the same height. Each element 632 # of the list is a single <td> box. 633 lastDate = time.strftime("%d %b %Y", 634 time.localtime(util.now())) 635 for r in range(0, len(timestamps)): 636 chunkstrip = eventGrid[r] 637 # chunkstrip is a horizontal strip of event blocks. Each block 638 # is a vertical list of events, all for the same source. 639 assert(len(chunkstrip) == len(sourceNames)) 640 maxRows = reduce(lambda x,y: max(x,y), 641 map(lambda x: len(x), chunkstrip)) 642 for i in range(maxRows): 643 if i != maxRows-1: 644 grid[0].append(None) 645 else: 646 # timestamp goes at the bottom of the chunk 647 stuff = [] 648 # add the date at the beginning (if it is not the same as 649 # today's date), and each time it changes 650 todayday = time.strftime("%a", 651 time.localtime(timestamps[r])) 652 today = time.strftime("%d %b %Y", 653 time.localtime(timestamps[r])) 654 if today != lastDate: 655 stuff.append(todayday) 656 stuff.append(today) 657 lastDate = today 658 stuff.append( 659 time.strftime("%H:%M:%S", 660 time.localtime(timestamps[r]))) 661 grid[0].append(Box(text=stuff, class_="Time", 662 valign="bottom", align="center")) 663 664 # at this point the timestamp column has been populated with 665 # maxRows boxes, most None but the last one has the time string 666 for c in range(0, len(chunkstrip)): 667 block = chunkstrip[c] 668 assert(block != None) # should be [] instead 669 for i in range(maxRows - len(block)): 670 # fill top of chunk with blank space 671 grid[c+1].append(None) 672 for i in range(len(block)): 673 # so the events are bottom-justified 674 b = IBox(block[i]).getBox(request) 675 b.parms['valign'] = "top" 676 b.parms['align'] = "center" 677 grid[c+1].append(b) 678 # now all the other columns have maxRows new boxes too 679 # populate the last row, if empty 680 gridlen = len(grid[0]) 681 for i in range(len(grid)): 682 strip = grid[i] 683 assert(len(strip) == gridlen) 684 if strip[-1] == None: 685 if sourceEvents[i-1]: 686 filler = IBox(sourceEvents[i-1]).getBox(request) 687 else: 688 # this can happen if you delete part of the build history 689 filler = Box(text=["?"], align="center") 690 strip[-1] = filler 691 strip[-1].parms['rowspan'] = 1 692 # second pass: bubble the events upwards to un-occupied locations 693 # Every square of the grid that has a None in it needs to have 694 # something else take its place. 695 noBubble = request.args.get("nobubble",['0']) 696 noBubble = int(noBubble[0]) 697 if not noBubble: 698 for col in range(len(grid)): 699 strip = grid[col] 700 if col == 1: # changes are handled differently 701 for i in range(2, len(strip)+1): 702 # only merge empty boxes. Don't bubble commit boxes. 703 if strip[-i] == None: 704 next = strip[-i+1] 705 assert(next) 706 if next: 707 #if not next.event: 708 if next.spacer: 709 # bubble the empty box up 710 strip[-i] = next 711 strip[-i].parms['rowspan'] += 1 712 strip[-i+1] = None 713 else: 714 # we are above a commit box. Leave it 715 # be, and turn the current box into an 716 # empty one 717 strip[-i] = Box([], rowspan=1, 718 comment="commit bubble") 719 strip[-i].spacer = True 720 else: 721 # we are above another empty box, which 722 # somehow wasn't already converted. 723 # Shouldn't happen 724 pass 725 else: 726 for i in range(2, len(strip)+1): 727 # strip[-i] will go from next-to-last back to first 728 if strip[-i] == None: 729 # bubble previous item up 730 assert(strip[-i+1] != None) 731 strip[-i] = strip[-i+1] 732 strip[-i].parms['rowspan'] += 1 733 strip[-i+1] = None 734 else: 735 strip[-i].parms['rowspan'] = 1 736 737 # convert to dicts 738 for i in range(gridlen): 739 for strip in grid: 740 if strip[i]: 741 strip[i] = strip[i].td() 742 743 return dict(grid=grid, gridlen=gridlen, no_bubble=noBubble, time=lastDate)
744