cc13a708cbec99e9d4dee6c2c00a592a5216ab8b
[jelmer/subvertpy.git] / transport.py
1 # Copyright (C) 2006 Jelmer Vernooij <jelmer@samba.org>
2 # -*- coding: utf-8 -*-
3
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
8
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17 """Simple transport for accessing Subversion smart servers."""
18
19 import bzrlib
20 from bzrlib import debug, urlutils
21 from bzrlib.errors import (NoSuchFile, TransportNotPossible, 
22                            FileExists, NotLocalUrl, InvalidURL, RedirectRequested)
23 from bzrlib.trace import mutter, warning
24 from bzrlib.transport import Transport
25
26 import bzrlib.plugins.svn
27 from bzrlib.plugins.svn import ra
28 from bzrlib.plugins.svn.auth import create_auth_baton
29 from bzrlib.plugins.svn.client import get_config
30 from bzrlib.plugins.svn.core import SubversionException
31 from bzrlib.plugins.svn.errors import convert_svn_error, NoSvnRepositoryPresent, ERR_BAD_URL, ERR_RA_SVN_REPOS_NOT_FOUND, ERR_FS_ALREADY_EXISTS, ERR_FS_NOT_DIRECTORY, ERR_RA_DAV_RELOCATED, ERR_RA_DAV_PATH_NOT_FOUND
32 import urlparse
33 import urllib
34
35 svn_config = get_config()
36
37 def get_client_string():
38     """Return a string that can be send as part of the User Agent string."""
39     return "bzr%s+bzr-svn%s" % (bzrlib.__version__, bzrlib.plugins.svn.__version__)
40
41  
42 # Don't run any tests on SvnTransport as it is not intended to be 
43 # a full implementation of Transport
44 def get_test_permutations():
45     return []
46
47
48 def get_svn_ra_transport(bzr_transport):
49     """Obtain corresponding SvnRaTransport for a stock Bazaar transport."""
50     if isinstance(bzr_transport, SvnRaTransport):
51         return bzr_transport
52
53     ra_transport = getattr(bzr_transport, "_svn_ra", None)
54     if ra_transport is not None:
55         return ra_transport
56
57     # Save _svn_ra transport here so we don't have to connect again next time
58     # we try to use bzr svn on this transport
59     ra_transport = SvnRaTransport(bzr_transport.base)
60     bzr_transport._svn_ra = ra_transport
61     return ra_transport
62
63
64 def _url_unescape_uri(url):
65     (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
66     if scheme in ("http", "https"):
67         # Without this, URLs with + in them break
68         path = urllib.unquote(path)
69     return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
70
71
72 def _url_escape_uri(url):
73     (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
74     if scheme in ("http", "https"):
75         # Without this, URLs with + in them break
76         path = urllib.quote(path)
77     return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
78
79
80 svnplus_warning_showed = False
81
82 def warn_svnplus(url):
83     global svnplus_warning_showed
84     if not svnplus_warning_showed:
85         warning("The svn+ syntax is deprecated, use %s instead.", url)
86         svnplus_warning_showed = True
87
88
89 def bzr_to_svn_url(url):
90     """Convert a Bazaar URL to a URL understood by Subversion.
91
92     This will possibly remove the svn+ prefix.
93     """
94     if (url.startswith("svn+http://") or 
95         url.startswith("svn+file://") or
96         url.startswith("svn+https://")):
97         url = url[len("svn+"):] # Skip svn+
98         warn_svnplus(url)
99
100     url = _url_unescape_uri(url)
101
102     # The SVN libraries don't like trailing slashes...
103     url = url.rstrip('/')
104
105     return url
106
107
108 def Connection(url):
109     try:
110         mutter('opening SVN RA connection to %r' % url)
111         ret = ra.RemoteAccess(url.encode('utf8'), 
112                 auth=create_auth_baton(url),
113                 client_string_func=get_client_string)
114     except SubversionException, (msg, num):
115         if num in (ERR_RA_SVN_REPOS_NOT_FOUND,):
116             raise NoSvnRepositoryPresent(url=url)
117         if num == ERR_BAD_URL:
118             raise InvalidURL(url)
119         if num == ERR_RA_DAV_PATH_NOT_FOUND:
120             raise NoSuchFile(url)
121         if num == ERR_RA_DAV_RELOCATED:
122             # Try to guess the new url
123             if "'" in msg:
124                 new_url = msg.split("'")[1]
125             elif "«" in msg:
126                 new_url = msg[msg.index("»")+2:msg.index("«")]
127             else:
128                 raise AssertionError("Unable to parse error message: %s" % msg)
129             raise RedirectRequested(source=url, target=new_url, 
130                                     is_permanent=True)
131         raise
132
133     from bzrlib.plugins.svn import lazy_check_versions
134     lazy_check_versions()
135
136     return ret
137
138
139 class ConnectionPool(object):
140     """Collection of connections to a Subversion repository."""
141     def __init__(self):
142         self.connections = set()
143
144     def get(self, url):
145         # Check if there is an existing connection we can use
146         for c in self.connections:
147             assert not c.busy, "busy connection in pool"
148             if c.url == url:
149                 self.connections.remove(c)
150                 return c
151         # Nothing available? Just pick an existing one and reparent:
152         if len(self.connections) == 0:
153             return Connection(url)
154         c = self.connections.pop()
155         try:
156             c.reparent(_url_escape_uri(url))
157             return c
158         except NotImplementedError:
159             self.connections.add(c)
160             return Connection(url)
161         except:
162             self.connections.add(c)
163             raise
164
165     def add(self, connection):
166         assert not connection.busy, "adding busy connection in pool"
167         self.connections.add(connection)
168     
169
170 class SvnRaTransport(Transport):
171     """Fake transport for Subversion-related namespaces.
172     
173     This implements just as much of Transport as is necessary 
174     to fool Bazaar. """
175     @convert_svn_error
176     def __init__(self, url="", pool=None, _uuid=None, _repos_root=None):
177         bzr_url = url
178         self.svn_url = bzr_to_svn_url(url)
179         Transport.__init__(self, bzr_url)
180
181         if pool is None:
182             self.connections = ConnectionPool()
183
184             # Make sure that the URL is valid by connecting to it.
185             self.connections.add(self.connections.get(self.svn_url))
186         else:
187             self.connections = pool
188
189         self._repos_root = _repos_root
190         self._uuid = _uuid
191         self.capabilities = {}
192
193         from bzrlib.plugins.svn import lazy_check_versions
194         lazy_check_versions()
195
196     def get_connection(self, repos_path=None):
197         if repos_path is not None:
198             return self.connections.get(urlutils.join(self.get_svn_repos_root(), 
199                                         repos_path))
200         else:
201             return self.connections.get(self.svn_url)
202
203     def add_connection(self, conn):
204         self.connections.add(conn)
205
206     def has(self, relpath):
207         """See Transport.has()."""
208         # TODO: Raise TransportNotPossible here instead and 
209         # catch it in bzrdir.py
210         return False
211
212     def get(self, relpath):
213         """See Transport.get()."""
214         # TODO: Raise TransportNotPossible here instead and 
215         # catch it in bzrdir.py
216         raise NoSuchFile(path=relpath)
217
218     def stat(self, relpath):
219         """See Transport.stat()."""
220         raise TransportNotPossible('stat not supported on Subversion')
221
222     def put_file(self, name, file, mode=0):
223         raise TransportNotPossible("put_file not supported on Subversion")
224
225     def get_uuid(self):
226         if self._uuid is None:
227             conn = self.get_connection()
228             self.mutter('svn get-uuid')
229             try:
230                 return conn.get_uuid()
231             finally:
232                 self.add_connection(conn)
233         return self._uuid
234
235     def get_repos_root(self):
236         root = self.get_svn_repos_root()
237         if (self.base.startswith("svn+http:") or 
238             self.base.startswith("svn+https:")):
239             return "svn+%s" % root
240         return root
241
242     def get_svn_repos_root(self):
243         if self._repos_root is None:
244             self.mutter('svn get-repos-root')
245             conn = self.get_connection()
246             try:
247                 self._repos_root = conn.get_repos_root()
248             finally:
249                 self.add_connection(conn)
250         return self._repos_root
251
252     def get_latest_revnum(self):
253         conn = self.get_connection()
254         self.mutter('svn get-latest-revnum')
255         try:
256             return conn.get_latest_revnum()
257         finally:
258             self.add_connection(conn)
259
260     def iter_log(self, paths, from_revnum, to_revnum, limit, discover_changed_paths, 
261                  strict_node_history, include_merged_revisions, revprops):
262         assert paths is None or isinstance(paths, list)
263         assert isinstance(from_revnum, int) and isinstance(to_revnum, int)
264         assert isinstance(limit, int)
265         from threading import Thread, Semaphore
266
267         self.mutter('svn iter-log -r%d:%d %r ' % (from_revnum, to_revnum, paths))
268
269         class logfetcher(Thread):
270             def __init__(self, transport, *args, **kwargs):
271                 Thread.__init__(self)
272                 self.setDaemon(True)
273                 self.transport = transport
274                 self.args = args
275                 self.kwargs = kwargs
276                 self.pending = []
277                 self.conn = self.transport.get_connection()
278                 self.semaphore = Semaphore(0)
279                 self.busy = False
280
281             def next(self):
282                 self.semaphore.acquire()
283                 ret = self.pending.pop(0)
284                 if ret is None:
285                     self.transport.add_connection(self.conn)
286                 elif isinstance(ret, Exception):
287                     self.transport.add_connection(self.conn)
288                     raise ret
289                 return ret
290
291             def run(self):
292                 assert not self.busy, "already running"
293                 self.busy = True
294                 def rcvr(*args):
295                     self.pending.append(args)
296                     self.semaphore.release()
297                 try:
298                     try:
299                         self.conn.get_log(callback=rcvr, *self.args, **self.kwargs)
300                         self.pending.append(None)
301                     except Exception, e:
302                         self.pending.append(e)
303                 finally:
304                     self.pending.append(Exception("Some exception was not handled"))
305                     self.semaphore.release()
306
307         if paths is None:
308             newpaths = None
309         else:
310             newpaths = [p.rstrip("/") for p in paths]
311
312         fetcher = logfetcher(self, paths=newpaths, start=from_revnum, end=to_revnum, limit=limit, discover_changed_paths=discover_changed_paths, strict_node_history=strict_node_history, include_merged_revisions=include_merged_revisions, revprops=revprops)
313         fetcher.start()
314         return iter(fetcher.next, None)
315
316     def get_log(self, rcvr, paths, from_revnum, to_revnum, limit, discover_changed_paths, 
317                 strict_node_history, include_merged_revisions, revprops):
318         assert paths is None or isinstance(paths, list), "Invalid paths"
319
320         all_true = True
321         for item in [isinstance(x, str) for x in paths]:
322             if not item:
323                 all_true = False
324                 break
325         
326         assert paths is None or all_true
327
328         self.mutter('svn log -r%d:%d %r' % (from_revnum, to_revnum, paths))
329
330         if paths is None:
331             newpaths = None
332         else:
333             newpaths = [p.rstrip("/") for p in paths]
334
335         conn = self.get_connection()
336         try:
337             return conn.get_log(rcvr, newpaths, 
338                     from_revnum, to_revnum,
339                     limit, discover_changed_paths, strict_node_history, 
340                     include_merged_revisions,
341                     revprops)
342         finally:
343             self.add_connection(conn)
344
345     def change_rev_prop(self, revnum, name, value):
346         conn = self.get_connection()
347         self.mutter('svn change-revprop -r%d %s=%s' % (revnum, name, value))
348         try:
349             return conn.change_rev_prop(revnum, name, value)
350         finally:
351             self.add_connection(conn)
352
353     def get_dir(self, path, revnum, kind=False):
354         conn = self.get_connection()
355         self.mutter('svn get-dir -r%d %s' % (revnum, path))
356         try:
357             return conn.get_dir(path, revnum, kind)
358         finally:
359             self.add_connection(conn)
360
361     def get_file(self, path, stream, revnum):
362         conn = self.get_connection()
363         self.mutter('svn get-file -r%d %s' % (revnum, path))
364         try:
365             return conn.get_file(path, stream, revnum)
366         finally:
367             self.add_connection(conn)
368
369     def mutter(self, text, *args):
370         if 'transport' in debug.debug_flags:
371             mutter(text, *args)
372
373     def list_dir(self, relpath):
374         assert len(relpath) == 0 or relpath[0] != "/"
375         if relpath == ".":
376             relpath = ""
377         try:
378             (dirents, _, _) = self.get_dir(relpath, self.get_latest_revnum())
379         except SubversionException, (msg, num):
380             if num == ERR_FS_NOT_DIRECTORY:
381                 raise NoSuchFile(relpath)
382             raise
383         return dirents.keys()
384
385     def check_path(self, path, revnum):
386         conn = self.get_connection()
387         self.mutter('svn check-path -r%d %s' % (revnum, path))
388         try:
389             return conn.check_path(path, revnum)
390         finally:
391             self.add_connection(conn)
392
393     @convert_svn_error
394     def mkdir(self, relpath, message="Creating directory"):
395         conn = self.get_connection()
396         self.mutter('svn mkdir %s' % (relpath,))
397         try:
398             ce = conn.get_commit_editor({"svn:log": message})
399             try:
400                 node = ce.open_root(-1)
401                 batons = relpath.split("/")
402                 toclose = [node]
403                 for i in range(len(batons)):
404                     node = node.open_directory("/".join(batons[:i]), -1)
405                     toclose.append(node)
406                 toclose.append(node.add_directory(relpath, None, -1))
407                 for c in reversed(toclose):
408                     c.close()
409                 ce.close()
410             except SubversionException, (msg, num):
411                 ce.abort()
412                 if num == ERR_FS_NOT_DIRECTORY:
413                     raise NoSuchFile(msg)
414                 if num == ERR_FS_ALREADY_EXISTS:
415                     raise FileExists(msg)
416                 raise
417         finally:
418             self.add_connection(conn)
419
420     def has_capability(self, cap):
421         if cap in self.capabilities:
422             return self.capabilities[cap]
423         conn = self.get_connection()
424         self.mutter('svn has-capability %s' % (cap,))
425         try:
426             try:
427                 self.capabilities[cap] = conn.has_capability(cap)
428             except NotImplementedError:
429                 self.capabilities[cap] = False # Assume the worst
430             return self.capabilities[cap]
431         finally:
432             self.add_connection(conn)
433
434     def revprop_list(self, revnum):
435         conn = self.get_connection()
436         self.mutter('svn revprop-list -r%d' % (revnum,))
437         try:
438             return conn.rev_proplist(revnum)
439         finally:
440             self.add_connection(conn)
441
442     def get_locations(self, path, peg_revnum, revnums):
443         conn = self.get_connection()
444         self.mutter('svn get_locations -r%d %s (%r)' % (peg_revnum, path, revnums))
445         try:
446             return conn.get_locations(path, peg_revnum, revnums)
447         finally:
448             self.add_connection(conn)
449
450     def listable(self):
451         """See Transport.listable().
452         """
453         return True
454
455     # There is no real way to do locking directly on the transport 
456     # nor is there a need to as the remote server will take care of 
457     # locking
458     class PhonyLock(object):
459         def unlock(self):
460             pass
461
462     def lock_read(self, relpath):
463         """See Transport.lock_read()."""
464         return self.PhonyLock()
465
466     def lock_write(self, path_revs, comment=None, steal_lock=False):
467         return self.PhonyLock() # FIXME
468
469     def _is_http_transport(self):
470         return False
471         return (self.svn_url.startswith("http://") or 
472                 self.svn_url.startswith("https://"))
473
474     def clone_root(self):
475         if self._is_http_transport():
476             return SvnRaTransport(self.get_repos_root(), 
477                                   bzr_to_svn_url(self.base),
478                                   pool=self.connections)
479         return SvnRaTransport(self.get_repos_root(),
480                               pool=self.connections)
481
482     def clone(self, offset=None):
483         """See Transport.clone()."""
484         if offset is None:
485             newurl = self.base
486         else:
487             newurl = urlutils.join(self.base, offset)
488
489         return SvnRaTransport(newurl, pool=self.connections)
490
491     def local_abspath(self, relpath):
492         """See Transport.local_abspath()."""
493         absurl = self.abspath(relpath)
494         if self.base.startswith("file:///"):
495             return urlutils.local_path_from_url(absurl)
496         raise NotLocalUrl(absurl)
497
498     def abspath(self, relpath):
499         """See Transport.abspath()."""
500         return urlutils.join(self.base, relpath)