1
2
3 from zope.interface import implements
4 from twisted.python import log, components
5 from twisted.web import html
6 import urllib
7
8 import time
9 import operator
10
11 from buildbot import interfaces, util
12 from buildbot import version
13 from buildbot.status import builder
14
15 from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \
16 ITopBox, td, build_get_class, path_to_build, path_to_step, map_branches
17
18
19
21
22 implements(ICurrentBox)
23
39
41
42 state, builds = self.original.getState()
43
44
45
46
47
48
49 upcoming = []
50 builderName = self.original.getName()
51 for s in status.getSchedulers():
52 if builderName in s.listBuilderNames():
53 upcoming.extend(s.getPendingBuildTimes())
54 if state == "idle" and upcoming:
55 state = "waiting"
56
57 if state == "building":
58 text = ["building"]
59 if builds:
60 for b in builds:
61 eta = b.getETA()
62 text.extend(self.formatETA("ETA in", eta))
63 elif state == "offline":
64 text = ["offline"]
65 elif state == "idle":
66 text = ["idle"]
67 elif state == "waiting":
68 text = ["waiting"]
69 else:
70
71 text = [state]
72
73
74
75
76
77
78
79 pbs = self.original.getPendingBuilds()
80 if pbs:
81 text.append("%d pending" % len(pbs))
82 for t in upcoming:
83 eta = t - util.now()
84 text.extend(self.formatETA("next in", eta))
85 return Box(text, class_="Activity " + state)
86
87 components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox)
88
89
91
92
93 implements(IBox)
94
114 components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox)
115
117
118 implements(IBox)
119
133 components.registerAdapter(BuildBox, builder.BuildStatus, IBox)
134
160 components.registerAdapter(StepBox, builder.BuildStepStatus, IBox)
161
162
164 implements(IBox)
165
167 text = self.original.getText()
168 class_ = "Event"
169 return Box(text, class_=class_)
170 components.registerAdapter(EventBox, builder.Event, IBox)
171
172
184
186 implements(IBox)
187
189
190 b = Box([])
191 b.spacer = True
192 return b
193 components.registerAdapter(SpacerBox, Spacer, IBox)
194
196 debug = False
197
198 e = g.next()
199 starts, finishes = e.getTimes()
200 if debug: log.msg("E0", starts, finishes)
201 if finishes == 0:
202 finishes = starts
203 if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \
204 (finishes, idleGap, lastEventTime))
205 if finishes is not None and finishes + idleGap < lastEventTime:
206 if debug: log.msg(" spacer0")
207 yield Spacer(finishes, lastEventTime)
208
209 followingEventStarts = starts
210 if debug: log.msg(" fES0", starts)
211 yield e
212
213 while 1:
214 e = g.next()
215 starts, finishes = e.getTimes()
216 if debug: log.msg("E2", starts, finishes)
217 if finishes == 0:
218 finishes = starts
219 if finishes is not None and finishes + idleGap < followingEventStarts:
220
221
222
223 if debug:
224 log.msg(" finishes=%s, gap=%s, fES=%s" % \
225 (finishes, idleGap, followingEventStarts))
226 yield Spacer(finishes, followingEventStarts)
227 yield e
228 followingEventStarts = starts
229 if debug: log.msg(" fES1", starts)
230
231 HELP = '''
232 <form action="../waterfall" method="GET">
233
234 <h1>The Waterfall Display</h1>
235
236 <p>The Waterfall display can be controlled by adding query arguments to the
237 URL. For example, if your Waterfall is accessed via the URL
238 <tt>http://buildbot.example.org:8080</tt>, then you could add a
239 <tt>branch=</tt> argument (described below) by going to
240 <tt>http://buildbot.example.org:8080?branch=beta4</tt> instead. Remember that
241 query arguments are separated from each other with ampersands, but they are
242 separated from the main URL with a question mark, so to add a
243 <tt>branch=</tt> and two <tt>builder=</tt> arguments, you would use
244 <tt>http://buildbot.example.org:8080?branch=beta4&builder=unix&builder=macos</tt>.</p>
245
246 <h2>Limiting the Displayed Interval</h2>
247
248 <p>The <tt>last_time=</tt> argument is a unix timestamp (seconds since the
249 start of 1970) that will be used as an upper bound on the interval of events
250 displayed: nothing will be shown that is more recent than the given time.
251 When no argument is provided, all events up to and including the most recent
252 steps are included.</p>
253
254 <p>The <tt>first_time=</tt> argument provides the lower bound. No events will
255 be displayed that occurred <b>before</b> this timestamp. Instead of providing
256 <tt>first_time=</tt>, you can provide <tt>show_time=</tt>: in this case,
257 <tt>first_time</tt> will be set equal to <tt>last_time</tt> minus
258 <tt>show_time</tt>. <tt>show_time</tt> overrides <tt>first_time</tt>.</p>
259
260 <p>The display normally shows the latest 200 events that occurred in the
261 given interval, where each timestamp on the left hand edge counts as a single
262 event. You can add a <tt>num_events=</tt> argument to override this this.</p>
263
264 <h2>Hiding non-Build events</h2>
265
266 <p>By passing <tt>show_events=false</tt>, you can remove the "buildslave
267 attached", "buildslave detached", and "builder reconfigured" events that
268 appear in-between the actual builds.</p>
269
270 %(show_events_input)s
271
272 <h2>Showing only the Builders with failures</h2>
273
274 <p>By adding the <tt>failures_only=true</tt> argument, the display will be limited
275 to showing builders that are currently failing. A builder is considered
276 failing if the last finished build was not successful, a step in the current
277 build(s) failed, or if the builder is offline.
278
279 %(failures_only_input)s
280
281 <h2>Showing only Certain Branches</h2>
282
283 <p>If you provide one or more <tt>branch=</tt> arguments, the display will be
284 limited to builds that used one of the given branches. If no <tt>branch=</tt>
285 arguments are given, builds from all branches will be displayed.</p>
286
287 Erase the text from these "Show Branch:" boxes to remove that branch filter.
288
289 %(show_branches_input)s
290
291 <h2>Limiting the Builders that are Displayed</h2>
292
293 <p>By adding one or more <tt>builder=</tt> arguments, the display will be
294 limited to showing builds that ran on the given builders. This serves to
295 limit the display to the specific named columns. If no <tt>builder=</tt>
296 arguments are provided, all Builders will be displayed.</p>
297
298 <p>To view a Waterfall page with only a subset of Builders displayed, select
299 the Builders you are interested in here.</p>
300
301 %(show_builders_input)s
302
303
304 <h2>Auto-reloading the Page</h2>
305
306 <p>Adding a <tt>reload=</tt> argument will cause the page to automatically
307 reload itself after that many seconds.</p>
308
309 %(show_reload_input)s
310
311 <h2>Reload Waterfall Page</h2>
312
313 <input type="submit" value="View Waterfall" />
314 </form>
315 '''
316
318 title = "Waterfall Help"
319
323
324 - def body(self, request):
325 data = ''
326 status = self.getStatus(request)
327
328 showEvents_checked = 'checked="checked"'
329 if request.args.get("show_events", ["true"])[0].lower() == "true":
330 showEvents_checked = ''
331 show_events_input = ('<p>'
332 '<input type="checkbox" name="show_events" '
333 'value="false" %s>'
334 'Hide non-Build events'
335 '</p>\n'
336 ) % showEvents_checked
337
338 failuresOnly_checked = ''
339 if request.args.get("failures_only", ["false"])[0].lower() == "true":
340 failuresOnly_checked = 'checked="checked"'
341 failures_only_input = ('<p>'
342 '<input type="checkbox" name="failures_only" '
343 'value="true" %s>'
344 'Show failures only'
345 '</p>\n'
346 ) % failuresOnly_checked
347
348 branches = [b
349 for b in request.args.get("branch", [])
350 if b]
351 branches.append('')
352 show_branches_input = '<table>\n'
353 for b in branches:
354 show_branches_input += ('<tr>'
355 '<td>Show Branch: '
356 '<input type="text" name="branch" '
357 'value="%s">'
358 '</td></tr>\n'
359 ) % (b,)
360 show_branches_input += '</table>\n'
361
362
363
364 showBuilders = request.args.get("show", [])
365 showBuilders.extend(request.args.get("builder", []))
366 allBuilders = status.getBuilderNames(categories=self.categories)
367
368 show_builders_input = '<table>\n'
369 for bn in allBuilders:
370 checked = ""
371 if bn in showBuilders:
372 checked = 'checked="checked"'
373 show_builders_input += ('<tr>'
374 '<td><input type="checkbox"'
375 ' name="builder" '
376 'value="%s" %s></td> '
377 '<td>%s</td></tr>\n'
378 ) % (bn, checked, bn)
379 show_builders_input += '</table>\n'
380
381
382
383 show_reload_input = '<table>\n'
384 times = [("none", "None"),
385 ("60", "60 seconds"),
386 ("300", "5 minutes"),
387 ("600", "10 minutes"),
388 ]
389 current_reload_time = request.args.get("reload", ["none"])
390 if current_reload_time:
391 current_reload_time = current_reload_time[0]
392 if current_reload_time not in [t[0] for t in times]:
393 times.insert(0, (current_reload_time, current_reload_time) )
394 for value, name in times:
395 checked = ""
396 if value == current_reload_time:
397 checked = 'checked="checked"'
398 show_reload_input += ('<tr>'
399 '<td><input type="radio" name="reload" '
400 'value="%s" %s></td> '
401 '<td>%s</td></tr>\n'
402 ) % (value, checked, name)
403 show_reload_input += '</table>\n'
404
405 fields = {"show_events_input": show_events_input,
406 "show_branches_input": show_branches_input,
407 "show_builders_input": show_builders_input,
408 "show_reload_input": show_reload_input,
409 "failures_only_input": failures_only_input,
410 }
411 data += HELP % fields
412 return data
413
415 """This builds the main status page, with the waterfall display, and
416 all child pages."""
417
422
430
434
436 if "reload" in request.args:
437 try:
438 reload_time = int(request.args["reload"][0])
439 return max(reload_time, 15)
440 except ValueError:
441 pass
442 return None
443
444 - def head(self, request):
445 head = ''
446 reload_time = self.get_reload_time(request)
447 if reload_time is not None:
448 head += '<meta http-equiv="refresh" content="%d">\n' % reload_time
449 return head
450
478
479 - def body(self, request):
480 "This method builds the main waterfall display."
481
482 status = self.getStatus(request)
483 data = ''
484
485 projectName = status.getProjectName()
486 projectURL = status.getProjectURL()
487
488 phase = request.args.get("phase",["2"])
489 phase = int(phase[0])
490
491
492
493
494 allBuilderNames = status.getBuilderNames(categories=self.categories)
495 builders = [status.getBuilder(name) for name in allBuilderNames]
496
497
498
499
500
501 showBuilders = request.args.get("show", [])
502 showBuilders.extend(request.args.get("builder", []))
503 if showBuilders:
504 builders = [b for b in builders if b.name in showBuilders]
505
506
507
508
509 showCategories = request.args.get("category", [])
510 if showCategories:
511 builders = [b for b in builders if b.category in showCategories]
512
513
514
515
516 failuresOnly = request.args.get("failures_only", ["false"])[0]
517 if failuresOnly.lower() == "true":
518 builders = [b for b in builders if not self.isSuccess(b)]
519
520 builderNames = [b.name for b in builders]
521
522 if phase == -1:
523 return self.body0(request, builders)
524 (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \
525 self.buildGrid(request, builders)
526 if phase == 0:
527 return self.phase0(request, (changeNames + builderNames),
528 timestamps, eventGrid)
529
530 data += '<table border="0" cellspacing="0">\n'
531
532 if projectName and projectURL:
533
534 topleft = '<a href="%s">%s</a><br />last build' % \
535 (projectURL, projectName)
536 else:
537 topleft = "last build"
538 data += ' <tr class="LastBuild">\n'
539 data += td(topleft, align="right", colspan=2, class_="Project")
540 for b in builders:
541 box = ITopBox(b).getBox(request)
542 data += box.td(align="center")
543 data += " </tr>\n"
544
545 data += ' <tr class="Activity">\n'
546 data += td('current activity', align='right', colspan=2)
547 for b in builders:
548 box = ICurrentBox(b).getBox(status)
549 data += box.td(align="center")
550 data += " </tr>\n"
551
552 data += " <tr>\n"
553 TZ = time.tzname[time.localtime()[-1]]
554 data += td("time (%s)" % TZ, align="center", class_="Time")
555 data += td('<a href="%s">changes</a>' % request.childLink("../changes"),
556 align="center", class_="Change")
557 for name in builderNames:
558 safename = urllib.quote(name, safe='')
559 data += td('<a href="%s">%s</a>' %
560 (request.childLink("../builders/%s" % safename), name),
561 align="center", class_="Builder")
562 data += " </tr>\n"
563
564 if phase == 1:
565 f = self.phase1
566 else:
567 f = self.phase2
568 data += f(request, changeNames + builderNames, timestamps, eventGrid,
569 sourceEvents)
570
571 data += "</table>\n"
572
573 data += '<hr /><div class="footer">\n'
574
575 def with_args(req, remove_args=[], new_args=[], new_path=None):
576
577 newargs = req.args.copy()
578 for argname in remove_args:
579 newargs[argname] = []
580 if "branch" in newargs:
581 newargs["branch"] = [b for b in newargs["branch"] if b]
582 for k,v in new_args:
583 if k in newargs:
584 newargs[k].append(v)
585 else:
586 newargs[k] = [v]
587 newquery = "&".join(["%s=%s" % (k, v)
588 for k in newargs
589 for v in newargs[k]
590 ])
591 if new_path:
592 new_url = new_path
593 elif req.prepath:
594 new_url = req.prepath[-1]
595 else:
596 new_url = ''
597 if newquery:
598 new_url += "?" + newquery
599 return new_url
600
601 if timestamps:
602 bottom = timestamps[-1]
603 nextpage = with_args(request, ["last_time"],
604 [("last_time", str(int(bottom)))])
605 data += '[<a href="%s">next page</a>]\n' % nextpage
606
607 helpurl = self.path_to_root(request) + "waterfall/help"
608 helppage = with_args(request, new_path=helpurl)
609 data += '[<a href="%s">help</a>]\n' % helppage
610
611 welcomeurl = self.path_to_root(request) + "index.html"
612 data += '[<a href="%s">welcome</a>]\n' % welcomeurl
613
614 if self.get_reload_time(request) is not None:
615 no_reload_page = with_args(request, remove_args=["reload"])
616 data += '[<a href="%s">Stop Reloading</a>]\n' % no_reload_page
617
618 data += "<br />\n"
619
620
621 bburl = "http://buildbot.net/?bb-ver=%s" % urllib.quote(version)
622 data += '<a href="%s">Buildbot-%s</a> ' % (bburl, version)
623 if projectName:
624 data += "working for the "
625 if projectURL:
626 data += '<a href="%s">%s</a> project.' % (projectURL,
627 projectName)
628 else:
629 data += "%s project." % projectName
630 data += "<br />\n"
631
632 data += ("Page built: " +
633 time.strftime("%a %d %b %Y %H:%M:%S",
634 time.localtime(util.now()))
635 + "\n")
636 data += '</div>\n'
637 return data
638
639 - def body0(self, request, builders):
640
641 data = ""
642 data += "<h2>Basic display</h2>\n"
643 data += '<p>See <a href="%s">here</a>' % request.childLink("../waterfall")
644 data += " for the waterfall display</p>\n"
645
646 data += '<table border="0" cellspacing="0">\n'
647 names = map(lambda builder: builder.name, builders)
648
649
650 data += " <tr>\n"
651 data += td("", colspan=2)
652 for b in builders:
653 text = ""
654 state, builds = b.getState()
655 if state != "offline":
656 text += "%s<br />\n" % state
657 else:
658 text += "OFFLINE<br />\n"
659 data += td(text, align="center")
660
661
662 data += " <tr>\n"
663 data += td("Time", align="center")
664 data += td("Changes", align="center")
665 for name in names:
666 data += td('<a href="%s">%s</a>' %
667 (request.childLink("../" + urllib.quote(name)), name),
668 align="center")
669 data += " </tr>\n"
670
671
672 data += " <tr>\n"
673 data += td("04:00", align="bottom")
674 data += td("fred", align="center")
675 for name in names:
676 data += td("stuff", align="center")
677 data += " </tr>\n"
678
679 data += "</table>\n"
680 return data
681
683 debug = False
684
685
686 showEvents = False
687 if request.args.get("show_events", ["true"])[0].lower() == "true":
688 showEvents = True
689 filterCategories = request.args.get('category', [])
690 filterBranches = [b for b in request.args.get("branch", []) if b]
691 filterBranches = map_branches(filterBranches)
692 maxTime = int(request.args.get("last_time", [util.now()])[0])
693 if "show_time" in request.args:
694 minTime = maxTime - int(request.args["show_time"][0])
695 elif "first_time" in request.args:
696 minTime = int(request.args["first_time"][0])
697 else:
698 minTime = None
699 spanLength = 10
700 maxPageLen = int(request.args.get("num_events", [200])[0])
701
702
703
704
705
706 commit_source = self.getChangemaster(request)
707
708 lastEventTime = util.now()
709 sources = [commit_source] + builders
710 changeNames = ["changes"]
711 builderNames = map(lambda builder: builder.getName(), builders)
712 sourceNames = changeNames + builderNames
713 sourceEvents = []
714 sourceGenerators = []
715
716 def get_event_from(g):
717 try:
718 while True:
719 e = g.next()
720
721
722
723
724
725 if not showEvents and isinstance(e, builder.Event):
726 continue
727 break
728 event = interfaces.IStatusEvent(e)
729 if debug:
730 log.msg("gen %s gave1 %s" % (g, event.getText()))
731 except StopIteration:
732 event = None
733 return event
734
735 for s in sources:
736 gen = insertGaps(s.eventGenerator(filterBranches, filterCategories), lastEventTime)
737 sourceGenerators.append(gen)
738
739 sourceEvents.append(get_event_from(gen))
740 eventGrid = []
741 timestamps = []
742
743 lastEventTime = 0
744 for e in sourceEvents:
745 if e and e.getTimes()[0] > lastEventTime:
746 lastEventTime = e.getTimes()[0]
747 if lastEventTime == 0:
748 lastEventTime = util.now()
749
750 spanStart = lastEventTime - spanLength
751 debugGather = 0
752
753 while 1:
754 if debugGather: log.msg("checking (%s,]" % spanStart)
755
756
757
758
759
760
761
762 spanEvents = []
763 firstTimestamp = None
764 lastTimestamp = None
765
766 for c in range(len(sourceGenerators)):
767 events = []
768 event = sourceEvents[c]
769 while event and spanStart < event.getTimes()[0]:
770
771
772 if not IBox(event, None):
773 log.msg("BAD EVENT", event, event.getText())
774 assert 0
775 if debug:
776 log.msg("pushing", event.getText(), event)
777 events.append(event)
778 starts, finishes = event.getTimes()
779 firstTimestamp = util.earlier(firstTimestamp, starts)
780 event = get_event_from(sourceGenerators[c])
781 if debug:
782 log.msg("finished span")
783
784 if event:
785
786 lastTimestamp = util.later(lastTimestamp,
787 event.getTimes()[0])
788 if debugGather:
789 log.msg(" got %s from %s" % (events, sourceNames[c]))
790 sourceEvents[c] = event
791 spanEvents.append(events)
792
793
794
795
796 if firstTimestamp is not None and firstTimestamp <= maxTime:
797 eventGrid.append(spanEvents)
798 timestamps.append(firstTimestamp)
799
800 if lastTimestamp:
801 spanStart = lastTimestamp - spanLength
802 else:
803
804 break
805 if minTime is not None and lastTimestamp < minTime:
806 break
807
808 if len(timestamps) > maxPageLen:
809 break
810
811
812
813
814
815 if debugGather: log.msg("finished loop")
816 assert(len(timestamps) == len(eventGrid))
817 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents)
818
819 - def phase0(self, request, sourceNames, timestamps, eventGrid):
820
821 if not timestamps:
822 return "no events"
823 data = ""
824 for r in range(0, len(timestamps)):
825 data += "<p>\n"
826 data += "[%s]<br />" % timestamps[r]
827 row = eventGrid[r]
828 assert(len(row) == len(sourceNames))
829 for c in range(0, len(row)):
830 if row[c]:
831 data += "<b>%s</b><br />\n" % sourceNames[c]
832 for e in row[c]:
833 log.msg("Event", r, c, sourceNames[c], e.getText())
834 lognames = [loog.getName() for loog in e.getLogs()]
835 data += "%s: %s: %s<br />" % (e.getText(),
836 e.getTimes()[0],
837 lognames)
838 else:
839 data += "<b>%s</b> [none]<br />\n" % sourceNames[c]
840 return data
841
842 - def phase1(self, request, sourceNames, timestamps, eventGrid,
843 sourceEvents):
844
845 data = ""
846 if not timestamps:
847 return data
848 lastDate = None
849 for r in range(0, len(timestamps)):
850 chunkstrip = eventGrid[r]
851
852
853 assert(len(chunkstrip) == len(sourceNames))
854 maxRows = reduce(lambda x,y: max(x,y),
855 map(lambda x: len(x), chunkstrip))
856 for i in range(maxRows):
857 data += " <tr>\n";
858 if i == 0:
859 stuff = []
860
861 today = time.strftime("<b>%d %b %Y</b>",
862 time.localtime(timestamps[r]))
863 todayday = time.strftime("<b>%a</b>",
864 time.localtime(timestamps[r]))
865 if today != lastDate:
866 stuff.append(todayday)
867 stuff.append(today)
868 lastDate = today
869 stuff.append(
870 time.strftime("%H:%M:%S",
871 time.localtime(timestamps[r])))
872 data += td(stuff, valign="bottom", align="center",
873 rowspan=maxRows, class_="Time")
874 for c in range(0, len(chunkstrip)):
875 block = chunkstrip[c]
876 assert(block != None)
877
878 offset = maxRows - len(block)
879 if i < offset:
880 data += td("")
881 else:
882 e = block[i-offset]
883 box = IBox(e).getBox(request)
884 box.parms["show_idle"] = 1
885 data += box.td(valign="top", align="center")
886 data += " </tr>\n"
887
888 return data
889
890 - def phase2(self, request, sourceNames, timestamps, eventGrid,
891 sourceEvents):
892 data = ""
893 if not timestamps:
894 return data
895
896 grid = []
897 for i in range(1+len(sourceNames)):
898 grid.append([])
899
900
901
902 lastDate = time.strftime("<b>%d %b %Y</b>",
903 time.localtime(util.now()))
904 for r in range(0, len(timestamps)):
905 chunkstrip = eventGrid[r]
906
907
908 assert(len(chunkstrip) == len(sourceNames))
909 maxRows = reduce(lambda x,y: max(x,y),
910 map(lambda x: len(x), chunkstrip))
911 for i in range(maxRows):
912 if i != maxRows-1:
913 grid[0].append(None)
914 else:
915
916 stuff = []
917
918
919 todayday = time.strftime("<b>%a</b>",
920 time.localtime(timestamps[r]))
921 today = time.strftime("<b>%d %b %Y</b>",
922 time.localtime(timestamps[r]))
923 if today != lastDate:
924 stuff.append(todayday)
925 stuff.append(today)
926 lastDate = today
927 stuff.append(
928 time.strftime("%H:%M:%S",
929 time.localtime(timestamps[r])))
930 grid[0].append(Box(text=stuff, class_="Time",
931 valign="bottom", align="center"))
932
933
934
935 for c in range(0, len(chunkstrip)):
936 block = chunkstrip[c]
937 assert(block != None)
938 for i in range(maxRows - len(block)):
939
940 grid[c+1].append(None)
941 for i in range(len(block)):
942
943 b = IBox(block[i]).getBox(request)
944 b.parms['valign'] = "top"
945 b.parms['align'] = "center"
946 grid[c+1].append(b)
947
948
949 gridlen = len(grid[0])
950 for i in range(len(grid)):
951 strip = grid[i]
952 assert(len(strip) == gridlen)
953 if strip[-1] == None:
954 if sourceEvents[i-1]:
955 filler = IBox(sourceEvents[i-1]).getBox(request)
956 else:
957
958 filler = Box(text=["?"], align="center")
959 strip[-1] = filler
960 strip[-1].parms['rowspan'] = 1
961
962
963
964 noBubble = request.args.get("nobubble",['0'])
965 noBubble = int(noBubble[0])
966 if not noBubble:
967 for col in range(len(grid)):
968 strip = grid[col]
969 if col == 1:
970 for i in range(2, len(strip)+1):
971
972 if strip[-i] == None:
973 next = strip[-i+1]
974 assert(next)
975 if next:
976
977 if next.spacer:
978
979 strip[-i] = next
980 strip[-i].parms['rowspan'] += 1
981 strip[-i+1] = None
982 else:
983
984
985
986 strip[-i] = Box([], rowspan=1,
987 comment="commit bubble")
988 strip[-i].spacer = True
989 else:
990
991
992
993 pass
994 else:
995 for i in range(2, len(strip)+1):
996
997 if strip[-i] == None:
998
999 assert(strip[-i+1] != None)
1000 strip[-i] = strip[-i+1]
1001 strip[-i].parms['rowspan'] += 1
1002 strip[-i+1] = None
1003 else:
1004 strip[-i].parms['rowspan'] = 1
1005
1006 for i in range(gridlen):
1007 data += " <tr>\n";
1008 for strip in grid:
1009 b = strip[i]
1010 if b:
1011
1012 s = b.td()
1013 if isinstance(s, unicode):
1014 s = s.encode("utf-8", "replace")
1015 data += s
1016 else:
1017 if noBubble:
1018 data += td([])
1019
1020 data += " </tr>\n"
1021 return data
1022