Package buildbot :: Package status :: Module builder
[hide private]
[frames] | no frames]

Source Code for Module buildbot.status.builder

   1  # -*- test-case-name: buildbot.test.test_status -*- 
   2   
   3  from zope.interface import implements 
   4  from twisted.python import log 
   5  from twisted.persisted import styles 
   6  from twisted.internet import reactor, defer, threads 
   7  from twisted.protocols import basic 
   8  from buildbot.process.properties import Properties 
   9   
  10  import weakref 
  11  import os, shutil, sys, re, urllib, itertools 
  12  import gc 
  13  from cPickle import load, dump 
  14  from cStringIO import StringIO 
  15  from bz2 import BZ2File 
  16   
  17  # sibling imports 
  18  from buildbot import interfaces, util, sourcestamp 
  19   
  20  SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION = range(5) 
  21  Results = ["success", "warnings", "failure", "skipped", "exception"] 
  22   
  23   
  24  # build processes call the following methods: 
  25  # 
  26  #  setDefaults 
  27  # 
  28  #  currentlyBuilding 
  29  #  currentlyIdle 
  30  #  currentlyInterlocked 
  31  #  currentlyOffline 
  32  #  currentlyWaiting 
  33  # 
  34  #  setCurrentActivity 
  35  #  updateCurrentActivity 
  36  #  addFileToCurrentActivity 
  37  #  finishCurrentActivity 
  38  # 
  39  #  startBuild 
  40  #  finishBuild 
  41   
  42  STDOUT = interfaces.LOG_CHANNEL_STDOUT 
  43  STDERR = interfaces.LOG_CHANNEL_STDERR 
  44  HEADER = interfaces.LOG_CHANNEL_HEADER 
  45  ChunkTypes = ["stdout", "stderr", "header"] 
  46   
47 -class LogFileScanner(basic.NetstringReceiver):
48 - def __init__(self, chunk_cb, channels=[]):
49 self.chunk_cb = chunk_cb 50 self.channels = channels
51
52 - def stringReceived(self, line):
53 channel = int(line[0]) 54 if not self.channels or (channel in self.channels): 55 self.chunk_cb((channel, line[1:]))
56
57 -class LogFileProducer:
58 """What's the plan? 59 60 the LogFile has just one FD, used for both reading and writing. 61 Each time you add an entry, fd.seek to the end and then write. 62 63 Each reader (i.e. Producer) keeps track of their own offset. The reader 64 starts by seeking to the start of the logfile, and reading forwards. 65 Between each hunk of file they yield chunks, so they must remember their 66 offset before yielding and re-seek back to that offset before reading 67 more data. When their read() returns EOF, they're finished with the first 68 phase of the reading (everything that's already been written to disk). 69 70 After EOF, the remaining data is entirely in the current entries list. 71 These entries are all of the same channel, so we can do one "".join and 72 obtain a single chunk to be sent to the listener. But since that involves 73 a yield, and more data might arrive after we give up control, we have to 74 subscribe them before yielding. We can't subscribe them any earlier, 75 otherwise they'd get data out of order. 76 77 We're using a generator in the first place so that the listener can 78 throttle us, which means they're pulling. But the subscription means 79 we're pushing. Really we're a Producer. In the first phase we can be 80 either a PullProducer or a PushProducer. In the second phase we're only a 81 PushProducer. 82 83 So the client gives a LogFileConsumer to File.subscribeConsumer . This 84 Consumer must have registerProducer(), unregisterProducer(), and 85 writeChunk(), and is just like a regular twisted.interfaces.IConsumer, 86 except that writeChunk() takes chunks (tuples of (channel,text)) instead 87 of the normal write() which takes just text. The LogFileConsumer is 88 allowed to call stopProducing, pauseProducing, and resumeProducing on the 89 producer instance it is given. """ 90 91 paused = False 92 subscribed = False 93 BUFFERSIZE = 2048 94
95 - def __init__(self, logfile, consumer):
96 self.logfile = logfile 97 self.consumer = consumer 98 self.chunkGenerator = self.getChunks() 99 consumer.registerProducer(self, True)
100
101 - def getChunks(self):
102 f = self.logfile.getFile() 103 offset = 0 104 chunks = [] 105 p = LogFileScanner(chunks.append) 106 f.seek(offset) 107 data = f.read(self.BUFFERSIZE) 108 offset = f.tell() 109 while data: 110 p.dataReceived(data) 111 while chunks: 112 c = chunks.pop(0) 113 yield c 114 f.seek(offset) 115 data = f.read(self.BUFFERSIZE) 116 offset = f.tell() 117 del f 118 119 # now subscribe them to receive new entries 120 self.subscribed = True 121 self.logfile.watchers.append(self) 122 d = self.logfile.waitUntilFinished() 123 124 # then give them the not-yet-merged data 125 if self.logfile.runEntries: 126 channel = self.logfile.runEntries[0][0] 127 text = "".join([c[1] for c in self.logfile.runEntries]) 128 yield (channel, text) 129 130 # now we've caught up to the present. Anything further will come from 131 # the logfile subscription. We add the callback *after* yielding the 132 # data from runEntries, because the logfile might have finished 133 # during the yield. 134 d.addCallback(self.logfileFinished)
135
136 - def stopProducing(self):
137 # TODO: should we still call consumer.finish? probably not. 138 self.paused = True 139 self.consumer = None 140 self.done()
141
142 - def done(self):
143 if self.chunkGenerator: 144 self.chunkGenerator = None # stop making chunks 145 if self.subscribed: 146 self.logfile.watchers.remove(self) 147 self.subscribed = False
148
149 - def pauseProducing(self):
150 self.paused = True
151
152 - def resumeProducing(self):
153 # Twisted-1.3.0 has a bug which causes hangs when resumeProducing 154 # calls transport.write (there is a recursive loop, fixed in 2.0 in 155 # t.i.abstract.FileDescriptor.doWrite by setting the producerPaused 156 # flag *before* calling resumeProducing). To work around this, we 157 # just put off the real resumeProducing for a moment. This probably 158 # has a performance hit, but I'm going to assume that the log files 159 # are not retrieved frequently enough for it to be an issue. 160 161 reactor.callLater(0, self._resumeProducing)
162
163 - def _resumeProducing(self):
164 self.paused = False 165 if not self.chunkGenerator: 166 return 167 try: 168 while not self.paused: 169 chunk = self.chunkGenerator.next() 170 self.consumer.writeChunk(chunk) 171 # we exit this when the consumer says to stop, or we run out 172 # of chunks 173 except StopIteration: 174 # if the generator finished, it will have done releaseFile 175 self.chunkGenerator = None
176 # now everything goes through the subscription, and they don't get to 177 # pause anymore 178
179 - def logChunk(self, build, step, logfile, channel, chunk):
180 if self.consumer: 181 self.consumer.writeChunk((channel, chunk))
182
183 - def logfileFinished(self, logfile):
184 self.done() 185 if self.consumer: 186 self.consumer.unregisterProducer() 187 self.consumer.finish() 188 self.consumer = None
189
190 -def _tryremove(filename, timeout, retries):
191 """Try to remove a file, and if failed, try again in timeout. 192 Increases the timeout by a factor of 4, and only keeps trying for 193 another retries-amount of times. 194 195 """ 196 try: 197 os.unlink(filename) 198 except OSError: 199 if retries > 0: 200 reactor.callLater(timeout, _tryremove, filename, timeout * 4, 201 retries - 1) 202 else: 203 log.msg("giving up on removing %s after over %d seconds" % 204 (filename, timeout))
205
206 -class LogFile:
207 """A LogFile keeps all of its contents on disk, in a non-pickle format to 208 which new entries can easily be appended. The file on disk has a name 209 like 12-log-compile-output, under the Builder's directory. The actual 210 filename is generated (before the LogFile is created) by 211 L{BuildStatus.generateLogfileName}. 212 213 Old LogFile pickles (which kept their contents in .entries) must be 214 upgraded. The L{BuilderStatus} is responsible for doing this, when it 215 loads the L{BuildStatus} into memory. The Build pickle is not modified, 216 so users who go from 0.6.5 back to 0.6.4 don't have to lose their 217 logs.""" 218 219 implements(interfaces.IStatusLog, interfaces.ILogFile) 220 221 finished = False 222 length = 0 223 chunkSize = 10*1000 224 runLength = 0 225 runEntries = [] # provided so old pickled builds will getChunks() ok 226 entries = None 227 BUFFERSIZE = 2048 228 filename = None # relative to the Builder's basedir 229 openfile = None 230
231 - def __init__(self, parent, name, logfilename):
232 """ 233 @type parent: L{BuildStepStatus} 234 @param parent: the Step that this log is a part of 235 @type name: string 236 @param name: the name of this log, typically 'output' 237 @type logfilename: string 238 @param logfilename: the Builder-relative pathname for the saved entries 239 """ 240 self.step = parent 241 self.name = name 242 self.filename = logfilename 243 fn = self.getFilename() 244 if os.path.exists(fn): 245 # the buildmaster was probably stopped abruptly, before the 246 # BuilderStatus could be saved, so BuilderStatus.nextBuildNumber 247 # is out of date, and we're overlapping with earlier builds now. 248 # Warn about it, but then overwrite the old pickle file 249 log.msg("Warning: Overwriting old serialized Build at %s" % fn) 250 self.openfile = open(fn, "w+") 251 self.runEntries = [] 252 self.watchers = [] 253 self.finishedWatchers = []
254
255 - def getFilename(self):
256 return os.path.join(self.step.build.builder.basedir, self.filename)
257
258 - def hasContents(self):
259 return os.path.exists(self.getFilename() + '.bz2') or \ 260 os.path.exists(self.getFilename())
261
262 - def getName(self):
263 return self.name
264
265 - def getStep(self):
266 return self.step
267
268 - def isFinished(self):
269 return self.finished
270 - def waitUntilFinished(self):
271 if self.finished: 272 d = defer.succeed(self) 273 else: 274 d = defer.Deferred() 275 self.finishedWatchers.append(d) 276 return d
277
278 - def getFile(self):
279 if self.openfile: 280 # this is the filehandle we're using to write to the log, so 281 # don't close it! 282 return self.openfile 283 # otherwise they get their own read-only handle 284 # try a compressed log first 285 try: 286 return BZ2File(self.getFilename() + ".bz2", "r") 287 except IOError: 288 pass 289 return open(self.getFilename(), "r")
290
291 - def getText(self):
292 # this produces one ginormous string 293 return "".join(self.getChunks([STDOUT, STDERR], onlyText=True))
294
295 - def getTextWithHeaders(self):
296 return "".join(self.getChunks(onlyText=True))
297
298 - def getChunks(self, channels=[], onlyText=False):
299 # generate chunks for everything that was logged at the time we were 300 # first called, so remember how long the file was when we started. 301 # Don't read beyond that point. The current contents of 302 # self.runEntries will follow. 303 304 # this returns an iterator, which means arbitrary things could happen 305 # while we're yielding. This will faithfully deliver the log as it 306 # existed when it was started, and not return anything after that 307 # point. To use this in subscribe(catchup=True) without missing any 308 # data, you must insure that nothing will be added to the log during 309 # yield() calls. 310 311 f = self.getFile() 312 offset = 0 313 f.seek(0, 2) 314 remaining = f.tell() 315 316 leftover = None 317 if self.runEntries and (not channels or 318 (self.runEntries[0][0] in channels)): 319 leftover = (self.runEntries[0][0], 320 "".join([c[1] for c in self.runEntries])) 321 322 # freeze the state of the LogFile by passing a lot of parameters into 323 # a generator 324 return self._generateChunks(f, offset, remaining, leftover, 325 channels, onlyText)
326
327 - def _generateChunks(self, f, offset, remaining, leftover, 328 channels, onlyText):
329 chunks = [] 330 p = LogFileScanner(chunks.append, channels) 331 f.seek(offset) 332 data = f.read(min(remaining, self.BUFFERSIZE)) 333 remaining -= len(data) 334 offset = f.tell() 335 while data: 336 p.dataReceived(data) 337 while chunks: 338 channel, text = chunks.pop(0) 339 if onlyText: 340 yield text 341 else: 342 yield (channel, text) 343 f.seek(offset) 344 data = f.read(min(remaining, self.BUFFERSIZE)) 345 remaining -= len(data) 346 offset = f.tell() 347 del f 348 349 if leftover: 350 if onlyText: 351 yield leftover[1] 352 else: 353 yield leftover
354
355 - def readlines(self, channel=STDOUT):
356 """Return an iterator that produces newline-terminated lines, 357 excluding header chunks.""" 358 # TODO: make this memory-efficient, by turning it into a generator 359 # that retrieves chunks as necessary, like a pull-driven version of 360 # twisted.protocols.basic.LineReceiver 361 alltext = "".join(self.getChunks([channel], onlyText=True)) 362 io = StringIO(alltext) 363 return io.readlines()
364
365 - def subscribe(self, receiver, catchup):
366 if self.finished: 367 return 368 self.watchers.append(receiver) 369 if catchup: 370 for channel, text in self.getChunks(): 371 # TODO: add logChunks(), to send over everything at once? 372 receiver.logChunk(self.step.build, self.step, self, 373 channel, text)
374
375 - def unsubscribe(self, receiver):
376 if receiver in self.watchers: 377 self.watchers.remove(receiver)
378
379 - def subscribeConsumer(self, consumer):
380 p = LogFileProducer(self, consumer) 381 p.resumeProducing()
382 383 # interface used by the build steps to add things to the log 384
385 - def merge(self):
386 # merge all .runEntries (which are all of the same type) into a 387 # single chunk for .entries 388 if not self.runEntries: 389 return 390 channel = self.runEntries[0][0] 391 text = "".join([c[1] for c in self.runEntries]) 392 assert channel < 10 393 f = self.openfile 394 f.seek(0, 2) 395 offset = 0 396 while offset < len(text): 397 size = min(len(text)-offset, self.chunkSize) 398 f.write("%d:%d" % (1 + size, channel)) 399 f.write(text[offset:offset+size]) 400 f.write(",") 401 offset += size 402 self.runEntries = [] 403 self.runLength = 0
404
405 - def addEntry(self, channel, text):
406 assert not self.finished 407 # we only add to .runEntries here. merge() is responsible for adding 408 # merged chunks to .entries 409 if self.runEntries and channel != self.runEntries[0][0]: 410 self.merge() 411 self.runEntries.append((channel, text)) 412 self.runLength += len(text) 413 if self.runLength >= self.chunkSize: 414 self.merge() 415 416 for w in self.watchers: 417 w.logChunk(self.step.build, self.step, self, channel, text) 418 self.length += len(text)
419
420 - def addStdout(self, text):
421 self.addEntry(STDOUT, text)
422 - def addStderr(self, text):
423 self.addEntry(STDERR, text)
424 - def addHeader(self, text):
425 self.addEntry(HEADER, text)
426
427 - def finish(self):
428 self.merge() 429 if self.openfile: 430 # we don't do an explicit close, because there might be readers 431 # shareing the filehandle. As soon as they stop reading, the 432 # filehandle will be released and automatically closed. We will 433 # do a sync, however, to make sure the log gets saved in case of 434 # a crash. 435 self.openfile.flush() 436 os.fsync(self.openfile.fileno()) 437 del self.openfile 438 self.finished = True 439 watchers = self.finishedWatchers 440 self.finishedWatchers = [] 441 for w in watchers: 442 w.callback(self) 443 self.watchers = []
444 445
446 - def compressLog(self):
447 compressed = self.getFilename() + ".bz2.tmp" 448 d = threads.deferToThread(self._compressLog, compressed) 449 d.addCallback(self._renameCompressedLog, compressed) 450 d.addErrback(self._cleanupFailedCompress, compressed) 451 return d
452
453 - def _compressLog(self, compressed):
454 infile = self.getFile() 455 cf = BZ2File(compressed, 'w') 456 bufsize = 1024*1024 457 while True: 458 buf = infile.read(bufsize) 459 cf.write(buf) 460 if len(buf) < bufsize: 461 break 462 cf.close()
463 - def _renameCompressedLog(self, rv, compressed):
464 filename = self.getFilename() + '.bz2' 465 if sys.platform == 'win32': 466 # windows cannot rename a file on top of an existing one, so 467 # fall back to delete-first. There are ways this can fail and 468 # lose the builder's history, so we avoid using it in the 469 # general (non-windows) case 470 if os.path.exists(filename): 471 os.unlink(filename) 472 os.rename(compressed, filename) 473 _tryremove(self.getFilename(), 1, 5)
474 - def _cleanupFailedCompress(self, failure, compressed):
475 log.msg("failed to compress %s" % self.getFilename()) 476 if os.path.exists(compressed): 477 _tryremove(compressed, 1, 5) 478 failure.trap() # reraise the failure
479 480 # persistence stuff
481 - def __getstate__(self):
482 d = self.__dict__.copy() 483 del d['step'] # filled in upon unpickling 484 del d['watchers'] 485 del d['finishedWatchers'] 486 d['entries'] = [] # let 0.6.4 tolerate the saved log. TODO: really? 487 if d.has_key('finished'): 488 del d['finished'] 489 if d.has_key('openfile'): 490 del d['openfile'] 491 return d
492
493 - def __setstate__(self, d):
494 self.__dict__ = d 495 self.watchers = [] # probably not necessary 496 self.finishedWatchers = [] # same 497 # self.step must be filled in by our parent 498 self.finished = True
499
500 - def upgrade(self, logfilename):
501 """Save our .entries to a new-style offline log file (if necessary), 502 and modify our in-memory representation to use it. The original 503 pickled LogFile (inside the pickled Build) won't be modified.""" 504 self.filename = logfilename 505 if not os.path.exists(self.getFilename()): 506 self.openfile = open(self.getFilename(), "w") 507 self.finished = False 508 for channel,text in self.entries: 509 self.addEntry(channel, text) 510 self.finish() # releases self.openfile, which will be closed 511 del self.entries
512
513 -class HTMLLogFile:
514 implements(interfaces.IStatusLog) 515 516 filename = None 517
518 - def __init__(self, parent, name, logfilename, html):
519 self.step = parent 520 self.name = name 521 self.filename = logfilename 522 self.html = html
523
524 - def getName(self):
525 return self.name # set in BuildStepStatus.addLog
526 - def getStep(self):
527 return self.step
528
529 - def isFinished(self):
530 return True
531 - def waitUntilFinished(self):
532 return defer.succeed(self)
533
534 - def hasContents(self):
535 return True
536 - def getText(self):
537 return self.html # looks kinda like text
538 - def getTextWithHeaders(self):
539 return self.html
540 - def getChunks(self):
541 return [(STDERR, self.html)]
542
543 - def subscribe(self, receiver, catchup):
544 pass
545 - def unsubscribe(self, receiver):
546 pass
547
548 - def finish(self):
549 pass
550
551 - def __getstate__(self):
552 d = self.__dict__.copy() 553 del d['step'] 554 return d
555
556 - def upgrade(self, logfilename):
557 pass
558 559
560 -class Event:
561 implements(interfaces.IStatusEvent) 562 563 started = None 564 finished = None 565 text = [] 566 567 # IStatusEvent methods
568 - def getTimes(self):
569 return (self.started, self.finished)
570 - def getText(self):
571 return self.text
572 - def getLogs(self):
573 return []
574
575 - def finish(self):
576 self.finished = util.now()
577
578 -class TestResult:
579 implements(interfaces.ITestResult) 580
581 - def __init__(self, name, results, text, logs):
582 assert isinstance(name, tuple) 583 self.name = name 584 self.results = results 585 self.text = text 586 self.logs = logs
587
588 - def getName(self):
589 return self.name
590
591 - def getResults(self):
592 return self.results
593
594 - def getText(self):
595 return self.text
596
597 - def getLogs(self):
598 return self.logs
599 600
601 -class BuildSetStatus:
602 implements(interfaces.IBuildSetStatus) 603
604 - def __init__(self, source, reason, builderNames, bsid=None):
605 self.source = source 606 self.reason = reason 607 self.builderNames = builderNames 608 self.id = bsid 609 self.successWatchers = [] 610 self.finishedWatchers = [] 611 self.stillHopeful = True 612 self.finished = False
613
614 - def setBuildRequestStatuses(self, buildRequestStatuses):
615 self.buildRequests = buildRequestStatuses
616 - def setResults(self, results):
617 # the build set succeeds only if all its component builds succeed 618 self.results = results
619 - def giveUpHope(self):
620 self.stillHopeful = False
621 622
623 - def notifySuccessWatchers(self):
624 for d in self.successWatchers: 625 d.callback(self) 626 self.successWatchers = []
627
628 - def notifyFinishedWatchers(self):
629 self.finished = True 630 for d in self.finishedWatchers: 631 d.callback(self) 632 self.finishedWatchers = []
633 634 # methods for our clients 635
636 - def getSourceStamp(self):
637 return self.source
638 - def getReason(self):
639 return self.reason
640 - def getResults(self):
641 return self.results
642 - def getID(self):
643 return self.id
644
645 - def getBuilderNames(self):
646 return self.builderNames
647 - def getBuildRequests(self):
648 return self.buildRequests
649 - def isFinished(self):
650 return self.finished
651
652 - def waitUntilSuccess(self):
653 if self.finished or not self.stillHopeful: 654 # the deferreds have already fired 655 return defer.succeed(self) 656 d = defer.Deferred() 657 self.successWatchers.append(d) 658 return d
659
660 - def waitUntilFinished(self):
661 if self.finished: 662 return defer.succeed(self) 663 d = defer.Deferred() 664 self.finishedWatchers.append(d) 665 return d
666
667 -class BuildRequestStatus:
668 implements(interfaces.IBuildRequestStatus) 669
670 - def __init__(self, source, builderName):
671 self.source = source 672 self.builderName = builderName 673 self.builds = [] # list of BuildStatus objects 674 self.observers = [] 675 self.submittedAt = None
676
677 - def buildStarted(self, build):
678 self.builds.append(build) 679 for o in self.observers[:]: 680 o(build)
681 682 # methods called by our clients
683 - def getSourceStamp(self):
684 return self.source
685 - def getBuilderName(self):
686 return self.builderName
687 - def getBuilds(self):
688 return self.builds
689
690 - def subscribe(self, observer):
691 self.observers.append(observer) 692 for b in self.builds: 693 observer(b)
694 - def unsubscribe(self, observer):
695 self.observers.remove(observer)
696
697 - def getSubmitTime(self):
698 return self.submittedAt
699 - def setSubmitTime(self, t):
700 self.submittedAt = t
701 702
703 -class BuildStepStatus(styles.Versioned):
704 """ 705 I represent a collection of output status for a 706 L{buildbot.process.step.BuildStep}. 707 708 Statistics contain any information gleaned from a step that is 709 not in the form of a logfile. As an example, steps that run 710 tests might gather statistics about the number of passed, failed, 711 or skipped tests. 712 713 @type progress: L{buildbot.status.progress.StepProgress} 714 @cvar progress: tracks ETA for the step 715 @type text: list of strings 716 @cvar text: list of short texts that describe the command and its status 717 @type text2: list of strings 718 @cvar text2: list of short texts added to the overall build description 719 @type logs: dict of string -> L{buildbot.status.builder.LogFile} 720 @ivar logs: logs of steps 721 @type statistics: dict 722 @ivar statistics: results from running this step 723 """ 724 # note that these are created when the Build is set up, before each 725 # corresponding BuildStep has started. 726 implements(interfaces.IBuildStepStatus, interfaces.IStatusEvent) 727 persistenceVersion = 2 728 729 started = None 730 finished = None 731 progress = None 732 text = [] 733 results = (None, []) 734 text2 = [] 735 watchers = [] 736 updates = {} 737 finishedWatchers = [] 738 statistics = {} 739
740 - def __init__(self, parent):
741 assert interfaces.IBuildStatus(parent) 742 self.build = parent 743 self.logs = [] 744 self.urls = {} 745 self.watchers = [] 746 self.updates = {} 747 self.finishedWatchers = [] 748 self.statistics = {}
749
750 - def getName(self):
751 """Returns a short string with the name of this step. This string 752 may have spaces in it.""" 753 return self.name
754
755 - def getBuild(self):
756 return self.build
757
758 - def getTimes(self):
759 return (self.started, self.finished)
760
761 - def getExpectations(self):
762 """Returns a list of tuples (name, current, target).""" 763 if not self.progress: 764 return [] 765 ret = [] 766 metrics = self.progress.progress.keys() 767 metrics.sort() 768 for m in metrics: 769 t = (m, self.progress.progress[m], self.progress.expectations[m]) 770 ret.append(t) 771 return ret
772
773 - def getLogs(self):
774 return self.logs
775
776 - def getURLs(self):
777 return self.urls.copy()
778
779 - def isStarted(self):
780 return (self.started is not None)
781
782 - def isFinished(self):
783 return (self.finished is not None)
784
785 - def waitUntilFinished(self):
786 if self.finished: 787 d = defer.succeed(self) 788 else: 789 d = defer.Deferred() 790 self.finishedWatchers.append(d) 791 return d
792 793 # while the step is running, the following methods make sense. 794 # Afterwards they return None 795
796 - def getETA(self):
797 if self.started is None: 798 return None # not started yet 799 if self.finished is not None: 800 return None # already finished 801 if not self.progress: 802 return None # no way to predict 803 return self.progress.remaining()
804 805 # Once you know the step has finished, the following methods are legal. 806 # Before this step has finished, they all return None. 807
808 - def getText(self):
809 """Returns a list of strings which describe the step. These are 810 intended to be displayed in a narrow column. If more space is 811 available, the caller should join them together with spaces before 812 presenting them to the user.""" 813 return self.text
814
815 - def getResults(self):
816 """Return a tuple describing the results of the step. 817 'result' is one of the constants in L{buildbot.status.builder}: 818 SUCCESS, WARNINGS, FAILURE, or SKIPPED. 819 'strings' is an optional list of strings that the step wants to 820 append to the overall build's results. These strings are usually 821 more terse than the ones returned by getText(): in particular, 822 successful Steps do not usually contribute any text to the 823 overall build. 824 825 @rtype: tuple of int, list of strings 826 @returns: (result, strings) 827 """ 828 return (self.results, self.text2)
829
830 - def hasStatistic(self, name):
831 """Return true if this step has a value for the given statistic. 832 """ 833 return self.statistics.has_key(name)
834
835 - def getStatistic(self, name, default=None):
836 """Return the given statistic, if present 837 """ 838 return self.statistics.get(name, default)
839 840 # subscription interface 841
842 - def subscribe(self, receiver, updateInterval=10):
843 # will get logStarted, logFinished, stepETAUpdate 844 assert receiver not in self.watchers 845 self.watchers.append(receiver) 846 self.sendETAUpdate(receiver, updateInterval)
847
848 - def sendETAUpdate(self, receiver, updateInterval):
849 self.updates[receiver] = None 850 # they might unsubscribe during stepETAUpdate 851 receiver.stepETAUpdate(self.build, self, 852 self.getETA(), self.getExpectations()) 853 if receiver in self.watchers: 854 self.updates[receiver] = reactor.callLater(updateInterval, 855 self.sendETAUpdate, 856 receiver, 857 updateInterval)
858
859 - def unsubscribe(self, receiver):
860 if receiver in self.watchers: 861 self.watchers.remove(receiver) 862 if receiver in self.updates: 863 if self.updates[receiver] is not None: 864 self.updates[receiver].cancel() 865 del self.updates[receiver]
866 867 868 # methods to be invoked by the BuildStep 869
870 - def setName(self, stepname):
871 self.name = stepname
872
873 - def setColor(self, color):
874 log.msg("BuildStepStatus.setColor is no longer supported -- ignoring color %s" % (color,))
875
876 - def setProgress(self, stepprogress):
877 self.progress = stepprogress
878
879 - def stepStarted(self):
880 self.started = util.now() 881 if self.build: 882 self.build.stepStarted(self)
883
884 - def addLog(self, name):
885 assert self.started # addLog before stepStarted won't notify watchers 886 logfilename = self.build.generateLogfileName(self.name, name) 887 log = LogFile(self, name, logfilename) 888 self.logs.append(log) 889 for w in self.watchers: 890 receiver = w.logStarted(self.build, self, log) 891 if receiver: 892 log.subscribe(receiver, True) 893 d = log.waitUntilFinished() 894 d.addCallback(lambda log: log.unsubscribe(receiver)) 895 d = log.waitUntilFinished() 896 d.addCallback(self.logFinished) 897 return log
898
899 - def addHTMLLog(self, name, html):
900 assert self.started # addLog before stepStarted won't notify watchers 901 logfilename = self.build.generateLogfileName(self.name, name) 902 log = HTMLLogFile(self, name, logfilename, html) 903 self.logs.append(log) 904 for w in self.watchers: 905 receiver = w.logStarted(self.build, self, log) 906 # TODO: think about this: there isn't much point in letting 907 # them subscribe 908 #if receiver: 909 # log.subscribe(receiver, True) 910 w.logFinished(self.build, self, log)
911
912 - def logFinished(self, log):
913 for w in self.watchers: 914 w.logFinished(self.build, self, log)
915
916 - def addURL(self, name, url):
917 self.urls[name] = url
918
919 - def setText(self, text):
920 self.text = text 921 for w in self.watchers: 922 w.stepTextChanged(self.build, self, text)
923 - def setText2(self, text):
924 self.text2 = text 925 for w in self.watchers: 926 w.stepText2Changed(self.build, self, text)
927
928 - def setStatistic(self, name, value):
929 """Set the given statistic. Usually called by subclasses. 930 """ 931 self.statistics[name] = value
932
933 - def stepFinished(self, results):
934 self.finished = util.now() 935 self.results = results 936 cld = [] # deferreds for log compression 937 logCompressionLimit = self.build.builder.logCompressionLimit 938 for loog in self.logs: 939 if not loog.isFinished(): 940 loog.finish() 941 # if log compression is on, and it's a real LogFile, 942 # HTMLLogFiles aren't files 943 if logCompressionLimit is not False and \ 944 isinstance(loog, LogFile): 945 if os.path.getsize(loog.getFilename()) > logCompressionLimit: 946 cld.append(loog.compressLog()) 947 948 for r in self.updates.keys(): 949 if self.updates[r] is not None: 950 self.updates[r].cancel() 951 del self.updates[r] 952 953 watchers = self.finishedWatchers 954 self.finishedWatchers = [] 955 for w in watchers: 956 w.callback(self) 957 if cld: 958 return defer.DeferredList(cld)
959
960 - def checkLogfiles(self):
961 # filter out logs that have been deleted 962 self.logs = [ l for l in self.logs if l.hasContents() ]
963 964 # persistence 965
966 - def __getstate__(self):
967 d = styles.Versioned.__getstate__(self) 968 del d['build'] # filled in when loading 969 if d.has_key('progress'): 970 del d['progress'] 971 del d['watchers'] 972 del d['finishedWatchers'] 973 del d['updates'] 974 return d
975
976 - def __setstate__(self, d):
977 styles.Versioned.__setstate__(self, d) 978 # self.build must be filled in by our parent 979 980 # point the logs to this object 981 for loog in self.logs: 982 loog.step = self
983
984 - def upgradeToVersion1(self):
985 if not hasattr(self, "urls"): 986 self.urls = {}
987
988 - def upgradeToVersion2(self):
989 if not hasattr(self, "statistics"): 990 self.statistics = {}
991 992
993 -class BuildStatus(styles.Versioned):
994 implements(interfaces.IBuildStatus, interfaces.IStatusEvent) 995 persistenceVersion = 3 996 997 source = None 998 reason = None 999 changes = [] 1000 blamelist = [] 1001 requests = [] 1002 progress = None 1003 started = None 1004 finished = None 1005 currentStep = None 1006 text = [] 1007 results = None 1008 slavename = "???" 1009 1010 # these lists/dicts are defined here so that unserialized instances have 1011 # (empty) values. They are set in __init__ to new objects to make sure 1012 # each instance gets its own copy. 1013 watchers = [] 1014 updates = {} 1015 finishedWatchers = [] 1016 testResults = {} 1017
1018 - def __init__(self, parent, number):
1019 """ 1020 @type parent: L{BuilderStatus} 1021 @type number: int 1022 """ 1023 assert interfaces.IBuilderStatus(parent) 1024 self.builder = parent 1025 self.number = number 1026 self.watchers = [] 1027 self.updates = {} 1028 self.finishedWatchers = [] 1029 self.steps = [] 1030 self.testResults = {} 1031 self.properties = Properties() 1032 self.requests = []
1033
1034 - def __repr__(self):
1035 return "<%s #%s>" % (self.__class__.__name__, self.number)
1036 1037 # IBuildStatus 1038
1039 - def getBuilder(self):
1040 """ 1041 @rtype: L{BuilderStatus} 1042 """ 1043 return self.builder
1044
1045 - def getProperty(self, propname):
1046 return self.properties[propname]
1047
1048 - def getProperties(self):
1049 return self.properties
1050
1051 - def getNumber(self):
1052 return self.number
1053
1054 - def getPreviousBuild(self):
1055 if self.number == 0: 1056 return None 1057 return self.builder.getBuild(self.number-1)
1058
1059 - def getSourceStamp(self, absolute=False):
1060 if not absolute or not self.properties.has_key('got_revision'): 1061 return self.source 1062 return self.source.getAbsoluteSourceStamp(self.properties['got_revision'])
1063
1064 - def getReason(self):
1065 return self.reason
1066
1067 - def getChanges(self):
1068 return self.changes
1069
1070 - def getRequests(self):
1071 return self.requests
1072
1073 - def getResponsibleUsers(self):
1074 return self.blamelist
1075
1076 - def getInterestedUsers(self):
1077 # TODO: the Builder should add others: sheriffs, domain-owners 1078 return self.blamelist + self.properties.getProperty('owners', [])
1079
1080 - def getSteps(self):
1081 """Return a list of IBuildStepStatus objects. For invariant builds 1082 (those which always use the same set of Steps), this should be the 1083 complete list, however some of the steps may not have started yet 1084 (step.getTimes()[0] will be None). For variant builds, this may not 1085 be complete (asking again later may give you more of them).""" 1086 return self.steps
1087
1088 - def getTimes(self):
1089 return (self.started, self.finished)
1090 1091 _sentinel = [] # used as a sentinel to indicate unspecified initial_value
1092 - def getSummaryStatistic(self, name, summary_fn, initial_value=_sentinel):
1093 """Summarize the named statistic over all steps in which it 1094 exists, using combination_fn and initial_value to combine multiple 1095 results into a single result. This translates to a call to Python's 1096 X{reduce}:: 1097 return reduce(summary_fn, step_stats_list, initial_value) 1098 """ 1099 step_stats_list = [ 1100 st.getStatistic(name) 1101 for st in self.steps 1102 if st.hasStatistic(name) ] 1103 if initial_value is self._sentinel: 1104 return reduce(summary_fn, step_stats_list) 1105 else: 1106 return reduce(summary_fn, step_stats_list, initial_value)
1107
1108 - def isFinished(self):
1109 return (self.finished is not None)
1110
1111 - def waitUntilFinished(self):
1112 if self.finished: 1113 d = defer.succeed(self) 1114 else: 1115 d = defer.Deferred() 1116 self.finishedWatchers.append(d) 1117 return d
1118 1119 # while the build is running, the following methods make sense. 1120 # Afterwards they return None 1121
1122 - def getETA(self):
1123 if self.finished is not None: 1124 return None 1125 if not self.progress: 1126 return None 1127 eta = self.progress.eta() 1128 if eta is None: 1129 return None 1130 return eta - util.now()
1131
1132 - def getCurrentStep(self):
1133 return self.currentStep
1134 1135 # Once you know the build has finished, the following methods are legal. 1136 # Before ths build has finished, they all return None. 1137
1138 - def getText(self):
1139 text = [] 1140 text.extend(self.text) 1141 for s in self.steps: 1142 text.extend(s.text2) 1143 return text
1144
1145 - def getResults(self):
1146 return self.results
1147
1148 - def getSlavename(self):
1149 return self.slavename
1150
1151 - def getTestResults(self):
1152 return self.testResults
1153
1154 - def getLogs(self):
1155 # TODO: steps should contribute significant logs instead of this 1156 # hack, which returns every log from every step. The logs should get 1157 # names like "compile" and "test" instead of "compile.output" 1158 logs = [] 1159 for s in self.steps: 1160 for log in s.getLogs(): 1161 logs.append(log) 1162 return logs
1163 1164 # subscription interface 1165
1166 - def subscribe(self, receiver, updateInterval=None):
1167 # will receive stepStarted and stepFinished messages 1168 # and maybe buildETAUpdate 1169 self.watchers.append(receiver) 1170 if updateInterval is not None: 1171 self.sendETAUpdate(receiver, updateInterval)
1172
1173 - def sendETAUpdate(self, receiver, updateInterval):
1174 self.updates[receiver] = None 1175 ETA = self.getETA() 1176 if ETA is not None: 1177 receiver.buildETAUpdate(self, self.getETA()) 1178 # they might have unsubscribed during buildETAUpdate 1179 if receiver in self.watchers: 1180 self.updates[receiver] = reactor.callLater(updateInterval, 1181 self.sendETAUpdate, 1182 receiver, 1183 updateInterval)
1184
1185 - def unsubscribe(self, receiver):
1186 if receiver in self.watchers: 1187 self.watchers.remove(receiver) 1188 if receiver in self.updates: 1189 if self.updates[receiver] is not None: 1190 self.updates[receiver].cancel() 1191 del self.updates[receiver]
1192 1193 # methods for the base.Build to invoke 1194
1195 - def addStepWithName(self, name):
1196 """The Build is setting up, and has added a new BuildStep to its 1197 list. Create a BuildStepStatus object to which it can send status 1198 updates.""" 1199 1200 s = BuildStepStatus(self) 1201 s.setName(name) 1202 self.steps.append(s) 1203 return s
1204
1205 - def setProperty(self, propname, value, source):
1206 self.properties.setProperty(propname, value, source)
1207
1208 - def addTestResult(self, result):
1209 self.testResults[result.getName()] = result
1210
1211 - def setSourceStamp(self, sourceStamp):
1212 self.source = sourceStamp 1213 self.changes = self.source.changes
1214
1215 - def setRequests(self, requests):
1216 self.requests = requests
1217
1218 - def setReason(self, reason):
1219 self.reason = reason
1220 - def setBlamelist(self, blamelist):
1221 self.blamelist = blamelist
1222 - def setProgress(self, progress):
1223 self.progress = progress
1224
1225 - def buildStarted(self, build):
1226 """The Build has been set up and is about to be started. It can now 1227 be safely queried, so it is time to announce the new build.""" 1228 1229 self.started = util.now() 1230 # now that we're ready to report status, let the BuilderStatus tell 1231 # the world about us 1232 self.builder.buildStarted(self)
1233
1234 - def setSlavename(self, slavename):
1235 self.slavename = slavename
1236
1237 - def setText(self, text):
1238 assert isinstance(text, (list, tuple)) 1239 self.text = text
1240 - def setResults(self, results):
1241 self.results = results
1242
1243 - def buildFinished(self):
1244 self.currentStep = None 1245 self.finished = util.now() 1246 1247 for r in self.updates.keys(): 1248 if self.updates[r] is not None: 1249 self.updates[r].cancel() 1250 del self.updates[r] 1251 1252 watchers = self.finishedWatchers 1253 self.finishedWatchers = [] 1254 for w in watchers: 1255 w.callback(self)
1256 1257 # methods called by our BuildStepStatus children 1258
1259 - def stepStarted(self, step):
1260 self.currentStep = step 1261 name = self.getBuilder().getName() 1262 for w in self.watchers: 1263 receiver = w.stepStarted(self, step) 1264 if receiver: 1265 if type(receiver) == type(()): 1266 step.subscribe(receiver[0], receiver[1]) 1267 else: 1268 step.subscribe(receiver) 1269 d = step.waitUntilFinished() 1270 d.addCallback(lambda step: step.unsubscribe(receiver)) 1271 1272 step.waitUntilFinished().addCallback(self._stepFinished)
1273
1274 - def _stepFinished(self, step):
1275 results = step.getResults() 1276 for w in self.watchers: 1277 w.stepFinished(self, step, results)
1278 1279 # methods called by our BuilderStatus parent 1280
1281 - def pruneSteps(self):
1282 # this build is very old: remove the build steps too 1283 self.steps = []
1284 1285 # persistence stuff 1286
1287 - def generateLogfileName(self, stepname, logname):
1288 """Return a filename (relative to the Builder's base directory) where 1289 the logfile's contents can be stored uniquely. 1290 1291 The base filename is made by combining our build number, the Step's 1292 name, and the log's name, then removing unsuitable characters. The 1293 filename is then made unique by appending _0, _1, etc, until it does 1294 not collide with any other logfile. 1295 1296 These files are kept in the Builder's basedir (rather than a 1297 per-Build subdirectory) because that makes cleanup easier: cron and 1298 find will help get rid of the old logs, but the empty directories are 1299 more of a hassle to remove.""" 1300 1301 starting_filename = "%d-log-%s-%s" % (self.number, stepname, logname) 1302 starting_filename = re.sub(r'[^\w\.\-]', '_', starting_filename) 1303 # now make it unique 1304 unique_counter = 0 1305 filename = starting_filename 1306 while filename in [l.filename 1307 for step in self.steps 1308 for l in step.getLogs() 1309 if l.filename]: 1310 filename = "%s_%d" % (starting_filename, unique_counter) 1311 unique_counter += 1 1312 return filename
1313
1314 - def __getstate__(self):
1315 d = styles.Versioned.__getstate__(self) 1316 # for now, a serialized Build is always "finished". We will never 1317 # save unfinished builds. 1318 if not self.finished: 1319 d['finished'] = True 1320 # TODO: push an "interrupted" step so it is clear that the build 1321 # was interrupted. The builder will have a 'shutdown' event, but 1322 # someone looking at just this build will be confused as to why 1323 # the last log is truncated. 1324 for k in 'builder', 'watchers', 'updates', 'requests', 'finishedWatchers': 1325 if k in d: del d[k] 1326 return d
1327
1328 - def __setstate__(self, d):
1329 styles.Versioned.__setstate__(self, d) 1330 # self.builder must be filled in by our parent when loading 1331 for step in self.steps: 1332 step.build = self 1333 self.watchers = [] 1334 self.updates = {} 1335 self.finishedWatchers = []
1336
1337 - def upgradeToVersion1(self):
1338 if hasattr(self, "sourceStamp"): 1339 # the old .sourceStamp attribute wasn't actually very useful 1340 maxChangeNumber, patch = self.sourceStamp 1341 changes = getattr(self, 'changes', []) 1342 source = sourcestamp.SourceStamp(branch=None, 1343 revision=None, 1344 patch=patch, 1345 changes=changes) 1346 self.source = source 1347 self.changes = source.changes 1348 del self.sourceStamp
1349
1350 - def upgradeToVersion2(self):
1351 self.properties = {}
1352
1353 - def upgradeToVersion3(self):
1354 # in version 3, self.properties became a Properties object 1355 propdict = self.properties 1356 self.properties = Properties() 1357 self.properties.update(propdict, "Upgrade from previous version")
1358
1359 - def upgradeLogfiles(self):
1360 # upgrade any LogFiles that need it. This must occur after we've been 1361 # attached to our Builder, and after we know about all LogFiles of 1362 # all Steps (to get the filenames right). 1363 assert self.builder 1364 for s in self.steps: 1365 for l in s.getLogs(): 1366 if l.filename: 1367 pass # new-style, log contents are on disk 1368 else: 1369 logfilename = self.generateLogfileName(s.name, l.name) 1370 # let the logfile update its .filename pointer, 1371 # transferring its contents onto disk if necessary 1372 l.upgrade(logfilename)
1373
1374 - def checkLogfiles(self):
1375 # check that all logfiles exist, and remove references to any that 1376 # have been deleted (e.g., by purge()) 1377 for s in self.steps: 1378 s.checkLogfiles()
1379
1380 - def saveYourself(self):
1381 filename = os.path.join(self.builder.basedir, "%d" % self.number) 1382 if os.path.isdir(filename): 1383 # leftover from 0.5.0, which stored builds in directories 1384 shutil.rmtree(filename, ignore_errors=True) 1385 tmpfilename = filename + ".tmp" 1386 try: 1387 dump(self, open(tmpfilename, "wb"), -1) 1388 if sys.platform == 'win32': 1389 # windows cannot rename a file on top of an existing one, so 1390 # fall back to delete-first. There are ways this can fail and 1391 # lose the builder's history, so we avoid using it in the 1392 # general (non-windows) case 1393 if os.path.exists(filename): 1394 os.unlink(filename) 1395 os.rename(tmpfilename, filename) 1396 except: 1397 log.msg("unable to save build %s-#%d" % (self.builder.name, 1398 self.number)) 1399 log.err()
1400 1401 1402
1403 -class BuilderStatus(styles.Versioned):
1404 """I handle status information for a single process.base.Builder object. 1405 That object sends status changes to me (frequently as Events), and I 1406 provide them on demand to the various status recipients, like the HTML 1407 waterfall display and the live status clients. It also sends build 1408 summaries to me, which I log and provide to status clients who aren't 1409 interested in seeing details of the individual build steps. 1410 1411 I am responsible for maintaining the list of historic Events and Builds, 1412 pruning old ones, and loading them from / saving them to disk. 1413 1414 I live in the buildbot.process.base.Builder object, in the 1415 .builder_status attribute. 1416 1417 @type category: string 1418 @ivar category: user-defined category this builder belongs to; can be 1419 used to filter on in status clients 1420 """ 1421 1422 implements(interfaces.IBuilderStatus, interfaces.IEventSource) 1423 persistenceVersion = 1 1424 1425 # these limit the amount of memory we consume, as well as the size of the 1426 # main Builder pickle. The Build and LogFile pickles on disk must be 1427 # handled separately. 1428 buildCacheSize = 15 1429 eventHorizon = 50 # forget events beyond this 1430 1431 # these limit on-disk storage 1432 logHorizon = 40 # forget logs in steps in builds beyond this 1433 buildHorizon = 100 # forget builds beyond this 1434 1435 category = None 1436 currentBigState = "offline" # or idle/waiting/interlocked/building 1437 basedir = None # filled in by our parent 1438
1439 - def __init__(self, buildername, category=None):
1440 self.name = buildername 1441 self.category = category 1442 1443 self.slavenames = [] 1444 self.events = [] 1445 # these three hold Events, and are used to retrieve the current 1446 # state of the boxes. 1447 self.lastBuildStatus = None 1448 #self.currentBig = None 1449 #self.currentSmall = None 1450 self.currentBuilds = [] 1451 self.pendingBuilds = [] 1452 self.nextBuild = None 1453 self.watchers = [] 1454 self.buildCache = weakref.WeakValueDictionary() 1455 self.buildCache_LRU = [] 1456 self.logCompressionLimit = False # default to no compression for tests
1457 1458 # persistence 1459
1460 - def __getstate__(self):
1461 # when saving, don't record transient stuff like what builds are 1462 # currently running, because they won't be there when we start back 1463 # up. Nor do we save self.watchers, nor anything that gets set by our 1464 # parent like .basedir and .status 1465 d = styles.Versioned.__getstate__(self) 1466 d['watchers'] = [] 1467 del d['buildCache'] 1468 del d['buildCache_LRU'] 1469 for b in self.currentBuilds: 1470 b.saveYourself() 1471 # TODO: push a 'hey, build was interrupted' event 1472 del d['currentBuilds'] 1473 del d['pendingBuilds'] 1474 del d['currentBigState'] 1475 del d['basedir'] 1476 del d['status'] 1477 del d['nextBuildNumber'] 1478 return d
1479
1480 - def __setstate__(self, d):
1481 # when loading, re-initialize the transient stuff. Remember that 1482 # upgradeToVersion1 and such will be called after this finishes. 1483 styles.Versioned.__setstate__(self, d) 1484 self.buildCache = weakref.WeakValueDictionary() 1485 self.buildCache_LRU = [] 1486 self.currentBuilds = [] 1487 self.pendingBuilds = [] 1488 self.watchers = [] 1489 self.slavenames = []
1490 # self.basedir must be filled in by our parent 1491 # self.status must be filled in by our parent 1492
1493 - def reconfigFromBuildmaster(self, buildmaster):
1494 # Note that we do not hang onto the buildmaster, since this object 1495 # gets pickled and unpickled. 1496 if buildmaster.buildCacheSize: 1497 self.buildCacheSize = buildmaster.buildCacheSize 1498 if buildmaster.eventHorizon: 1499 self.eventHorizon = buildmaster.eventHorizon 1500 if buildmaster.logHorizon: 1501 self.logHorizon = buildmaster.logHorizon 1502 if buildmaster.buildHorizon: 1503 self.buildHorizon = buildmaster.buildHorizon
1504
1505 - def upgradeToVersion1(self):
1506 if hasattr(self, 'slavename'): 1507 self.slavenames = [self.slavename] 1508 del self.slavename 1509 if hasattr(self, 'nextBuildNumber'): 1510 del self.nextBuildNumber # determineNextBuildNumber chooses this
1511
1512 - def determineNextBuildNumber(self):
1513 """Scan our directory of saved BuildStatus instances to determine 1514 what our self.nextBuildNumber should be. Set it one larger than the 1515 highest-numbered build we discover. This is called by the top-level 1516 Status object shortly after we are created or loaded from disk. 1517 """ 1518 existing_builds = [int(f) 1519 for f in os.listdir(self.basedir) 1520 if re.match("^\d+$", f)] 1521 if existing_builds: 1522 self.nextBuildNumber = max(existing_builds) + 1 1523 else: 1524 self.nextBuildNumber = 0
1525
1526 - def setLogCompressionLimit(self, lowerLimit):
1527 self.logCompressionLimit = lowerLimit
1528
1529 - def saveYourself(self):
1530 for b in self.currentBuilds: 1531 if not b.isFinished: 1532 # interrupted build, need to save it anyway. 1533 # BuildStatus.saveYourself will mark it as interrupted. 1534 b.saveYourself() 1535 filename = os.path.join(self.basedir, "builder") 1536 tmpfilename = filename + ".tmp" 1537 try: 1538 dump(self, open(tmpfilename, "wb"), -1) 1539 if sys.platform == 'win32': 1540 # windows cannot rename a file on top of an existing one 1541 if os.path.exists(filename): 1542 os.unlink(filename) 1543 os.rename(tmpfilename, filename) 1544 except: 1545 log.msg("unable to save builder %s" % self.name) 1546 log.err()
1547 1548 1549 # build cache management 1550
1551 - def makeBuildFilename(self, number):
1552 return os.path.join(self.basedir, "%d" % number)
1553
1554 - def touchBuildCache(self, build):
1555 self.buildCache[build.number] = build 1556 if build in self.buildCache_LRU: 1557 self.buildCache_LRU.remove(build) 1558 self.buildCache_LRU = self.buildCache_LRU[-(self.buildCacheSize-1):] + [ build ] 1559 return build
1560
1561 - def getBuildByNumber(self, number):
1562 # first look in currentBuilds 1563 for b in self.currentBuilds: 1564 if b.number == number: 1565 return self.touchBuildCache(b) 1566 1567 # then in the buildCache 1568 if number in self.buildCache: 1569 return self.touchBuildCache(self.buildCache[number]) 1570 1571 # then fall back to loading it from disk 1572 filename = self.makeBuildFilename(number) 1573 try: 1574 log.msg("Loading builder %s's build %d from on-disk pickle" 1575 % (self.name, number)) 1576 build = load(open(filename, "rb")) 1577 styles.doUpgrade() 1578 build.builder = self 1579 # handle LogFiles from after 0.5.0 and before 0.6.5 1580 build.upgradeLogfiles() 1581 # check that logfiles exist 1582 build.checkLogfiles() 1583 return self.touchBuildCache(build) 1584 except IOError: 1585 raise IndexError("no such build %d" % number) 1586 except EOFError: 1587 raise IndexError("corrupted build pickle %d" % number)
1588
1589 - def prune(self):
1590 gc.collect() 1591 1592 # begin by pruning our own events 1593 self.events = self.events[-self.eventHorizon:] 1594 1595 # get the horizons straight 1596 if self.buildHorizon: 1597 earliest_build = self.nextBuildNumber - self.buildHorizon 1598 else: 1599 earliest_build = 0 1600 1601 if self.logHorizon: 1602 earliest_log = self.nextBuildNumber - self.logHorizon 1603 else: 1604 earliest_log = 0 1605 1606 if earliest_log < earliest_build: 1607 earliest_log = earliest_build 1608 1609 if earliest_build == 0: 1610 return 1611 1612 # skim the directory and delete anything that shouldn't be there anymore 1613 build_re = re.compile(r"^([0-9]+)$") 1614 build_log_re = re.compile(r"^([0-9]+)-.*$") 1615 for filename in os.listdir(self.basedir): 1616 num = None 1617 mo = build_re.match(filename) 1618 is_logfile = False 1619 if mo: 1620 num = int(mo.group(1)) 1621 else: 1622 mo = build_log_re.match(filename) 1623 if mo: 1624 num = int(mo.group(1)) 1625 is_logfile = True 1626 1627 if num is None: continue 1628 if num in self.buildCache: continue 1629 1630 if (is_logfile and num < earliest_log) or num < earliest_build: 1631 pathname = os.path.join(self.basedir, filename) 1632 log.msg("pruning '%s'" % pathname) 1633 try: os.unlink(pathname) 1634 except OSError: pass
1635 1636 # IBuilderStatus methods
1637 - def getName(self):
1638 return self.name
1639
1640 - def getState(self):
1641 return (self.currentBigState, self.currentBuilds)
1642
1643 - def getSlaves(self):
1644 return [self.status.getSlave(name) for name in self.slavenames]
1645
1646 - def getPendingBuilds(self):
1647 return self.pendingBuilds
1648
1649 - def getCurrentBuilds(self):
1650 return self.currentBuilds
1651
1652 - def getLastFinishedBuild(self):
1653 b = self.getBuild(-1) 1654 if not (b and b.isFinished()): 1655 b = self.getBuild(-2) 1656 return b
1657
1658 - def getCategory(self):
1659 return self.category
1660
1661 - def getBuild(self, number):
1662 if number < 0: 1663 number = self.nextBuildNumber + number 1664 if number < 0 or number >= self.nextBuildNumber: 1665 return None 1666 1667 try: 1668 return self.getBuildByNumber(number) 1669 except IndexError: 1670 return None
1671
1672 - def getEvent(self, number):
1673 try: 1674 return self.events[number] 1675 except IndexError: 1676 return None
1677
1678 - def generateFinishedBuilds(self, branches=[], 1679 num_builds=None, 1680 max_buildnum=None, 1681 finished_before=None, 1682 max_search=200):
1683 got = 0 1684 for Nb in itertools.count(1): 1685 if Nb > self.nextBuildNumber: 1686 break 1687 if Nb > max_search: 1688 break 1689 build = self.getBuild(-Nb) 1690 if build is None: 1691 continue 1692 if max_buildnum is not None: 1693 if build.getNumber() > max_buildnum: 1694 continue 1695 if not build.isFinished(): 1696 continue 1697 if finished_before is not None: 1698 start, end = build.getTimes() 1699 if end >= finished_before: 1700 continue 1701 if branches: 1702 if build.getSourceStamp().branch not in branches: 1703 continue 1704 got += 1 1705 yield build 1706 if num_builds is not None: 1707 if got >= num_builds: 1708 return
1709
1710 - def eventGenerator(self, branches=[], categories=[]):
1711 """This function creates a generator which will provide all of this 1712 Builder's status events, starting with the most recent and 1713 progressing backwards in time. """ 1714 1715 # remember the oldest-to-earliest flow here. "next" means earlier. 1716 1717 # TODO: interleave build steps and self.events by timestamp. 1718 # TODO: um, I think we're already doing that. 1719 1720 # TODO: there's probably something clever we could do here to 1721 # interleave two event streams (one from self.getBuild and the other 1722 # from self.getEvent), which would be simpler than this control flow 1723 1724 eventIndex = -1 1725 e = self.getEvent(eventIndex) 1726 for Nb in range(1, self.nextBuildNumber+1): 1727 b = self.getBuild(-Nb) 1728 if not b: 1729 break 1730 if branches and not b.getSourceStamp().branch in branches: 1731 continue 1732 if categories and not b.getBuilder().getCategory() in categories: 1733 continue 1734 steps = b.getSteps() 1735 for Ns in range(1, len(steps)+1): 1736 if steps[-Ns].started: 1737 step_start = steps[-Ns].getTimes()[0] 1738 while e is not None and e.getTimes()[0] > step_start: 1739 yield e 1740 eventIndex -= 1 1741 e = self.getEvent(eventIndex) 1742 yield steps[-Ns] 1743 yield b 1744 while e is not None: 1745 yield e 1746 eventIndex -= 1 1747 e = self.getEvent(eventIndex)
1748
1749 - def subscribe(self, receiver):
1750 # will get builderChangedState, buildStarted, and buildFinished 1751 self.watchers.append(receiver) 1752 self.publishState(receiver)
1753
1754 - def unsubscribe(self, receiver):
1755 self.watchers.remove(receiver)
1756 1757 ## Builder interface (methods called by the Builder which feeds us) 1758
1759 - def setSlavenames(self, names):
1760 self.slavenames = names
1761
1762 - def addEvent(self, text=[]):
1763 # this adds a duration event. When it is done, the user should call 1764 # e.finish(). They can also mangle it by modifying .text 1765 e = Event() 1766 e.started = util.now() 1767 e.text = text 1768 self.events.append(e) 1769 return e # they are free to mangle it further
1770
1771 - def addPointEvent(self, text=[]):
1772 # this adds a point event, one which occurs as a single atomic 1773 # instant of time. 1774 e = Event() 1775 e.started = util.now() 1776 e.finished = 0 1777 e.text = text 1778 self.events.append(e) 1779 return e # for consistency, but they really shouldn't touch it
1780
1781 - def setBigState(self, state):
1782 needToUpdate = state != self.currentBigState 1783 self.currentBigState = state 1784 if needToUpdate: 1785 self.publishState()
1786
1787 - def publishState(self, target=None):
1788 state = self.currentBigState 1789 1790 if target is not None: 1791 # unicast 1792 target.builderChangedState(self.name, state) 1793 return 1794 for w in self.watchers: 1795 try: 1796 w.builderChangedState(self.name, state) 1797 except: 1798 log.msg("Exception caught publishing state to %r" % w) 1799 log.err()
1800
1801 - def newBuild(self):
1802 """The Builder has decided to start a build, but the Build object is 1803 not yet ready to report status (it has not finished creating the 1804 Steps). Create a BuildStatus object that it can use.""" 1805 number = self.nextBuildNumber 1806 self.nextBuildNumber += 1 1807 # TODO: self.saveYourself(), to make sure we don't forget about the 1808 # build number we've just allocated. This is not quite as important 1809 # as it was before we switch to determineNextBuildNumber, but I think 1810 # it may still be useful to have the new build save itself. 1811 s = BuildStatus(self, number) 1812 s.waitUntilFinished().addCallback(self._buildFinished) 1813 return s
1814
1815 - def addBuildRequest(self, brstatus):
1816 self.pendingBuilds.append(brstatus) 1817 for w in self.watchers: 1818 w.requestSubmitted(brstatus)
1819
1820 - def removeBuildRequest(self, brstatus, cancelled=False):
1821 self.pendingBuilds.remove(brstatus) 1822 if cancelled: 1823 for w in self.watchers: 1824 w.requestCancelled(self, brstatus)
1825 1826 # buildStarted is called by our child BuildStatus instances
1827 - def buildStarted(self, s):
1828 """Now the BuildStatus object is ready to go (it knows all of its 1829 Steps, its ETA, etc), so it is safe to notify our watchers.""" 1830 1831 assert s.builder is self # paranoia 1832 assert s.number == self.nextBuildNumber - 1 1833 assert s not in self.currentBuilds 1834 self.currentBuilds.append(s) 1835 self.touchBuildCache(s) 1836 1837 # now that the BuildStatus is prepared to answer queries, we can 1838 # announce the new build to all our watchers 1839 1840 for w in self.watchers: # TODO: maybe do this later? callLater(0)? 1841 try: 1842 receiver = w.buildStarted(self.getName(), s) 1843 if receiver: 1844 if type(receiver) == type(()): 1845 s.subscribe(receiver[0], receiver[1]) 1846 else: 1847 s.subscribe(receiver) 1848 d = s.waitUntilFinished() 1849 d.addCallback(lambda s: s.unsubscribe(receiver)) 1850 except: 1851 log.msg("Exception caught notifying %r of buildStarted event" % w) 1852 log.err()
1853
1854 - def _buildFinished(self, s):
1855 assert s in self.currentBuilds 1856 s.saveYourself() 1857 self.currentBuilds.remove(s) 1858 1859 name = self.getName() 1860 results = s.getResults() 1861 for w in self.watchers: 1862 try: 1863 w.buildFinished(name, s, results) 1864 except: 1865 log.msg("Exception caught notifying %r of buildFinished event" % w) 1866 log.err() 1867 1868 self.prune() # conserve disk
1869 1870 1871 # waterfall display (history) 1872 1873 # I want some kind of build event that holds everything about the build: 1874 # why, what changes went into it, the results of the build, itemized 1875 # test results, etc. But, I do kind of need something to be inserted in 1876 # the event log first, because intermixing step events and the larger 1877 # build event is fraught with peril. Maybe an Event-like-thing that 1878 # doesn't have a file in it but does have links. Hmm, that's exactly 1879 # what it does now. The only difference would be that this event isn't 1880 # pushed to the clients. 1881 1882 # publish to clients
1883 - def sendLastBuildStatus(self, client):
1884 #client.newLastBuildStatus(self.lastBuildStatus) 1885 pass
1887 for s in self.subscribers: 1888 self.sendCurrentActivityBig(s)
1889 - def sendCurrentActivityBig(self, client):
1890 state = self.currentBigState 1891 if state == "offline": 1892 client.currentlyOffline() 1893 elif state == "idle": 1894 client.currentlyIdle() 1895 elif state == "building": 1896 client.currentlyBuilding() 1897 else: 1898 log.msg("Hey, self.currentBigState is weird:", state)
1899 1900 1901 ## HTML display interface 1902
1903 - def getEventNumbered(self, num):
1904 # deal with dropped events, pruned events 1905 first = self.events[0].number 1906 if first + len(self.events)-1 != self.events[-1].number: 1907 log.msg(self, 1908 "lost an event somewhere: [0] is %d, [%d] is %d" % \ 1909 (self.events[0].number, 1910 len(self.events) - 1, 1911 self.events[-1].number)) 1912 for e in self.events: 1913 log.msg("e[%d]: " % e.number, e) 1914 return None 1915 offset = num - first 1916 log.msg(self, "offset", offset) 1917 try: 1918 return self.events[offset] 1919 except IndexError: 1920 return None
1921 1922 ## Persistence of Status
1923 - def loadYourOldEvents(self):
1924 if hasattr(self, "allEvents"): 1925 # first time, nothing to get from file. Note that this is only if 1926 # the Application gets .run() . If it gets .save()'ed, then the 1927 # .allEvents attribute goes away in the initial __getstate__ and 1928 # we try to load a non-existent file. 1929 return 1930 self.allEvents = self.loadFile("events", []) 1931 if self.allEvents: 1932 self.nextEventNumber = self.allEvents[-1].number + 1 1933 else: 1934 self.nextEventNumber = 0
1935 - def saveYourOldEvents(self):
1936 self.saveFile("events", self.allEvents)
1937 1938 ## clients 1939
1940 - def addClient(self, client):
1941 if client not in self.subscribers: 1942 self.subscribers.append(client) 1943 self.sendLastBuildStatus(client) 1944 self.sendCurrentActivityBig(client) 1945 client.newEvent(self.currentSmall)
1946 - def removeClient(self, client):
1947 if client in self.subscribers: 1948 self.subscribers.remove(client)
1949
1950 -class SlaveStatus:
1951 implements(interfaces.ISlaveStatus) 1952 1953 admin = None 1954 host = None 1955 connected = False 1956 graceful_shutdown = False 1957
1958 - def __init__(self, name):
1959 self.name = name 1960 self._lastMessageReceived = 0 1961 self.runningBuilds = [] 1962 self.graceful_callbacks = []
1963
1964 - def getName(self):
1965 return self.name
1966 - def getAdmin(self):
1967 return self.admin
1968 - def getHost(self):
1969 return self.host
1970 - def isConnected(self):
1971 return self.connected
1972 - def lastMessageReceived(self):
1973 return self._lastMessageReceived
1974 - def getRunningBuilds(self):
1975 return self.runningBuilds
1976
1977 - def setAdmin(self, admin):
1978 self.admin = admin
1979 - def setHost(self, host):
1980 self.host = host
1981 - def setConnected(self, isConnected):
1982 self.connected = isConnected
1983 - def setLastMessageReceived(self, when):
1984 self._lastMessageReceived = when
1985
1986 - def buildStarted(self, build):
1987 self.runningBuilds.append(build)
1988 - def buildFinished(self, build):
1989 self.runningBuilds.remove(build)
1990
1991 - def getGraceful(self):
1992 """Return the graceful shutdown flag""" 1993 return self.graceful_shutdown
1994 - def setGraceful(self, graceful):
1995 """Set the graceful shutdown flag, and notify all the watchers""" 1996 self.graceful_shutdown = graceful 1997 for cb in self.graceful_callbacks: 1998 reactor.callLater(0, cb, graceful)
1999 - def addGracefulWatcher(self, watcher):
2000 """Add watcher to the list of watchers to be notified when the 2001 graceful shutdown flag is changed.""" 2002 if not watcher in self.graceful_callbacks: 2003 self.graceful_callbacks.append(watcher)
2004 - def removeGracefulWatcher(self, watcher):
2005 """Remove watcher from the list of watchers to be notified when the 2006 graceful shutdown flag is changed.""" 2007 if watcher in self.graceful_callbacks: 2008 self.graceful_callbacks.remove(watcher)
2009
2010 -class Status:
2011 """ 2012 I represent the status of the buildmaster. 2013 """ 2014 implements(interfaces.IStatus) 2015
2016 - def __init__(self, botmaster, basedir):
2017 """ 2018 @type botmaster: L{buildbot.master.BotMaster} 2019 @param botmaster: the Status object uses C{.botmaster} to get at 2020 both the L{buildbot.master.BuildMaster} (for 2021 various buildbot-wide parameters) and the 2022 actual Builders (to get at their L{BuilderStatus} 2023 objects). It is not allowed to change or influence 2024 anything through this reference. 2025 @type basedir: string 2026 @param basedir: this provides a base directory in which saved status 2027 information (changes.pck, saved Build status 2028 pickles) can be stored 2029 """ 2030 self.botmaster = botmaster 2031 self.basedir = basedir 2032 self.watchers = [] 2033 self.activeBuildSets = [] 2034 assert os.path.isdir(basedir) 2035 # compress logs bigger than 4k, a good default on linux 2036 self.logCompressionLimit = 4*1024
2037 2038 2039 # methods called by our clients 2040
2041 - def getProjectName(self):
2042 return self.botmaster.parent.projectName
2043 - def getProjectURL(self):
2044 return self.botmaster.parent.projectURL
2045 - def getBuildbotURL(self):
2046 return self.botmaster.parent.buildbotURL
2047
2048 - def getURLForThing(self, thing):
2049 prefix = self.getBuildbotURL() 2050 if not prefix: 2051 return None 2052 if interfaces.IStatus.providedBy(thing): 2053 return prefix 2054 if interfaces.ISchedulerStatus.providedBy(thing): 2055 pass 2056 if interfaces.IBuilderStatus.providedBy(thing): 2057 builder = thing 2058 return prefix + "builders/%s" % ( 2059 urllib.quote(builder.getName(), safe=''), 2060 ) 2061 if interfaces.IBuildStatus.providedBy(thing): 2062 build = thing 2063 builder = build.getBuilder() 2064 return prefix + "builders/%s/builds/%d" % ( 2065 urllib.quote(builder.getName(), safe=''), 2066 build.getNumber()) 2067 if interfaces.IBuildStepStatus.providedBy(thing): 2068 step = thing 2069 build = step.getBuild() 2070 builder = build.getBuilder() 2071 return prefix + "builders/%s/builds/%d/steps/%s" % ( 2072 urllib.quote(builder.getName(), safe=''), 2073 build.getNumber(), 2074 urllib.quote(step.getName(), safe='')) 2075 # IBuildSetStatus 2076 # IBuildRequestStatus 2077 # ISlaveStatus 2078 2079 # IStatusEvent 2080 if interfaces.IStatusEvent.providedBy(thing): 2081 from buildbot.changes import changes 2082 # TODO: this is goofy, create IChange or something 2083 if isinstance(thing, changes.Change): 2084 change = thing 2085 return "%schanges/%d" % (prefix, change.number) 2086 2087 if interfaces.IStatusLog.providedBy(thing): 2088 log = thing 2089 step = log.getStep() 2090 build = step.getBuild() 2091 builder = build.getBuilder() 2092 2093 logs = step.getLogs() 2094 for i in range(len(logs)): 2095 if log is logs[i]: 2096 lognum = i 2097 break 2098 else: 2099 return None 2100 return prefix + "builders/%s/builds/%d/steps/%s/logs/%s" % ( 2101 urllib.quote(builder.getName(), safe=''), 2102 build.getNumber(), 2103 urllib.quote(step.getName(), safe=''), 2104 urllib.quote(log.getName()))
2105
2106 - def getChangeSources(self):
2107 return list(self.botmaster.parent.change_svc)
2108
2109 - def getChange(self, number):
2110 return self.botmaster.parent.change_svc.getChangeNumbered(number)
2111
2112 - def getSchedulers(self):
2113 return self.botmaster.parent.allSchedulers()
2114
2115 - def getBuilderNames(self, categories=None):
2116 if categories == None: 2117 return self.botmaster.builderNames[:] # don't let them break it 2118 2119 l = [] 2120 # respect addition order 2121 for name in self.botmaster.builderNames: 2122 builder = self.botmaster.builders[name] 2123 if builder.builder_status.category in categories: 2124 l.append(name) 2125 return l
2126
2127 - def getBuilder(self, name):
2128 """ 2129 @rtype: L{BuilderStatus} 2130 """ 2131 return self.botmaster.builders[name].builder_status
2132
2133 - def getSlaveNames(self):
2134 return self.botmaster.slaves.keys()
2135
2136 - def getSlave(self, slavename):
2137 return self.botmaster.slaves[slavename].slave_status
2138
2139 - def getBuildSets(self):
2140 return self.activeBuildSets[:]
2141
2142 - def generateFinishedBuilds(self, builders=[], branches=[], 2143 num_builds=None, finished_before=None, 2144 max_search=200):
2145 2146 def want_builder(bn): 2147 if builders: 2148 return bn in builders 2149 return True
2150 builder_names = [bn 2151 for bn in self.getBuilderNames() 2152 if want_builder(bn)] 2153 2154 # 'sources' is a list of generators, one for each Builder we're 2155 # using. When the generator is exhausted, it is replaced in this list 2156 # with None. 2157 sources = [] 2158 for bn in builder_names: 2159 b = self.getBuilder(bn) 2160 g = b.generateFinishedBuilds(branches, 2161 finished_before=finished_before, 2162 max_search=max_search) 2163 sources.append(g) 2164 2165 # next_build the next build from each source 2166 next_build = [None] * len(sources) 2167 2168 def refill(): 2169 for i,g in enumerate(sources): 2170 if next_build[i]: 2171 # already filled 2172 continue 2173 if not g: 2174 # already exhausted 2175 continue 2176 try: 2177 next_build[i] = g.next() 2178 except StopIteration: 2179 next_build[i] = None 2180 sources[i] = None
2181 2182 got = 0 2183 while True: 2184 refill() 2185 # find the latest build among all the candidates 2186 candidates = [(i, b, b.getTimes()[1]) 2187 for i,b in enumerate(next_build) 2188 if b is not None] 2189 candidates.sort(lambda x,y: cmp(x[2], y[2])) 2190 if not candidates: 2191 return 2192 2193 # and remove it from the list 2194 i, build, finshed_time = candidates[-1] 2195 next_build[i] = None 2196 got += 1 2197 yield build 2198 if num_builds is not None: 2199 if got >= num_builds: 2200 return 2201
2202 - def subscribe(self, target):
2203 self.watchers.append(target) 2204 for name in self.botmaster.builderNames: 2205 self.announceNewBuilder(target, name, self.getBuilder(name))
2206 - def unsubscribe(self, target):
2207 self.watchers.remove(target)
2208 2209 2210 # methods called by upstream objects 2211
2212 - def announceNewBuilder(self, target, name, builder_status):
2213 t = target.builderAdded(name, builder_status) 2214 if t: 2215 builder_status.subscribe(t)
2216
2217 - def builderAdded(self, name, basedir, category=None):
2218 """ 2219 @rtype: L{BuilderStatus} 2220 """ 2221 filename = os.path.join(self.basedir, basedir, "builder") 2222 log.msg("trying to load status pickle from %s" % filename) 2223 builder_status = None 2224 try: 2225 builder_status = load(open(filename, "rb")) 2226 styles.doUpgrade() 2227 except IOError: 2228 log.msg("no saved status pickle, creating a new one") 2229 except: 2230 log.msg("error while loading status pickle, creating a new one") 2231 log.msg("error follows:") 2232 log.err() 2233 if not builder_status: 2234 builder_status = BuilderStatus(name, category) 2235 builder_status.addPointEvent(["builder", "created"]) 2236 log.msg("added builder %s in category %s" % (name, category)) 2237 # an unpickled object might not have category set from before, 2238 # so set it here to make sure 2239 builder_status.category = category 2240 builder_status.basedir = os.path.join(self.basedir, basedir) 2241 builder_status.name = name # it might have been updated 2242 builder_status.status = self 2243 2244 if not os.path.isdir(builder_status.basedir): 2245 os.makedirs(builder_status.basedir) 2246 builder_status.determineNextBuildNumber() 2247 2248 builder_status.setBigState("offline") 2249 builder_status.setLogCompressionLimit(self.logCompressionLimit) 2250 2251 for t in self.watchers: 2252 self.announceNewBuilder(t, name, builder_status) 2253 2254 return builder_status
2255
2256 - def builderRemoved(self, name):
2257 for t in self.watchers: 2258 t.builderRemoved(name)
2259
2260 - def buildsetSubmitted(self, bss):
2261 self.activeBuildSets.append(bss) 2262 bss.waitUntilFinished().addCallback(self.activeBuildSets.remove) 2263 for t in self.watchers: 2264 t.buildsetSubmitted(bss)
2265