|
15 | 15 |
|
16 | 16 | from ConfigParser import SafeConfigParser |
17 | 17 | from cookielib import LoadError, LWPCookieJar, MozillaCookieJar |
18 | | -from urllib2 import Request, HTTPError, build_opener |
| 18 | +from io import BytesIO |
19 | 19 | from urlparse import urlparse, parse_qsl |
20 | | -from StringIO import StringIO |
21 | 20 | from xmlrpclib import Binary, Fault, ProtocolError, ServerProxy, Transport |
22 | 21 |
|
23 | | -import pycurl |
| 22 | +import requests |
24 | 23 |
|
25 | 24 | from bugzilla import __version__, log |
26 | 25 | from bugzilla.bug import _Bug, _User |
@@ -63,20 +62,6 @@ def _detect_filetype(fname): |
63 | 62 | return None |
64 | 63 |
|
65 | 64 |
|
66 | | -def _decode_rfc2231_value(val): |
67 | | - # BUG WORKAROUND: decode_header doesn't work unless there's whitespace |
68 | | - # around the encoded string (see http://bugs.python.org/issue1079) |
69 | | - from email import utils |
70 | | - from email import header |
71 | | - |
72 | | - # pylint: disable=W1401 |
73 | | - # Anomolous backslash in string |
74 | | - val = utils.ecre.sub(' \g<0> ', val) |
75 | | - val = val.strip('"') |
76 | | - return ''.join(f[0].decode(f[1] or 'us-ascii') |
77 | | - for f in header.decode_header(val)) |
78 | | - |
79 | | - |
80 | 65 | def _build_cookiejar(cookiefile): |
81 | 66 | cj = MozillaCookieJar(cookiefile) |
82 | 67 | if cookiefile is None: |
@@ -110,134 +95,86 @@ def _build_cookiejar(cookiefile): |
110 | 95 | return retcj |
111 | 96 |
|
112 | 97 |
|
113 | | -def _check_http_error(uri, request_body, response_data): |
114 | | - # This pulls some of the guts from urllib to give us HTTP error |
115 | | - # code checking. Wrap it all in try/except incase this breaks in |
116 | | - # the future, it's only for error handling. |
117 | | - try: |
118 | | - import httplib |
119 | | - import urllib |
120 | | - |
121 | | - class FakeSocket(StringIO): |
122 | | - def makefile(self, *args, **kwarg): |
123 | | - ignore = args |
124 | | - ignore = kwarg |
125 | | - return self |
126 | | - |
127 | | - httpresp = httplib.HTTPResponse(FakeSocket(response_data)) |
128 | | - httpresp.begin() |
129 | | - resp = urllib.addinfourl(FakeSocket(response_data), httpresp.msg, uri) |
130 | | - resp.code = httpresp.status |
131 | | - resp.msg = httpresp.reason |
132 | | - |
133 | | - req = Request(uri) |
134 | | - req.add_data(request_body) |
135 | | - opener = build_opener() |
136 | | - |
137 | | - for handler in opener.handlers: |
138 | | - if hasattr(handler, "http_response"): |
139 | | - handler.http_response(req, resp) |
140 | | - except HTTPError: |
141 | | - raise |
142 | | - except: |
143 | | - pass |
144 | | - |
| 98 | +class RequestsTransport(Transport): |
| 99 | + user_agent = 'Python/Bugzilla' |
145 | 100 |
|
146 | | -class _CURLTransport(Transport): |
147 | 101 | def __init__(self, url, cookiejar, |
148 | 102 | sslverify=True, sslcafile=None, debug=0): |
| 103 | + # pylint: disable=W0231 |
| 104 | + # pylint does not handle multiple import of Transport well |
149 | 105 | if hasattr(Transport, "__init__"): |
150 | 106 | Transport.__init__(self, use_datetime=False) |
151 | 107 |
|
152 | 108 | self.verbose = debug |
| 109 | + self._cookiejar = cookiejar |
153 | 110 |
|
154 | 111 | # transport constructor needs full url too, as xmlrpc does not pass |
155 | 112 | # scheme to request |
156 | 113 | self.scheme = urlparse(url)[0] |
157 | 114 | if self.scheme not in ["http", "https"]: |
158 | 115 | raise Exception("Invalid URL scheme: %s (%s)" % (self.scheme, url)) |
159 | 116 |
|
160 | | - self.c = pycurl.Curl() |
161 | | - self.c.setopt(pycurl.POST, 1) |
162 | | - self.c.setopt(pycurl.CONNECTTIMEOUT, 30) |
163 | | - self.c.setopt(pycurl.HTTPHEADER, [ |
164 | | - "Content-Type: text/xml", |
165 | | - ]) |
166 | | - self.c.setopt(pycurl.VERBOSE, debug) |
167 | | - |
168 | | - self.set_cookiejar(cookiejar) |
| 117 | + self.use_https = self.scheme == 'https' |
169 | 118 |
|
170 | | - # ssl settings |
171 | | - if self.scheme == "https": |
172 | | - # override curl built-in ca file setting |
173 | | - if sslcafile is not None: |
174 | | - self.c.setopt(pycurl.CAINFO, sslcafile) |
175 | | - |
176 | | - # disable ssl verification |
177 | | - if not sslverify: |
178 | | - self.c.setopt(pycurl.SSL_VERIFYPEER, 0) |
179 | | - self.c.setopt(pycurl.SSL_VERIFYHOST, 0) |
| 119 | + self.request_defaults = { |
| 120 | + 'cert': sslcafile if self.use_https else None, |
| 121 | + 'cookies': cookiejar, |
| 122 | + 'verify': sslverify, |
| 123 | + 'headers': { |
| 124 | + 'Content-Type': 'text/xml', |
| 125 | + 'User-Agent': self.user_agent, |
| 126 | + } |
| 127 | + } |
180 | 128 |
|
181 | | - def set_cookiejar(self, cj): |
182 | | - self.c.setopt(pycurl.COOKIEFILE, cj.filename or "") |
183 | | - self.c.setopt(pycurl.COOKIEJAR, cj.filename or "") |
| 129 | + def parse_response(self, response): |
| 130 | + """ Parse XMLRPC response """ |
| 131 | + parser, unmarshaller = self.getparser() |
| 132 | + parser.feed(response.text.encode('utf-8')) |
| 133 | + parser.close() |
| 134 | + return unmarshaller.close() |
184 | 135 |
|
185 | | - def get_cookies(self): |
186 | | - return self.c.getinfo(pycurl.INFO_COOKIELIST) |
| 136 | + def _request_helper(self, url, request_body): |
| 137 | + """ |
| 138 | + A helper method to assist in making a request and provide a parsed |
| 139 | + response. |
| 140 | + """ |
| 141 | + try: |
| 142 | + response = requests.post( |
| 143 | + url, data=request_body, **self.request_defaults) |
187 | 144 |
|
188 | | - def _open_helper(self, url, request_body): |
189 | | - self.c.setopt(pycurl.URL, url) |
190 | | - self.c.setopt(pycurl.POSTFIELDS, request_body) |
| 145 | + # We expect utf-8 from the server |
| 146 | + response.encoding = 'UTF-8' |
191 | 147 |
|
192 | | - b = StringIO() |
193 | | - headers = StringIO() |
194 | | - self.c.setopt(pycurl.WRITEFUNCTION, b.write) |
195 | | - self.c.setopt(pycurl.HEADERFUNCTION, headers.write) |
| 148 | + # update/set any cookies |
| 149 | + for cookie in response.cookies: |
| 150 | + self._cookiejar.set_cookie(cookie) |
196 | 151 |
|
197 | | - try: |
198 | | - m = pycurl.CurlMulti() |
199 | | - m.add_handle(self.c) |
200 | | - while True: |
201 | | - if m.perform()[0] == -1: |
202 | | - continue |
203 | | - num, ok, err = m.info_read() |
204 | | - ignore = num |
| 152 | + if self._cookiejar.filename is not None: |
| 153 | + # Save is required only if we have a filename |
| 154 | + self._cookiejar.save() |
205 | 155 |
|
206 | | - if ok: |
207 | | - m.remove_handle(self.c) |
208 | | - break |
209 | | - if err: |
210 | | - m.remove_handle(self.c) |
211 | | - raise pycurl.error(*err[0][1:]) |
212 | | - if m.select(.1) == -1: |
213 | | - # Looks like -1 is passed straight up from select(2) |
214 | | - # While it's not true that this will always be caused |
215 | | - # by SIGINT, it should be the only case we hit |
216 | | - log.debug("pycurl select failed, this likely came from " |
217 | | - "SIGINT, raising") |
218 | | - m.remove_handle(self.c) |
219 | | - raise KeyboardInterrupt |
220 | | - except pycurl.error: |
| 156 | + response.raise_for_status() |
| 157 | + return self.parse_response(response) |
| 158 | + except requests.RequestException: |
221 | 159 | e = sys.exc_info()[1] |
222 | | - raise ProtocolError(url, e[0], e[1], None) |
223 | | - |
224 | | - b.seek(0) |
225 | | - headers.seek(0) |
226 | | - return b, headers |
| 160 | + raise ProtocolError( |
| 161 | + url, response.status_code, str(e), response.headers) |
| 162 | + except Fault: |
| 163 | + raise sys.exc_info()[1] |
| 164 | + except Exception: |
| 165 | + # pylint: disable=W0201 |
| 166 | + e = BugzillaError(str(sys.exc_info()[1])) |
| 167 | + e.__traceback__ = sys.exc_info()[2] |
| 168 | + raise e |
227 | 169 |
|
228 | 170 | def request(self, host, handler, request_body, verbose=0): |
229 | 171 | self.verbose = verbose |
230 | 172 | url = "%s://%s%s" % (self.scheme, host, handler) |
231 | 173 |
|
232 | 174 | # xmlrpclib fails to escape \r |
233 | | - request_body = request_body.replace('\r', '
') |
234 | | - |
235 | | - body, headers = self._open_helper(url, request_body) |
236 | | - _check_http_error(url, body.getvalue(), headers.getvalue()) |
237 | | - |
238 | | - return self.parse_response(body) |
239 | | - |
| 175 | + request_body = request_body.replace(b'\r', b'
') |
240 | 176 |
|
| 177 | + return self._request_helper(url, request_body) |
241 | 178 |
|
242 | 179 |
|
243 | 180 | class BugzillaError(Exception): |
@@ -475,8 +412,8 @@ def connect(self, url=None): |
475 | 412 | url = self.url |
476 | 413 | url = self.fix_url(url) |
477 | 414 |
|
478 | | - self._transport = _CURLTransport(url, self._cookiejar, |
479 | | - sslverify=self._sslverify) |
| 415 | + self._transport = RequestsTransport( |
| 416 | + url, self._cookiejar, sslverify=self._sslverify) |
480 | 417 | self._transport.user_agent = self.user_agent |
481 | 418 | self._proxy = ServerProxy(url, self._transport) |
482 | 419 |
|
@@ -1293,29 +1230,27 @@ def attachfile(self, idlist, attachfile, description, **kwargs): |
1293 | 1230 | def openattachment(self, attachid): |
1294 | 1231 | '''Get the contents of the attachment with the given attachment ID. |
1295 | 1232 | Returns a file-like object.''' |
| 1233 | + |
| 1234 | + def get_filename(headers): |
| 1235 | + import re |
| 1236 | + |
| 1237 | + match = re.search( |
| 1238 | + r'^.*filename="?(.*)"$', |
| 1239 | + headers.get('content-disposition', '') |
| 1240 | + ) |
| 1241 | + |
| 1242 | + # default to attchid if no match was found |
| 1243 | + return match.group(1) if match else attachid |
| 1244 | + |
1296 | 1245 | att_uri = self._attachment_uri(attachid) |
1297 | 1246 |
|
1298 | | - headers = {} |
1299 | | - ret = StringIO() |
| 1247 | + response = requests.get(att_uri, cookies=self._cookiejar, stream=True) |
1300 | 1248 |
|
1301 | | - def headers_cb(buf): |
1302 | | - if not ":" in buf: |
1303 | | - return |
1304 | | - name, val = buf.split(":", 1) |
1305 | | - headers[name.lower()] = val |
1306 | | - |
1307 | | - c = pycurl.Curl() |
1308 | | - c.setopt(pycurl.URL, att_uri) |
1309 | | - c.setopt(pycurl.WRITEFUNCTION, ret.write) |
1310 | | - c.setopt(pycurl.HEADERFUNCTION, headers_cb) |
1311 | | - c.setopt(pycurl.COOKIEFILE, self._cookiejar.filename or "") |
1312 | | - c.perform() |
1313 | | - c.close() |
1314 | | - |
1315 | | - disp = headers['content-disposition'].split(';') |
1316 | | - disp.pop(0) |
1317 | | - parms = dict([p.strip().split("=", 1) for p in disp]) |
1318 | | - ret.name = _decode_rfc2231_value(parms['filename']) |
| 1249 | + ret = BytesIO() |
| 1250 | + for chunk in response.iter_content(chunk_size=1024): |
| 1251 | + if chunk: |
| 1252 | + ret.write(chunk) |
| 1253 | + ret.name = get_filename(response.headers) |
1319 | 1254 |
|
1320 | 1255 | # Hooray, now we have a file-like object with .read() and .name |
1321 | 1256 | ret.seek(0) |
|
0 commit comments