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