~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/urlutils.py

(vila) Revise legal option names to be less drastic. (Vincent Ladeuil)

Show diffs side-by-side

added added

removed removed

Lines of Context:
16
16
 
17
17
"""A collection of function for handling URL operations."""
18
18
 
 
19
from __future__ import absolute_import
 
20
 
19
21
import os
20
22
import re
21
23
import sys
22
24
 
23
25
from bzrlib.lazy_import import lazy_import
24
26
lazy_import(globals(), """
25
 
from posixpath import split as _posix_split, normpath as _posix_normpath
26
 
import urllib
 
27
from posixpath import split as _posix_split
27
28
import urlparse
28
29
 
29
30
from bzrlib import (
60
61
    return split(url, exclude_trailing_slash=exclude_trailing_slash)[0]
61
62
 
62
63
 
 
64
# Private copies of quote and unquote, copied from Python's
 
65
# urllib module because urllib unconditionally imports socket, which imports
 
66
# ssl.
 
67
 
 
68
always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'
 
69
               'abcdefghijklmnopqrstuvwxyz'
 
70
               '0123456789' '_.-')
 
71
_safe_map = {}
 
72
for i, c in zip(xrange(256), str(bytearray(xrange(256)))):
 
73
    _safe_map[c] = c if (i < 128 and c in always_safe) else '%{0:02X}'.format(i)
 
74
_safe_quoters = {}
 
75
 
 
76
 
 
77
def quote(s, safe='/'):
 
78
    """quote('abc def') -> 'abc%20def'
 
79
 
 
80
    Each part of a URL, e.g. the path info, the query, etc., has a
 
81
    different set of reserved characters that must be quoted.
 
82
 
 
83
    RFC 2396 Uniform Resource Identifiers (URI): Generic Syntax lists
 
84
    the following reserved characters.
 
85
 
 
86
    reserved    = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" |
 
87
                  "$" | ","
 
88
 
 
89
    Each of these characters is reserved in some component of a URL,
 
90
    but not necessarily in all of them.
 
91
 
 
92
    By default, the quote function is intended for quoting the path
 
93
    section of a URL.  Thus, it will not encode '/'.  This character
 
94
    is reserved, but in typical usage the quote function is being
 
95
    called on a path where the existing slash characters are used as
 
96
    reserved characters.
 
97
    """
 
98
    # fastpath
 
99
    if not s:
 
100
        if s is None:
 
101
            raise TypeError('None object cannot be quoted')
 
102
        return s
 
103
    cachekey = (safe, always_safe)
 
104
    try:
 
105
        (quoter, safe) = _safe_quoters[cachekey]
 
106
    except KeyError:
 
107
        safe_map = _safe_map.copy()
 
108
        safe_map.update([(c, c) for c in safe])
 
109
        quoter = safe_map.__getitem__
 
110
        safe = always_safe + safe
 
111
        _safe_quoters[cachekey] = (quoter, safe)
 
112
    if not s.rstrip(safe):
 
113
        return s
 
114
    return ''.join(map(quoter, s))
 
115
 
 
116
 
 
117
_hexdig = '0123456789ABCDEFabcdef'
 
118
_hextochr = dict((a + b, chr(int(a + b, 16)))
 
119
                 for a in _hexdig for b in _hexdig)
 
120
 
 
121
def unquote(s):
 
122
    """unquote('abc%20def') -> 'abc def'."""
 
123
    res = s.split('%')
 
124
    # fastpath
 
125
    if len(res) == 1:
 
126
        return s
 
127
    s = res[0]
 
128
    for item in res[1:]:
 
129
        try:
 
130
            s += _hextochr[item[:2]] + item[2:]
 
131
        except KeyError:
 
132
            s += '%' + item
 
133
        except UnicodeDecodeError:
 
134
            s += unichr(int(item[:2], 16)) + item[2:]
 
135
    return s
 
136
 
 
137
 
63
138
def escape(relpath):
64
139
    """Escape relpath to be a valid url."""
65
140
    if isinstance(relpath, unicode):
66
141
        relpath = relpath.encode('utf-8')
67
142
    # After quoting and encoding, the path should be perfectly
68
143
    # safe as a plain ASCII string, str() just enforces this
69
 
    return str(urllib.quote(relpath, safe='/~'))
 
144
    return str(quote(relpath, safe='/~'))
70
145
 
71
146
 
72
147
def file_relpath(base, path):
78
153
        raise ValueError('Length of base (%r) must equal or'
79
154
            ' exceed the platform minimum url length (which is %d)' %
80
155
            (base, MIN_ABS_FILEURL_LENGTH))
81
 
    base = local_path_from_url(base)
82
 
    path = local_path_from_url(path)
 
156
    base = osutils.normpath(local_path_from_url(base))
 
157
    path = osutils.normpath(local_path_from_url(path))
83
158
    return escape(osutils.relpath(base, path))
84
159
 
85
160
 
101
176
    first_path_slash = path.find('/')
102
177
    if first_path_slash == -1:
103
178
        return len(scheme), None
104
 
    return len(scheme), first_path_slash+len(scheme)+3
 
179
    return len(scheme), first_path_slash+m.start('path')
 
180
 
 
181
 
 
182
def is_url(url):
 
183
    """Tests whether a URL is in actual fact a URL."""
 
184
    return _url_scheme_re.match(url) is not None
105
185
 
106
186
 
107
187
def join(base, *args):
118
198
    """
119
199
    if not args:
120
200
        return base
121
 
    match = _url_scheme_re.match(base)
122
 
    scheme = None
123
 
    if match:
124
 
        scheme = match.group('scheme')
125
 
        path = match.group('path').split('/')
126
 
        if path[-1:] == ['']:
127
 
            # Strip off a trailing slash
128
 
            # This helps both when we are at the root, and when
129
 
            # 'base' has an extra slash at the end
130
 
            path = path[:-1]
131
 
    else:
132
 
        path = base.split('/')
133
 
 
134
 
    if scheme is not None and len(path) >= 1:
135
 
        host = path[:1]
136
 
        # the path should be represented as an abs path.
137
 
        # we know this must be absolute because of the presence of a URL scheme.
138
 
        remove_root = True
139
 
        path = [''] + path[1:]
140
 
    else:
141
 
        # create an empty host, but dont alter the path - this might be a
142
 
        # relative url fragment.
143
 
        host = []
144
 
        remove_root = False
145
 
 
 
201
    scheme_end, path_start = _find_scheme_and_separator(base)
 
202
    if scheme_end is None and path_start is None:
 
203
        path_start = 0
 
204
    elif path_start is None:
 
205
        path_start = len(base)
 
206
    path = base[path_start:]
146
207
    for arg in args:
147
 
        match = _url_scheme_re.match(arg)
148
 
        if match:
149
 
            # Absolute URL
150
 
            scheme = match.group('scheme')
151
 
            # this skips .. normalisation, making http://host/../../..
152
 
            # be rather strange.
153
 
            path = match.group('path').split('/')
154
 
            # set the host and path according to new absolute URL, discarding
155
 
            # any previous values.
156
 
            # XXX: duplicates mess from earlier in this function.  This URL
157
 
            # manipulation code needs some cleaning up.
158
 
            if scheme is not None and len(path) >= 1:
159
 
                host = path[:1]
160
 
                path = path[1:]
161
 
                # url scheme implies absolute path.
162
 
                path = [''] + path
163
 
            else:
164
 
                # no url scheme we take the path as is.
165
 
                host = []
 
208
        arg_scheme_end, arg_path_start = _find_scheme_and_separator(arg)
 
209
        if arg_scheme_end is None and arg_path_start is None:
 
210
            arg_path_start = 0
 
211
        elif arg_path_start is None:
 
212
            arg_path_start = len(arg)
 
213
        if arg_scheme_end is not None:
 
214
            base = arg
 
215
            path = arg[arg_path_start:]
 
216
            scheme_end = arg_scheme_end
 
217
            path_start = arg_path_start
166
218
        else:
167
 
            path = '/'.join(path)
168
219
            path = joinpath(path, arg)
169
 
            path = path.split('/')
170
 
    if remove_root and path[0:1] == ['']:
171
 
        del path[0]
172
 
    if host:
173
 
        # Remove the leading slash from the path, so long as it isn't also the
174
 
        # trailing slash, which we want to keep if present.
175
 
        if path and path[0] == '' and len(path) > 1:
176
 
            del path[0]
177
 
        path = host + path
178
 
 
179
 
    if scheme is None:
180
 
        return '/'.join(path)
181
 
    return scheme + '://' + '/'.join(path)
 
220
    return base[:path_start] + path
182
221
 
183
222
 
184
223
def joinpath(base, *args):
217
256
# jam 20060502 Sorted to 'l' because the final target is 'local_path_from_url'
218
257
def _posix_local_path_from_url(url):
219
258
    """Convert a url like file:///path/to/foo into /path/to/foo"""
 
259
    url = split_segment_parameters_raw(url)[0]
220
260
    file_localhost_prefix = 'file://localhost/'
221
261
    if url.startswith(file_localhost_prefix):
222
262
        path = url[len(file_localhost_prefix) - 1:]
236
276
    """
237
277
    # importing directly from posixpath allows us to test this
238
278
    # on non-posix platforms
239
 
    return 'file://' + escape(_posix_normpath(
240
 
        osutils._posix_abspath(path)))
 
279
    return 'file://' + escape(osutils._posix_abspath(path))
241
280
 
242
281
 
243
282
def _win32_local_path_from_url(url):
245
284
    if not url.startswith('file://'):
246
285
        raise errors.InvalidURL(url, 'local urls must start with file:///, '
247
286
                                     'UNC path urls must start with file://')
 
287
    url = split_segment_parameters_raw(url)[0]
248
288
    # We strip off all 3 slashes
249
289
    win32_url = url[len('file:'):]
250
290
    # check for UNC path: //HOST/path
260
300
        return '/'
261
301
 
262
302
    # usual local path with drive letter
263
 
    if (win32_url[3] not in ('abcdefghijklmnopqrstuvwxyz'
264
 
                             'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
 
303
    if (len(win32_url) < 6
 
304
        or win32_url[3] not in ('abcdefghijklmnopqrstuvwxyz'
 
305
                                'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
265
306
        or win32_url[4] not in  '|:'
266
307
        or win32_url[5] != '/'):
267
308
        raise errors.InvalidURL(url, 'Win32 file urls start with'
278
319
    # on non-win32 platform
279
320
    # FIXME: It turns out that on nt, ntpath.abspath uses nt._getfullpathname
280
321
    #       which actually strips trailing space characters.
281
 
    #       The worst part is that under linux ntpath.abspath has different
 
322
    #       The worst part is that on linux ntpath.abspath has different
282
323
    #       semantics, since 'nt' is not an available module.
283
324
    if path == '/':
284
325
        return 'file:///'
303
344
    MIN_ABS_FILEURL_LENGTH = WIN32_MIN_ABS_FILEURL_LENGTH
304
345
 
305
346
 
306
 
_url_scheme_re = re.compile(r'^(?P<scheme>[^:/]{2,})://(?P<path>.*)$')
 
347
_url_scheme_re = re.compile(r'^(?P<scheme>[^:/]{2,}):(//)?(?P<path>.*)$')
307
348
_url_hex_escapes_re = re.compile(r'(%[0-9a-fA-F]{2})')
308
349
 
309
350
 
339
380
    :param url: Either a hybrid URL or a local path
340
381
    :return: A normalized URL which only includes 7-bit ASCII characters.
341
382
    """
342
 
    m = _url_scheme_re.match(url)
343
 
    if not m:
 
383
    scheme_end, path_start = _find_scheme_and_separator(url)
 
384
    if scheme_end is None:
344
385
        return local_path_to_url(url)
345
 
    scheme = m.group('scheme')
346
 
    path = m.group('path')
 
386
    prefix = url[:path_start]
 
387
    path = url[path_start:]
347
388
    if not isinstance(url, unicode):
348
389
        for c in url:
349
390
            if c not in _url_safe_characters:
350
391
                raise errors.InvalidURL(url, 'URLs can only contain specific'
351
392
                                            ' safe characters (not %r)' % c)
352
393
        path = _url_hex_escapes_re.sub(_unescape_safe_chars, path)
353
 
        return str(scheme + '://' + ''.join(path))
 
394
        return str(prefix + ''.join(path))
354
395
 
355
396
    # We have a unicode (hybrid) url
356
397
    path_chars = list(path)
362
403
                ['%%%02X' % ord(c) for c in path_chars[i].encode('utf-8')])
363
404
    path = ''.join(path_chars)
364
405
    path = _url_hex_escapes_re.sub(_unescape_safe_chars, path)
365
 
    return str(scheme + '://' + path)
 
406
    return str(prefix + path)
366
407
 
367
408
 
368
409
def relative_url(base, other):
421
462
    """On win32 the drive letter needs to be added to the url base."""
422
463
    # Strip off the drive letter
423
464
    # path is currently /C:/foo
424
 
    if len(path) < 3 or path[2] not in ':|' or path[3] != '/':
 
465
    if len(path) < 4 or path[2] not in ':|' or path[3] != '/':
425
466
        raise errors.InvalidURL(url_base + path,
426
467
            'win32 file:/// paths need a drive letter')
427
468
    url_base += path[0:3] # file:// + /C:
475
516
    :param url: A relative or absolute URL
476
517
    :return: (url, subsegments)
477
518
    """
478
 
    (parent_url, child_dir) = split(url)
479
 
    subsegments = child_dir.split(",")
480
 
    if len(subsegments) == 1:
 
519
    # GZ 2011-11-18: Dodgy removing the terminal slash like this, function
 
520
    #                operates on urls not url+segments, and Transport classes
 
521
    #                should not be blindly adding slashes in the first place. 
 
522
    lurl = strip_trailing_slash(url)
 
523
    # Segments begin at first comma after last forward slash, if one exists
 
524
    segment_start = lurl.find(",", lurl.rfind("/")+1)
 
525
    if segment_start == -1:
481
526
        return (url, [])
482
 
    return (join(parent_url, subsegments[0]), subsegments[1:])
 
527
    return (lurl[:segment_start], lurl[segment_start+1:].split(","))
483
528
 
484
529
 
485
530
def split_segment_parameters(url):
596
641
    This returns a Unicode path from a URL
597
642
    """
598
643
    # jam 20060427 URLs are supposed to be ASCII only strings
599
 
    #       If they are passed in as unicode, urllib.unquote
 
644
    #       If they are passed in as unicode, unquote
600
645
    #       will return a UNICODE string, which actually contains
601
646
    #       utf-8 bytes. So we have to ensure that they are
602
647
    #       plain ASCII strings, or the final .decode will
607
652
    except UnicodeError, e:
608
653
        raise errors.InvalidURL(url, 'URL was not a plain ASCII url: %s' % (e,))
609
654
 
610
 
    unquoted = urllib.unquote(url)
 
655
    unquoted = unquote(url)
611
656
    try:
612
657
        unicode_path = unquoted.decode('utf-8')
613
658
    except UnicodeError, e:
765
810
    return osutils.pathjoin(*segments)
766
811
 
767
812
 
 
813
class URL(object):
 
814
    """Parsed URL."""
 
815
 
 
816
    def __init__(self, scheme, quoted_user, quoted_password, quoted_host,
 
817
            port, quoted_path):
 
818
        self.scheme = scheme
 
819
        self.quoted_host = quoted_host
 
820
        self.host = unquote(self.quoted_host)
 
821
        self.quoted_user = quoted_user
 
822
        if self.quoted_user is not None:
 
823
            self.user = unquote(self.quoted_user)
 
824
        else:
 
825
            self.user = None
 
826
        self.quoted_password = quoted_password
 
827
        if self.quoted_password is not None:
 
828
            self.password = unquote(self.quoted_password)
 
829
        else:
 
830
            self.password = None
 
831
        self.port = port
 
832
        self.quoted_path = _url_hex_escapes_re.sub(_unescape_safe_chars, quoted_path)
 
833
        self.path = unquote(self.quoted_path)
 
834
 
 
835
    def __eq__(self, other):
 
836
        return (isinstance(other, self.__class__) and
 
837
                self.scheme == other.scheme and
 
838
                self.host == other.host and
 
839
                self.user == other.user and
 
840
                self.password == other.password and
 
841
                self.path == other.path)
 
842
 
 
843
    def __repr__(self):
 
844
        return "<%s(%r, %r, %r, %r, %r, %r)>" % (
 
845
            self.__class__.__name__,
 
846
            self.scheme, self.quoted_user, self.quoted_password,
 
847
            self.quoted_host, self.port, self.quoted_path)
 
848
 
 
849
    @classmethod
 
850
    def from_string(cls, url):
 
851
        """Create a URL object from a string.
 
852
 
 
853
        :param url: URL as bytestring
 
854
        """
 
855
        if isinstance(url, unicode):
 
856
            raise errors.InvalidURL('should be ascii:\n%r' % url)
 
857
        url = url.encode('utf-8')
 
858
        (scheme, netloc, path, params,
 
859
         query, fragment) = urlparse.urlparse(url, allow_fragments=False)
 
860
        user = password = host = port = None
 
861
        if '@' in netloc:
 
862
            user, host = netloc.rsplit('@', 1)
 
863
            if ':' in user:
 
864
                user, password = user.split(':', 1)
 
865
        else:
 
866
            host = netloc
 
867
 
 
868
        if ':' in host and not (host[0] == '[' and host[-1] == ']'):
 
869
            # there *is* port
 
870
            host, port = host.rsplit(':',1)
 
871
            try:
 
872
                port = int(port)
 
873
            except ValueError:
 
874
                raise errors.InvalidURL('invalid port number %s in url:\n%s' %
 
875
                                        (port, url))
 
876
        if host != "" and host[0] == '[' and host[-1] == ']': #IPv6
 
877
            host = host[1:-1]
 
878
 
 
879
        return cls(scheme, user, password, host, port, path)
 
880
 
 
881
    def __str__(self):
 
882
        netloc = self.quoted_host
 
883
        if ":" in netloc:
 
884
            netloc = "[%s]" % netloc
 
885
        if self.quoted_user is not None:
 
886
            # Note that we don't put the password back even if we
 
887
            # have one so that it doesn't get accidentally
 
888
            # exposed.
 
889
            netloc = '%s@%s' % (self.quoted_user, netloc)
 
890
        if self.port is not None:
 
891
            netloc = '%s:%d' % (netloc, self.port)
 
892
        return urlparse.urlunparse(
 
893
            (self.scheme, netloc, self.quoted_path, None, None, None))
 
894
 
 
895
    @staticmethod
 
896
    def _combine_paths(base_path, relpath):
 
897
        """Transform a Transport-relative path to a remote absolute path.
 
898
 
 
899
        This does not handle substitution of ~ but does handle '..' and '.'
 
900
        components.
 
901
 
 
902
        Examples::
 
903
 
 
904
            t._combine_paths('/home/sarah', 'project/foo')
 
905
                => '/home/sarah/project/foo'
 
906
            t._combine_paths('/home/sarah', '../../etc')
 
907
                => '/etc'
 
908
            t._combine_paths('/home/sarah', '/etc')
 
909
                => '/etc'
 
910
 
 
911
        :param base_path: base path
 
912
        :param relpath: relative url string for relative part of remote path.
 
913
        :return: urlencoded string for final path.
 
914
        """
 
915
        if not isinstance(relpath, str):
 
916
            raise errors.InvalidURL(relpath)
 
917
        relpath = _url_hex_escapes_re.sub(_unescape_safe_chars, relpath)
 
918
        if relpath.startswith('/'):
 
919
            base_parts = []
 
920
        else:
 
921
            base_parts = base_path.split('/')
 
922
        if len(base_parts) > 0 and base_parts[-1] == '':
 
923
            base_parts = base_parts[:-1]
 
924
        for p in relpath.split('/'):
 
925
            if p == '..':
 
926
                if len(base_parts) == 0:
 
927
                    # In most filesystems, a request for the parent
 
928
                    # of root, just returns root.
 
929
                    continue
 
930
                base_parts.pop()
 
931
            elif p == '.':
 
932
                continue # No-op
 
933
            elif p != '':
 
934
                base_parts.append(p)
 
935
        path = '/'.join(base_parts)
 
936
        if not path.startswith('/'):
 
937
            path = '/' + path
 
938
        return path
 
939
 
 
940
    def clone(self, offset=None):
 
941
        """Return a new URL for a path relative to this URL.
 
942
 
 
943
        :param offset: A relative path, already urlencoded
 
944
        :return: `URL` instance
 
945
        """
 
946
        if offset is not None:
 
947
            relative = unescape(offset).encode('utf-8')
 
948
            path = self._combine_paths(self.path, relative)
 
949
            path = quote(path, safe="/~")
 
950
        else:
 
951
            path = self.quoted_path
 
952
        return self.__class__(self.scheme, self.quoted_user,
 
953
                self.quoted_password, self.quoted_host, self.port,
 
954
                path)
 
955
 
768
956
 
769
957
def parse_url(url):
770
958
    """Extract the server address, the credentials and the path from the url.
773
961
    chars.
774
962
 
775
963
    :param url: an quoted url
776
 
 
777
964
    :return: (scheme, user, password, host, port, path) tuple, all fields
778
965
        are unquoted.
779
966
    """
780
 
    if isinstance(url, unicode):
781
 
        raise errors.InvalidURL('should be ascii:\n%r' % url)
782
 
    url = url.encode('utf-8')
783
 
    (scheme, netloc, path, params,
784
 
     query, fragment) = urlparse.urlparse(url, allow_fragments=False)
785
 
    user = password = host = port = None
786
 
    if '@' in netloc:
787
 
        user, host = netloc.rsplit('@', 1)
788
 
        if ':' in user:
789
 
            user, password = user.split(':', 1)
790
 
            password = urllib.unquote(password)
791
 
        user = urllib.unquote(user)
792
 
    else:
793
 
        host = netloc
794
 
 
795
 
    if ':' in host and not (host[0] == '[' and host[-1] == ']'): #there *is* port
796
 
        host, port = host.rsplit(':',1)
797
 
        try:
798
 
            port = int(port)
799
 
        except ValueError:
800
 
            raise errors.InvalidURL('invalid port number %s in url:\n%s' %
801
 
                                    (port, url))
802
 
    if host != "" and host[0] == '[' and host[-1] == ']': #IPv6
803
 
        host = host[1:-1]
804
 
 
805
 
    host = urllib.unquote(host)
806
 
    path = urllib.unquote(path)
807
 
 
808
 
    return (scheme, user, password, host, port, path)
 
967
    parsed_url = URL.from_string(url)
 
968
    return (parsed_url.scheme, parsed_url.user, parsed_url.password,
 
969
        parsed_url.host, parsed_url.port, parsed_url.path)