~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/http.py

  • Committer: Robert Collins
  • Date: 2006-02-11 11:58:06 UTC
  • mto: (1534.1.22 integration)
  • mto: This revision was merged to the branch mainline in revision 1554.
  • Revision ID: robertc@robertcollins.net-20060211115806-732dabc1e35714ed
Give format3 working trees their own last-revision marker.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005, 2006 Canonical Ltd
2
 
#
 
1
# Copyright (C) 2005 Canonical Ltd
 
2
 
3
3
# This program is free software; you can redistribute it and/or modify
4
4
# it under the terms of the GNU General Public License as published by
5
5
# the Free Software Foundation; either version 2 of the License, or
6
6
# (at your option) any later version.
7
 
#
 
7
 
8
8
# This program is distributed in the hope that it will be useful,
9
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
11
# GNU General Public License for more details.
12
 
#
 
12
 
13
13
# You should have received a copy of the GNU General Public License
14
14
# along with this program; if not, write to the Free Software
15
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16
 
 
17
 
"""Base implementation of Transport over http.
18
 
 
19
 
There are separate implementation modules for each http client implementation.
 
16
"""Implementation of Transport over http.
20
17
"""
21
18
 
 
19
import os, errno
22
20
from cStringIO import StringIO
23
 
import errno
24
 
import mimetools
25
 
import os
26
 
import posixpath
27
 
import re
28
 
import sys
 
21
import urllib, urllib2
29
22
import urlparse
30
 
import urllib
31
23
from warnings import warn
32
24
 
33
 
# TODO: load these only when running http tests
34
 
import BaseHTTPServer, SimpleHTTPServer, socket, time
35
 
import threading
36
 
 
37
 
from bzrlib import errors
38
 
from bzrlib.errors import (TransportNotPossible, NoSuchFile,
39
 
                           TransportError, ConnectionError, InvalidURL)
 
25
from bzrlib.transport import Transport, Server
 
26
from bzrlib.errors import (TransportNotPossible, NoSuchFile, 
 
27
                           TransportError, ConnectionError)
 
28
from bzrlib.errors import BzrError, BzrCheckError
40
29
from bzrlib.branch import Branch
41
30
from bzrlib.trace import mutter
42
 
from bzrlib.transport import Transport, register_transport, Server
43
 
from bzrlib.transport.http.response import (HttpMultipartRangeResponse,
44
 
                                            HttpRangeResponse)
45
 
from bzrlib.ui import ui_factory
46
31
 
47
32
 
48
33
def extract_auth(url, password_manager):
49
 
    """Extract auth parameters from am HTTP/HTTPS url and add them to the given
 
34
    """
 
35
    Extract auth parameters from am HTTP/HTTPS url and add them to the given
50
36
    password manager.  Return the url, minus those auth parameters (which
51
37
    confuse urllib2).
52
38
    """
53
 
    assert re.match(r'^(https?)(\+\w+)?://', url), \
54
 
            'invalid absolute url %r' % url
55
 
    scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
56
 
    
57
 
    if '@' in netloc:
58
 
        auth, netloc = netloc.split('@', 1)
 
39
    assert url.startswith('http://') or url.startswith('https://')
 
40
    scheme, host = url.split('//', 1)
 
41
    if '/' in host:
 
42
        host, path = host.split('/', 1)
 
43
        path = '/' + path
 
44
    else:
 
45
        path = ''
 
46
    port = ''
 
47
    if '@' in host:
 
48
        auth, host = host.split('@', 1)
59
49
        if ':' in auth:
60
50
            username, password = auth.split(':', 1)
61
51
        else:
62
52
            username, password = auth, None
63
 
        if ':' in netloc:
64
 
            host = netloc.split(':', 1)[0]
65
 
        else:
66
 
            host = netloc
67
 
        username = urllib.unquote(username)
 
53
        if ':' in host:
 
54
            host, port = host.split(':', 1)
 
55
            port = ':' + port
 
56
        # FIXME: if password isn't given, should we ask for it?
68
57
        if password is not None:
 
58
            username = urllib.unquote(username)
69
59
            password = urllib.unquote(password)
70
 
        else:
71
 
            password = ui_factory.get_password(prompt='HTTP %(user)@%(host) password',
72
 
                                               user=username, host=host)
73
 
        password_manager.add_password(None, host, username, password)
74
 
    url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
 
60
            password_manager.add_password(None, host, username, password)
 
61
    url = scheme + '//' + host + port + path
75
62
    return url
76
 
 
77
 
 
78
 
def _extract_headers(header_text, url):
79
 
    """Extract the mapping for an rfc2822 header
80
 
 
81
 
    This is a helper function for the test suite and for _pycurl.
82
 
    (urllib already parses the headers for us)
83
 
 
84
 
    In the case that there are multiple headers inside the file,
85
 
    the last one is returned.
86
 
 
87
 
    :param header_text: A string of header information.
88
 
        This expects that the first line of a header will always be HTTP ...
89
 
    :param url: The url we are parsing, so we can raise nice errors
90
 
    :return: mimetools.Message object, which basically acts like a case 
91
 
        insensitive dictionary.
92
 
    """
93
 
    first_header = True
94
 
    remaining = header_text
95
 
 
96
 
    if not remaining:
97
 
        raise errors.InvalidHttpResponse(url, 'Empty headers')
98
 
 
99
 
    while remaining:
100
 
        header_file = StringIO(remaining)
101
 
        first_line = header_file.readline()
102
 
        if not first_line.startswith('HTTP'):
103
 
            if first_header: # The first header *must* start with HTTP
104
 
                raise errors.InvalidHttpResponse(url,
105
 
                    'Opening header line did not start with HTTP: %s' 
106
 
                    % (first_line,))
107
 
                assert False, 'Opening header line was not HTTP'
108
 
            else:
109
 
                break # We are done parsing
110
 
        first_header = False
111
 
        m = mimetools.Message(header_file)
112
 
 
113
 
        # mimetools.Message parses the first header up to a blank line
114
 
        # So while there is remaining data, it probably means there is
115
 
        # another header to be parsed.
116
 
        # Get rid of any preceeding whitespace, which if it is all whitespace
117
 
        # will get rid of everything.
118
 
        remaining = header_file.read().lstrip()
119
 
    return m
120
 
 
121
 
 
122
 
class HttpTransportBase(Transport):
123
 
    """Base class for http implementations.
124
 
 
125
 
    Does URL parsing, etc, but not any network IO.
126
 
 
127
 
    The protocol can be given as e.g. http+urllib://host/ to use a particular
128
 
    implementation.
129
 
    """
130
 
 
131
 
    # _proto: "http" or "https"
132
 
    # _qualified_proto: may have "+pycurl", etc
 
63
    
 
64
def get_url(url):
 
65
    import urllib2
 
66
    mutter("get_url %s" % url)
 
67
    manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
 
68
    url = extract_auth(url, manager)
 
69
    auth_handler = urllib2.HTTPBasicAuthHandler(manager)
 
70
    opener = urllib2.build_opener(auth_handler)
 
71
    url_f = opener.open(url)
 
72
    return url_f
 
73
 
 
74
class HttpTransport(Transport):
 
75
    """This is the transport agent for http:// access.
 
76
    
 
77
    TODO: Implement pipelined versions of all of the *_multi() functions.
 
78
    """
133
79
 
134
80
    def __init__(self, base):
135
81
        """Set the base path where files will be stored."""
136
 
        proto_match = re.match(r'^(https?)(\+\w+)?://', base)
137
 
        if not proto_match:
138
 
            raise AssertionError("not a http url: %r" % base)
139
 
        self._proto = proto_match.group(1)
140
 
        impl_name = proto_match.group(2)
141
 
        if impl_name:
142
 
            impl_name = impl_name[1:]
143
 
        self._impl_name = impl_name
 
82
        assert base.startswith('http://') or base.startswith('https://')
144
83
        if base[-1] != '/':
145
84
            base = base + '/'
146
 
        super(HttpTransportBase, self).__init__(base)
 
85
        super(HttpTransport, self).__init__(base)
147
86
        # In the future we might actually connect to the remote host
148
87
        # rather than using get_url
149
88
        # self._connection = None
150
 
        (apparent_proto, self._host,
 
89
        (self._proto, self._host,
151
90
            self._path, self._parameters,
152
91
            self._query, self._fragment) = urlparse.urlparse(self.base)
153
 
        self._qualified_proto = apparent_proto
 
92
 
 
93
    def should_cache(self):
 
94
        """Return True if the data pulled across should be cached locally.
 
95
        """
 
96
        return True
 
97
 
 
98
    def clone(self, offset=None):
 
99
        """Return a new HttpTransport with root at self.base + offset
 
100
        For now HttpTransport does not actually connect, so just return
 
101
        a new HttpTransport object.
 
102
        """
 
103
        if offset is None:
 
104
            return HttpTransport(self.base)
 
105
        else:
 
106
            return HttpTransport(self.abspath(offset))
154
107
 
155
108
    def abspath(self, relpath):
156
109
        """Return the full url to the given relative path.
157
 
 
158
 
        This can be supplied with a string or a list.
159
 
 
160
 
        The URL returned always has the protocol scheme originally used to 
161
 
        construct the transport, even if that includes an explicit
162
 
        implementation qualifier.
 
110
        This can be supplied with a string or a list
163
111
        """
164
112
        assert isinstance(relpath, basestring)
165
 
        if isinstance(relpath, unicode):
166
 
            raise InvalidURL(relpath, 'paths must not be unicode.')
167
113
        if isinstance(relpath, basestring):
168
114
            relpath_parts = relpath.split('/')
169
115
        else:
194
140
        # I'm concerned about when it chooses to strip the last
195
141
        # portion of the path, and when it doesn't.
196
142
        path = '/'.join(basepath)
197
 
        if path == '':
198
 
            path = '/'
199
 
        result = urlparse.urlunparse((self._qualified_proto,
200
 
                                    self._host, path, '', '', ''))
201
 
        return result
202
 
 
203
 
    def _real_abspath(self, relpath):
204
 
        """Produce absolute path, adjusting protocol if needed"""
205
 
        abspath = self.abspath(relpath)
206
 
        qp = self._qualified_proto
207
 
        rp = self._proto
208
 
        if self._qualified_proto != self._proto:
209
 
            abspath = rp + abspath[len(qp):]
210
 
        if not isinstance(abspath, str):
211
 
            # escaping must be done at a higher level
212
 
            abspath = abspath.encode('ascii')
213
 
        return abspath
 
143
        return urlparse.urlunparse((self._proto,
 
144
                self._host, path, '', '', ''))
214
145
 
215
146
    def has(self, relpath):
216
 
        raise NotImplementedError("has() is abstract on %r" % self)
217
 
 
218
 
    def get(self, relpath):
 
147
        """Does the target location exist?
 
148
 
 
149
        TODO: HttpTransport.has() should use a HEAD request,
 
150
        not a full GET request.
 
151
 
 
152
        TODO: This should be changed so that we don't use
 
153
        urllib2 and get an exception, the code path would be
 
154
        cleaner if we just do an http HEAD request, and parse
 
155
        the return code.
 
156
        """
 
157
        path = relpath
 
158
        try:
 
159
            path = self.abspath(relpath)
 
160
            f = get_url(path)
 
161
            # Without the read and then close()
 
162
            # we tend to have busy sockets.
 
163
            f.read()
 
164
            f.close()
 
165
            return True
 
166
        except urllib2.URLError, e:
 
167
            mutter('url error code: %s for has url: %r', e.code, path)
 
168
            if e.code == 404:
 
169
                return False
 
170
            raise
 
171
        except IOError, e:
 
172
            mutter('io error: %s %s for has url: %r', 
 
173
                e.errno, errno.errorcode.get(e.errno), path)
 
174
            if e.errno == errno.ENOENT:
 
175
                return False
 
176
            raise TransportError(orig_error=e)
 
177
 
 
178
    def get(self, relpath, decode=False):
219
179
        """Get the file at the given relative path.
220
180
 
221
181
        :param relpath: The relative path to the file
222
182
        """
223
 
        code, response_file = self._get(relpath, None)
224
 
        return response_file
225
 
 
226
 
    def _get(self, relpath, ranges):
227
 
        """Get a file, or part of a file.
228
 
 
229
 
        :param relpath: Path relative to transport base URL
230
 
        :param byte_range: None to get the whole file;
231
 
            or [(start,end)] to fetch parts of a file.
232
 
 
233
 
        :returns: (http_code, result_file)
234
 
 
235
 
        Note that the current http implementations can only fetch one range at
236
 
        a time through this call.
237
 
        """
238
 
        raise NotImplementedError(self._get)
239
 
 
240
 
    def readv(self, relpath, offsets):
241
 
        """Get parts of the file at the given relative path.
242
 
 
243
 
        :param offsets: A list of (offset, size) tuples.
244
 
        :param return: A list or generator of (offset, data) tuples
245
 
        """
246
 
        ranges = self.offsets_to_ranges(offsets)
247
 
        mutter('http readv of %s collapsed %s offsets => %s',
248
 
                relpath, len(offsets), ranges)
249
 
        code, f = self._get(relpath, ranges)
250
 
        for start, size in offsets:
251
 
            f.seek(start, (start < 0) and 2 or 0)
252
 
            start = f.tell()
253
 
            data = f.read(size)
254
 
            assert len(data) == size
255
 
            yield start, data
256
 
 
257
 
    @staticmethod
258
 
    def offsets_to_ranges(offsets):
259
 
        """Turn a list of offsets and sizes into a list of byte ranges.
260
 
 
261
 
        :param offsets: A list of tuples of (start, size).  An empty list
262
 
            is not accepted.
263
 
        :return: a list of inclusive byte ranges (start, end) 
264
 
            Adjacent ranges will be combined.
265
 
        """
266
 
        # Make sure we process sorted offsets
267
 
        offsets = sorted(offsets)
268
 
 
269
 
        prev_end = None
270
 
        combined = []
271
 
 
272
 
        for start, size in offsets:
273
 
            end = start + size - 1
274
 
            if prev_end is None:
275
 
                combined.append([start, end])
276
 
            elif start <= prev_end + 1:
277
 
                combined[-1][1] = end
278
 
            else:
279
 
                combined.append([start, end])
280
 
            prev_end = end
281
 
 
282
 
        return combined
 
183
        path = relpath
 
184
        try:
 
185
            path = self.abspath(relpath)
 
186
            return get_url(path)
 
187
        except urllib2.HTTPError, e:
 
188
            mutter('url error code: %s for has url: %r', e.code, path)
 
189
            if e.code == 404:
 
190
                raise NoSuchFile(path, extra=e)
 
191
            raise
 
192
        except (BzrError, IOError), e:
 
193
            if hasattr(e, 'errno'):
 
194
                mutter('io error: %s %s for has url: %r', 
 
195
                    e.errno, errno.errorcode.get(e.errno), path)
 
196
                if e.errno == errno.ENOENT:
 
197
                    raise NoSuchFile(path, extra=e)
 
198
            raise ConnectionError(msg = "Error retrieving %s: %s" 
 
199
                             % (self.abspath(relpath), str(e)),
 
200
                             orig_error=e)
283
201
 
284
202
    def put(self, relpath, f, mode=None):
285
203
        """Copy the file-like or string object into the location.
318
236
        # At this point HttpTransport might be able to check and see if
319
237
        # the remote location is the same, and rather than download, and
320
238
        # then upload, it could just issue a remote copy_this command.
321
 
        if isinstance(other, HttpTransportBase):
 
239
        if isinstance(other, HttpTransport):
322
240
            raise TransportNotPossible('http cannot be the target of copy_to()')
323
241
        else:
324
 
            return super(HttpTransportBase, self).\
325
 
                    copy_to(relpaths, other, mode=mode, pb=pb)
 
242
            return super(HttpTransport, self).copy_to(relpaths, other, mode=mode, pb=pb)
326
243
 
327
244
    def move(self, rel_from, rel_to):
328
245
        """Move the item at rel_from to the location at rel_to"""
366
283
        """
367
284
        raise TransportNotPossible('http does not support lock_write()')
368
285
 
369
 
    def clone(self, offset=None):
370
 
        """Return a new HttpTransportBase with root at self.base + offset
371
 
        For now HttpTransportBase does not actually connect, so just return
372
 
        a new HttpTransportBase object.
373
 
        """
374
 
        if offset is None:
375
 
            return self.__class__(self.base)
376
 
        else:
377
 
            return self.__class__(self.abspath(offset))
378
 
 
379
 
    @staticmethod
380
 
    def range_header(ranges, tail_amount):
381
 
        """Turn a list of bytes ranges into a HTTP Range header value.
382
 
 
383
 
        :param offsets: A list of byte ranges, (start, end). An empty list
384
 
        is not accepted.
385
 
 
386
 
        :return: HTTP range header string.
387
 
        """
388
 
        strings = []
389
 
        for start, end in ranges:
390
 
            strings.append('%d-%d' % (start, end))
391
 
 
392
 
        if tail_amount:
393
 
            strings.append('-%d' % tail_amount)
394
 
 
395
 
        return ','.join(strings)
396
 
 
397
286
 
398
287
#---------------- test server facilities ----------------
399
 
# TODO: load these only when running tests
 
288
import BaseHTTPServer, SimpleHTTPServer, socket, time
 
289
import threading
400
290
 
401
291
 
402
292
class WebserverNotAvailable(Exception):
411
301
class TestingHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
412
302
 
413
303
    def log_message(self, format, *args):
414
 
        self.server.test_case.log('webserver - %s - - [%s] %s "%s" "%s"',
 
304
        self.server.test_case.log("webserver - %s - - [%s] %s",
415
305
                                  self.address_string(),
416
306
                                  self.log_date_time_string(),
417
 
                                  format % args,
418
 
                                  self.headers.get('referer', '-'),
419
 
                                  self.headers.get('user-agent', '-'))
 
307
                                  format%args)
420
308
 
421
309
    def handle_one_request(self):
422
310
        """Handle a single HTTP request.
452
340
        method = getattr(self, mname)
453
341
        method()
454
342
 
455
 
    if sys.platform == 'win32':
456
 
        # On win32 you cannot access non-ascii filenames without
457
 
        # decoding them into unicode first.
458
 
        # However, under Linux, you can access bytestream paths
459
 
        # without any problems. If this function was always active
460
 
        # it would probably break tests when LANG=C was set
461
 
        def translate_path(self, path):
462
 
            """Translate a /-separated PATH to the local filename syntax.
463
 
 
464
 
            For bzr, all url paths are considered to be utf8 paths.
465
 
            On Linux, you can access these paths directly over the bytestream
466
 
            request, but on win32, you must decode them, and access them
467
 
            as Unicode files.
468
 
            """
469
 
            # abandon query parameters
470
 
            path = urlparse.urlparse(path)[2]
471
 
            path = posixpath.normpath(urllib.unquote(path))
472
 
            path = path.decode('utf-8')
473
 
            words = path.split('/')
474
 
            words = filter(None, words)
475
 
            path = os.getcwdu()
476
 
            for word in words:
477
 
                drive, word = os.path.splitdrive(word)
478
 
                head, word = os.path.split(word)
479
 
                if word in (os.curdir, os.pardir): continue
480
 
                path = os.path.join(path, word)
481
 
            return path
482
 
 
483
 
 
484
343
class TestingHTTPServer(BaseHTTPServer.HTTPServer):
485
344
    def __init__(self, server_address, RequestHandlerClass, test_case):
486
345
        BaseHTTPServer.HTTPServer.__init__(self, server_address,
491
350
class HttpServer(Server):
492
351
    """A test server for http transports."""
493
352
 
494
 
    # used to form the url that connects to this server
495
 
    _url_protocol = 'http'
 
353
    _HTTP_PORTS = range(13000, 0x8000)
496
354
 
497
355
    def _http_start(self):
498
356
        httpd = None
499
 
        httpd = TestingHTTPServer(('localhost', 0),
500
 
                                  TestingHTTPRequestHandler,
501
 
                                  self)
502
 
        host, port = httpd.socket.getsockname()
503
 
        self._http_base_url = '%s://localhost:%s/' % (self._url_protocol, port)
 
357
        for port in self._HTTP_PORTS:
 
358
            try:
 
359
                httpd = TestingHTTPServer(('localhost', port),
 
360
                                          TestingHTTPRequestHandler,
 
361
                                          self)
 
362
            except socket.error, e:
 
363
                if e.args[0] == errno.EADDRINUSE:
 
364
                    continue
 
365
                print >>sys.stderr, "Cannot run webserver :-("
 
366
                raise
 
367
            else:
 
368
                break
 
369
 
 
370
        if httpd is None:
 
371
            raise WebserverNotAvailable("Cannot run webserver :-( "
 
372
                                        "no free ports in range %s..%s" %
 
373
                                        (_HTTP_PORTS[0], _HTTP_PORTS[-1]))
 
374
 
 
375
        self._http_base_url = 'http://localhost:%s/' % port
504
376
        self._http_starting.release()
505
377
        httpd.socket.settimeout(0.1)
506
378
 
524
396
        self._http_starting.release()
525
397
        return self._http_base_url + remote_path
526
398
 
527
 
    def log(self, format, *args):
 
399
    def log(self, *args, **kwargs):
528
400
        """Capture Server log output."""
529
 
        self.logs.append(format % args)
 
401
        self.logs.append(args[3])
530
402
 
531
403
    def setUp(self):
532
404
        """See bzrlib.transport.Server.setUp."""
558
430
        
559
431
    def get_bogus_url(self):
560
432
        """See bzrlib.transport.Server.get_bogus_url."""
561
 
        # this is chosen to try to prevent trouble with proxies, wierd dns,
562
 
        # etc
563
 
        return 'http://127.0.0.1:1/'
564
 
 
 
433
        return 'http://jasldkjsalkdjalksjdkljasd'
 
434
 
 
435
 
 
436
def get_test_permutations():
 
437
    """Return the permutations to be used in testing."""
 
438
    warn("There are no HTTPS transport provider tests yet.")
 
439
    return [(HttpTransport, HttpServer),
 
440
            ]