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

Source Code for Module buildbot.status.web.waterfall

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