| | 1 | import time |
|---|
| | 2 | from calendar import timegm |
|---|
| | 3 | from urllib2 import urlopen |
|---|
| | 4 | from xml.dom import minidom, Node |
|---|
| | 5 | |
|---|
| | 6 | from twisted.python import log, failure |
|---|
| | 7 | from twisted.internet import defer, reactor |
|---|
| | 8 | from twisted.internet.task import LoopingCall |
|---|
| | 9 | |
|---|
| | 10 | from buildbot.changes import base, changes |
|---|
| | 11 | |
|---|
| | 12 | |
|---|
| | 13 | # From pyiso8601 module, |
|---|
| | 14 | # http://code.google.com/p/pyiso8601/source/browse/trunk/iso8601/iso8601.py |
|---|
| | 15 | # Revision 22 |
|---|
| | 16 | |
|---|
| | 17 | # Required license header: |
|---|
| | 18 | |
|---|
| | 19 | # Copyright (c) 2007 Michael Twomey |
|---|
| | 20 | # |
|---|
| | 21 | # Permission is hereby granted, free of charge, to any person obtaining a |
|---|
| | 22 | # copy of this software and associated documentation files (the |
|---|
| | 23 | # "Software"), to deal in the Software without restriction, including |
|---|
| | 24 | # without limitation the rights to use, copy, modify, merge, publish, |
|---|
| | 25 | # distribute, sublicense, and/or sell copies of the Software, and to |
|---|
| | 26 | # permit persons to whom the Software is furnished to do so, subject to |
|---|
| | 27 | # the following conditions: |
|---|
| | 28 | # |
|---|
| | 29 | # The above copyright notice and this permission notice shall be included |
|---|
| | 30 | # in all copies or substantial portions of the Software. |
|---|
| | 31 | # |
|---|
| | 32 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS |
|---|
| | 33 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|---|
| | 34 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. |
|---|
| | 35 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY |
|---|
| | 36 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, |
|---|
| | 37 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE |
|---|
| | 38 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|---|
| | 39 | |
|---|
| | 40 | """ISO 8601 date time string parsing |
|---|
| | 41 | |
|---|
| | 42 | Basic usage: |
|---|
| | 43 | >>> import iso8601 |
|---|
| | 44 | >>> iso8601.parse_date("2007-01-25T12:00:00Z") |
|---|
| | 45 | datetime.datetime(2007, 1, 25, 12, 0, tzinfo=<iso8601.iso8601.Utc ...>) |
|---|
| | 46 | >>> |
|---|
| | 47 | |
|---|
| | 48 | """ |
|---|
| | 49 | |
|---|
| | 50 | from datetime import datetime, timedelta, tzinfo |
|---|
| | 51 | import re |
|---|
| | 52 | |
|---|
| | 53 | __all__ = ["parse_date", "ParseError"] |
|---|
| | 54 | |
|---|
| | 55 | # Adapted from http://delete.me.uk/2005/03/iso8601.html |
|---|
| | 56 | ISO8601_REGEX = re.compile(r"(?P<year>[0-9]{4})(-(?P<month>[0-9]{1,2})(-(?P<day>[0-9]{1,2})" |
|---|
| | 57 | r"((?P<separator>.)(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2})(:(?P<second>[0-9]{2})(\.(?P<fraction>[0-9]+))?)?" |
|---|
| | 58 | r"(?P<timezone>Z|(([-+])([0-9]{2}):([0-9]{2})))?)?)?)?" |
|---|
| | 59 | ) |
|---|
| | 60 | TIMEZONE_REGEX = re.compile("(?P<prefix>[+-])(?P<hours>[0-9]{2}).(?P<minutes>[0-9]{2})") |
|---|
| | 61 | |
|---|
| | 62 | class ParseError(Exception): |
|---|
| | 63 | """Raised when there is a problem parsing a date string""" |
|---|
| | 64 | |
|---|
| | 65 | # Yoinked from python docs |
|---|
| | 66 | ZERO = timedelta(0) |
|---|
| | 67 | class Utc(tzinfo): |
|---|
| | 68 | """UTC |
|---|
| | 69 | |
|---|
| | 70 | """ |
|---|
| | 71 | def utcoffset(self, dt): |
|---|
| | 72 | return ZERO |
|---|
| | 73 | |
|---|
| | 74 | def tzname(self, dt): |
|---|
| | 75 | return "UTC" |
|---|
| | 76 | |
|---|
| | 77 | def dst(self, dt): |
|---|
| | 78 | return ZERO |
|---|
| | 79 | UTC = Utc() |
|---|
| | 80 | |
|---|
| | 81 | class FixedOffset(tzinfo): |
|---|
| | 82 | """Fixed offset in hours and minutes from UTC |
|---|
| | 83 | |
|---|
| | 84 | """ |
|---|
| | 85 | def __init__(self, offset_hours, offset_minutes, name): |
|---|
| | 86 | self.__offset = timedelta(hours=offset_hours, minutes=offset_minutes) |
|---|
| | 87 | self.__name = name |
|---|
| | 88 | |
|---|
| | 89 | def utcoffset(self, dt): |
|---|
| | 90 | return self.__offset |
|---|
| | 91 | |
|---|
| | 92 | def tzname(self, dt): |
|---|
| | 93 | return self.__name |
|---|
| | 94 | |
|---|
| | 95 | def dst(self, dt): |
|---|
| | 96 | return ZERO |
|---|
| | 97 | |
|---|
| | 98 | def __repr__(self): |
|---|
| | 99 | return "<FixedOffset %r>" % self.__name |
|---|
| | 100 | |
|---|
| | 101 | def parse_timezone(tzstring, default_timezone=UTC): |
|---|
| | 102 | """Parses ISO 8601 time zone specs into tzinfo offsets |
|---|
| | 103 | |
|---|
| | 104 | """ |
|---|
| | 105 | if tzstring == "Z": |
|---|
| | 106 | return default_timezone |
|---|
| | 107 | # This isn't strictly correct, but it's common to encounter dates without |
|---|
| | 108 | # timezones so I'll assume the default (which defaults to UTC). |
|---|
| | 109 | # Addresses issue 4. |
|---|
| | 110 | if tzstring is None: |
|---|
| | 111 | return default_timezone |
|---|
| | 112 | m = TIMEZONE_REGEX.match(tzstring) |
|---|
| | 113 | prefix, hours, minutes = m.groups() |
|---|
| | 114 | hours, minutes = int(hours), int(minutes) |
|---|
| | 115 | if prefix == "-": |
|---|
| | 116 | hours = -hours |
|---|
| | 117 | minutes = -minutes |
|---|
| | 118 | return FixedOffset(hours, minutes, tzstring) |
|---|
| | 119 | |
|---|
| | 120 | def parse_date(datestring, default_timezone=UTC): |
|---|
| | 121 | """Parses ISO 8601 dates into datetime objects |
|---|
| | 122 | |
|---|
| | 123 | The timezone is parsed from the date string. However it is quite common to |
|---|
| | 124 | have dates without a timezone (not strictly correct). In this case the |
|---|
| | 125 | default timezone specified in default_timezone is used. This is UTC by |
|---|
| | 126 | default. |
|---|
| | 127 | """ |
|---|
| | 128 | if not isinstance(datestring, basestring): |
|---|
| | 129 | raise ParseError("Expecting a string %r" % datestring) |
|---|
| | 130 | m = ISO8601_REGEX.match(datestring) |
|---|
| | 131 | if not m: |
|---|
| | 132 | raise ParseError("Unable to parse date string %r" % datestring) |
|---|
| | 133 | groups = m.groupdict() |
|---|
| | 134 | tz = parse_timezone(groups["timezone"], default_timezone=default_timezone) |
|---|
| | 135 | if groups["fraction"] is None: |
|---|
| | 136 | groups["fraction"] = 0 |
|---|
| | 137 | else: |
|---|
| | 138 | groups["fraction"] = int(float("0.%s" % groups["fraction"]) * 1e6) |
|---|
| | 139 | return datetime(int(groups["year"]), int(groups["month"]), int(groups["day"]), |
|---|
| | 140 | int(groups["hour"]), int(groups["minute"]), int(groups["second"]), |
|---|
| | 141 | int(groups["fraction"]), tz) |
|---|
| | 142 | |
|---|
| | 143 | # End of iso8601.py |
|---|
| | 144 | |
|---|
| | 145 | |
|---|
| | 146 | class HgPoller(base.ChangeSource): |
|---|
| | 147 | """This source will poll a Mercurial server over HTTP using |
|---|
| | 148 | the built-in RSS feed for changes and submit them to the |
|---|
| | 149 | change master.""" |
|---|
| | 150 | |
|---|
| | 151 | compare_attrs = ['hgURL', 'branch', 'pollInterval'] |
|---|
| | 152 | parent = None |
|---|
| | 153 | loop = None |
|---|
| | 154 | volatile = ['loop'] |
|---|
| | 155 | working = False |
|---|
| | 156 | |
|---|
| | 157 | def __init__(self, hgURL, branch, pushlogUrlOverride=None, pollInterval=30): |
|---|
| | 158 | """ |
|---|
| | 159 | @type hgURL: string |
|---|
| | 160 | @param hgURL: The base URL of the Hg repo |
|---|
| | 161 | (e.g. http://hg.mozilla.org/) |
|---|
| | 162 | @type branch: string |
|---|
| | 163 | @param branch: The branch to check (e.g. mozilla-central) |
|---|
| | 164 | @type pollInterval: int |
|---|
| | 165 | @param pollInterval: The time (in seconds) between queries for |
|---|
| | 166 | changes |
|---|
| | 167 | """ |
|---|
| | 168 | |
|---|
| | 169 | self.hgURL = hgURL |
|---|
| | 170 | self.branch = branch |
|---|
| | 171 | self.pushlogUrlOverride = pushlogUrlOverride |
|---|
| | 172 | self.pollInterval = pollInterval |
|---|
| | 173 | self.lastChange = time.time() |
|---|
| | 174 | |
|---|
| | 175 | def startService(self): |
|---|
| | 176 | self.loop = LoopingCall(self.poll) |
|---|
| | 177 | base.ChangeSource.startService(self) |
|---|
| | 178 | reactor.callLater(0, self.loop.start, self.pollInterval) |
|---|
| | 179 | |
|---|
| | 180 | def stopService(self): |
|---|
| | 181 | self.loop.stop() |
|---|
| | 182 | return base.ChangeSource.stopService(self) |
|---|
| | 183 | |
|---|
| | 184 | def describe(self): |
|---|
| | 185 | return "Getting changes from the Mercurial repo at %s%s" % \ |
|---|
| | 186 | (self.hgURL, self.branch) |
|---|
| | 187 | |
|---|
| | 188 | def poll(self): |
|---|
| | 189 | if self.working: |
|---|
| | 190 | log.msg("Not polling because last poll is still working") |
|---|
| | 191 | else: |
|---|
| | 192 | self.working = True |
|---|
| | 193 | d = self._get_changes() |
|---|
| | 194 | d.addCallback(self._process_changes) |
|---|
| | 195 | d.addCallbacks(self._finished_ok, self._finished_failure) |
|---|
| | 196 | |
|---|
| | 197 | def _finished_ok(self, res): |
|---|
| | 198 | assert self.working |
|---|
| | 199 | self.working = False |
|---|
| | 200 | |
|---|
| | 201 | return res |
|---|
| | 202 | |
|---|
| | 203 | def _finished_failure(self, res): |
|---|
| | 204 | log.msg("Hg poll failed: %s" % res) |
|---|
| | 205 | assert self.working |
|---|
| | 206 | self.working = False |
|---|
| | 207 | return None |
|---|
| | 208 | |
|---|
| | 209 | def _make_url(self): |
|---|
| | 210 | if self.pushlogUrlOverride: |
|---|
| | 211 | return self.pushlogUrlOverride |
|---|
| | 212 | else: |
|---|
| | 213 | return "%s%s/pushlog" % (self.hgURL, self.branch) |
|---|
| | 214 | |
|---|
| | 215 | def _get_changes(self): |
|---|
| | 216 | url = self._make_url() |
|---|
| | 217 | log.msg("Polling Hg server at %s" % url) |
|---|
| | 218 | |
|---|
| | 219 | return defer.maybeDeferred(urlopen, url) |
|---|
| | 220 | |
|---|
| | 221 | def _parse_date_string(self, dateString): |
|---|
| | 222 | return timegm((parse_date(dateString).utctimetuple())) |
|---|
| | 223 | |
|---|
| | 224 | def _parse_changes(self, query): |
|---|
| | 225 | dom = minidom.parseString(query.read()) |
|---|
| | 226 | |
|---|
| | 227 | items = dom.getElementsByTagName("entry") |
|---|
| | 228 | changes = [] |
|---|
| | 229 | for i in items: |
|---|
| | 230 | d = {} |
|---|
| | 231 | for k in ["title", "updated"]: |
|---|
| | 232 | d[k] = i.getElementsByTagName(k)[0].firstChild.wholeText |
|---|
| | 233 | d["updated"] = self._parse_date_string(d["updated"]) |
|---|
| | 234 | d["changeset"] = d["title"].split(" ")[1] |
|---|
| | 235 | nameNode = i.getElementsByTagName("author")[0].childNodes[1] |
|---|
| | 236 | d["author"] = nameNode.firstChild.wholeText |
|---|
| | 237 | d["link"] = i.getElementsByTagName("link")[0].getAttribute("href") |
|---|
| | 238 | changes.append(d) |
|---|
| | 239 | changes = [c for c in changes if c["updated"] > self.lastChange] |
|---|
| | 240 | changes.reverse() # want them in chronological order |
|---|
| | 241 | return changes |
|---|
| | 242 | |
|---|
| | 243 | def _process_changes(self, query): |
|---|
| | 244 | change_list = self._parse_changes(query) |
|---|
| | 245 | for change in change_list: |
|---|
| | 246 | adjustedChangeTime = change["updated"] |
|---|
| | 247 | c = changes.Change(who = change["author"], |
|---|
| | 248 | files = [], # sucks |
|---|
| | 249 | revision = change["changeset"], |
|---|
| | 250 | comments = change["link"], |
|---|
| | 251 | when = adjustedChangeTime, |
|---|
| | 252 | branch = self.branch) |
|---|
| | 253 | self.parent.addChange(c) |
|---|
| | 254 | if len(change_list) > 0: |
|---|
| | 255 | self.lastChange = max(self.lastChange, *[c["updated"] for c in |
|---|
| | 256 | change_list]) |
|---|
| | 257 | |