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