1"""
2fs.contrib.davfs
3================
4
5
6FS implementation accessing a WebDAV server.
7
8This module provides a relatively-complete WebDAV Level 1 client that exposes
9a WebDAV server as an FS object.  Locks are not currently supported.
10
11Requires the dexml module:
12
13    http://pypi.python.org/pypi/dexml/
14
15"""
16#  Copyright (c) 2009-2010, Cloud Matrix Pty. Ltd.
17#  All rights reserved; available under the terms of the MIT License.
18
19from __future__ import with_statement
20
21import os
22import sys
23import httplib
24import socket
25from urlparse import urlparse
26import stat as statinfo
27from urllib import quote as urlquote
28from urllib import unquote as urlunquote
29import base64
30import re
31import time
32import datetime
33import cookielib
34import fnmatch
35import xml.dom.pulldom
36import threading
37from collections import deque
38
39import fs
40from fs.base import *
41from fs.path import *
42from fs.errors import *
43from fs.remote import RemoteFileBuffer
44from fs import iotools
45
46from fs.contrib.davfs.util import *
47from fs.contrib.davfs import xmlobj
48from fs.contrib.davfs.xmlobj import *
49
50import six
51from six import b
52
53import errno
54_RETRYABLE_ERRORS = [errno.EADDRINUSE]
55try:
56    _RETRYABLE_ERRORS.append(errno.ECONNRESET)
57    _RETRYABLE_ERRORS.append(errno.ECONNABORTED)
58except AttributeError:
59    _RETRYABLE_ERRORS.append(104)
60
61
62
63class DAVFS(FS):
64    """Access a remote filesystem via WebDAV.
65
66    This FS implementation provides access to a remote filesystem via the
67    WebDAV protocol.  Basic Level 1 WebDAV is supported; locking is not
68    currently supported, but planned for the future.
69
70    HTTP Basic authentication is supported; provide a dict giving username
71    and password in the "credentials" argument, or a callback for obtaining
72    one in the "get_credentials" argument.
73
74    To use custom HTTP connector classes (e.g. to implement proper certificate
75    checking for SSL connections) you can replace the factory functions in the
76    DAVFS.connection_classes dictionary, or provide the "connection_classes"
77    argument.
78    """
79
80    connection_classes = {
81        "http":  httplib.HTTPConnection,
82        "https":  httplib.HTTPSConnection,
83    }
84
85    _DEFAULT_PORT_NUMBERS = {
86        "http": 80,
87        "https": 443,
88    }
89
90    _meta = { 'virtual' : False,
91              'read_only' : False,
92              'unicode_paths' : True,
93              'case_insensitive_paths' : False,
94              'network' : True
95             }
96
97    def __init__(self,url,credentials=None,get_credentials=None,thread_synchronize=True,connection_classes=None,timeout=None):
98        """DAVFS constructor.
99
100        The only required argument is the root url of the remote server. If
101        authentication is required, provide the 'credentials' keyword argument
102        and/or the 'get_credentials' keyword argument.  The former is a dict
103        of credentials info, while the latter is a callback function returning
104        such a dict. Only HTTP Basic Auth is supported at this stage, so the
105        only useful keys in a credentials dict are 'username' and 'password'.
106        """
107        if not url.endswith("/"):
108            url = url + "/"
109        self.url = url
110        self.timeout = timeout
111        self.credentials = credentials
112        self.get_credentials = get_credentials
113        if connection_classes is not None:
114            self.connection_classes = self.connection_classes.copy()
115            self.connection_classes.update(connection_classes)
116        self._connections = []
117        self._free_connections = {}
118        self._connection_lock = threading.Lock()
119        self._cookiejar = cookielib.CookieJar()
120        super(DAVFS,self).__init__(thread_synchronize=thread_synchronize)
121        #  Check that the server speaks WebDAV, and normalize the URL
122        #  after any redirects have been followed.
123        self.url = url
124        pf = propfind(prop="<prop xmlns='DAV:'><resourcetype /></prop>")
125        resp = self._request("/","PROPFIND",pf.render(),{"Depth":"0"})
126        try:
127            if resp.status == 404:
128                raise ResourceNotFoundError("/",msg="root url gives 404")
129            if resp.status in (401,403):
130                raise PermissionDeniedError("listdir (http %s)" % resp.status)
131            if resp.status != 207:
132                msg = "server at %s doesn't speak WebDAV" % (self.url,)
133                raise RemoteConnectionError("",msg=msg,details=resp.read())
134        finally:
135            resp.close()
136        self.url = resp.request_url
137        self._url_p = urlparse(self.url)
138
139    def close(self):
140        for con in self._connections:
141            con.close()
142        super(DAVFS,self).close()
143
144    def _take_connection(self,url):
145        """Get a connection to the given url's host, re-using if possible."""
146        scheme = url.scheme.lower()
147        hostname = url.hostname
148        port = url.port
149        if not port:
150            try:
151                port = self._DEFAULT_PORT_NUMBERS[scheme]
152            except KeyError:
153                msg = "unsupported protocol: '%s'" % (url.scheme,)
154                raise RemoteConnectionError(msg=msg)
155        #  Can we re-use an existing connection?
156        with self._connection_lock:
157            now = time.time()
158            try:
159                free_connections = self._free_connections[(hostname,port)]
160            except KeyError:
161                self._free_connections[(hostname,port)] = deque()
162                free_connections = self._free_connections[(hostname,port)]
163            else:
164                while free_connections:
165                    (when,con) = free_connections.popleft()
166                    if when + 30 > now:
167                        return (False,con)
168                    self._discard_connection(con)
169        #  Nope, we need to make a fresh one.
170        try:
171            ConClass = self.connection_classes[scheme]
172        except KeyError:
173            msg = "unsupported protocol: '%s'" % (url.scheme,)
174            raise RemoteConnectionError(msg=msg)
175        con = ConClass(url.hostname,url.port,timeout=self.timeout)
176        self._connections.append(con)
177        return (True,con)
178
179    def _give_connection(self,url,con):
180        """Return a connection to the pool, or destroy it if dead."""
181        scheme = url.scheme.lower()
182        hostname = url.hostname
183        port = url.port
184        if not port:
185            try:
186                port = self._DEFAULT_PORT_NUMBERS[scheme]
187            except KeyError:
188                msg = "unsupported protocol: '%s'" % (url.scheme,)
189                raise RemoteConnectionError(msg=msg)
190        with self._connection_lock:
191            now = time.time()
192            try:
193                free_connections = self._free_connections[(hostname,port)]
194            except KeyError:
195                self._free_connections[(hostname,port)] = deque()
196                free_connections = self._free_connections[(hostname,port)]
197            free_connections.append((now,con))
198
199    def _discard_connection(self,con):
200        con.close()
201        self._connections.remove(con)
202
203    def __str__(self):
204        return '<DAVFS: %s>' % (self.url,)
205    __repr__ = __str__
206
207    def __getstate__(self):
208        state = super(DAVFS,self).__getstate__()
209        del state["_connection_lock"]
210        del state["_connections"]
211        del state["_free_connections"]
212        # Python2.5 cannot load pickled urlparse.ParseResult objects.
213        del state["_url_p"]
214        # CookieJar objects contain a lock, so they can't be pickled.
215        del state["_cookiejar"]
216        return state
217
218    def __setstate__(self,state):
219        super(DAVFS,self).__setstate__(state)
220        self._connections = []
221        self._free_connections = {}
222        self._connection_lock = threading.Lock()
223        self._url_p = urlparse(self.url)
224        self._cookiejar = cookielib.CookieJar()
225
226    def getpathurl(self, path, allow_none=False):
227        """Convert a client-side path into a server-side URL."""
228        path = relpath(normpath(path))
229        if path.endswith("/"):
230            path = path[:-1]
231        if isinstance(path,unicode):
232            path = path.encode("utf8")
233        return self.url + urlquote(path)
234
235    def _url2path(self,url):
236        """Convert a server-side URL into a client-side path."""
237        path = urlunquote(urlparse(url).path)
238        root = urlunquote(self._url_p.path)
239        path = path[len(root)-1:].decode("utf8")
240        while path.endswith("/"):
241            path = path[:-1]
242        return path
243
244    def _isurl(self,path,url):
245        """Check whether the given URL corresponds to the given local path."""
246        path = normpath(relpath(path))
247        upath = relpath(normpath(self._url2path(url)))
248        return path == upath
249
250    def _request(self,path,method,body="",headers={}):
251        """Issue a HTTP request to the remote server.
252
253        This is a simple wrapper around httplib that does basic error and
254        sanity checking e.g. following redirects and providing authentication.
255        """
256        url = self.getpathurl(path)
257        visited = []
258        resp = None
259        try:
260            resp = self._raw_request(url,method,body,headers)
261            #  Loop to retry for redirects and authentication responses.
262            while resp.status in (301,302,401,403):
263                resp.close()
264                if resp.status in (301,302,):
265                    visited.append(url)
266                    url = resp.getheader("Location",None)
267                    if not url:
268                        raise OperationFailedError(msg="no location header in 301 response")
269                    if url in visited:
270                        raise OperationFailedError(msg="redirection seems to be looping")
271                    if len(visited) > 10:
272                        raise OperationFailedError("too much redirection")
273                elif resp.status in (401,403):
274                    if self.get_credentials is None:
275                        break
276                    else:
277                        creds = self.get_credentials(self.credentials)
278                        if creds is None:
279                            break
280                        else:
281                            self.credentials = creds
282                resp = self._raw_request(url,method,body,headers)
283        except Exception:
284            if resp is not None:
285                resp.close()
286            raise
287        resp.request_url = url
288        return resp
289
290    def _raw_request(self,url,method,body,headers,num_tries=0):
291        """Perform a single HTTP request, without any error handling."""
292        if self.closed:
293            raise RemoteConnectionError("",msg="FS is closed")
294        if isinstance(url,basestring):
295            url = urlparse(url)
296        if self.credentials is not None:
297            username = self.credentials.get("username","")
298            password = self.credentials.get("password","")
299            if username is not None and password is not None:
300                creds = "%s:%s" % (username,password,)
301                creds = "Basic %s" % (base64.b64encode(creds).strip(),)
302                headers["Authorization"] = creds
303        (size,chunks) = normalize_req_body(body)
304        try:
305            (fresh,con) = self._take_connection(url)
306            try:
307                con.putrequest(method,url.path)
308                if size is not None:
309                    con.putheader("Content-Length",str(size))
310                if hasattr(body,"md5"):
311                    md5 = body.md5.decode("hex").encode("base64")
312                    con.putheader("Content-MD5",md5)
313                for hdr,val in headers.iteritems():
314                    con.putheader(hdr,val)
315                self._cookiejar.add_cookie_header(FakeReq(con,url.scheme,url.path))
316                con.endheaders()
317                for chunk in chunks:
318                    con.send(chunk)
319                    if self.closed:
320                        raise RemoteConnectionError("",msg="FS is closed")
321                resp = con.getresponse()
322                self._cookiejar.extract_cookies(FakeResp(resp),FakeReq(con,url.scheme,url.path))
323            except Exception:
324                self._discard_connection(con)
325                raise
326            else:
327                old_close = resp.close
328                def new_close():
329                    del resp.close
330                    old_close()
331                    con.close()
332                    self._give_connection(url,con)
333                resp.close = new_close
334                return resp
335        except socket.error, e:
336            if not fresh:
337                return self._raw_request(url,method,body,headers,num_tries)
338            if e.args[0] in _RETRYABLE_ERRORS:
339                if num_tries < 3:
340                    num_tries += 1
341                    return self._raw_request(url,method,body,headers,num_tries)
342            try:
343                msg = e.args[1]
344            except IndexError:
345                msg = str(e)
346            raise RemoteConnectionError("",msg=msg,details=e)
347
348    def setcontents(self,path, data=b'', encoding=None, errors=None, chunk_size=1024 * 64):
349        if isinstance(data, six.text_type):
350            data = data.encode(encoding=encoding, errors=errors)
351        resp = self._request(path, "PUT", data)
352        resp.close()
353        if resp.status == 405:
354            raise ResourceInvalidError(path)
355        if resp.status == 409:
356            raise ParentDirectoryMissingError(path)
357        if resp.status not in (200,201,204):
358            raise_generic_error(resp,"setcontents",path)
359
360    @iotools.filelike_to_stream
361    def open(self,path,mode="r", **kwargs):
362        mode = mode.replace("b","").replace("t","")
363        # Truncate the file if requested
364        contents = b("")
365        if "w" in mode:
366            self.setcontents(path,contents)
367        else:
368            contents = self._request(path,"GET")
369            if contents.status == 404:
370                # Create the file if it's missing in append mode.
371                if "a" not in mode:
372                    contents.close()
373                    raise ResourceNotFoundError(path)
374                contents = b("")
375                self.setcontents(path,contents)
376            elif contents.status in (401,403):
377                contents.close()
378                raise PermissionDeniedError("open")
379            elif contents.status != 200:
380                contents.close()
381                raise_generic_error(contents,"open",path)
382            elif self.isdir(path):
383                contents.close()
384                raise ResourceInvalidError(path)
385        #  For streaming reads, return the socket contents directly.
386        if mode == "r-":
387            contents.size = contents.getheader("Content-Length",None)
388            if contents.size is not None:
389                try:
390                    contents.size = int(contents.size)
391                except ValueError:
392                    contents.size = None
393            if not hasattr(contents,"__exit__"):
394                contents.__enter__ = lambda *a: contents
395                contents.__exit__ = lambda *a: contents.close()
396            return contents
397        #  For everything else, use a RemoteFileBuffer.
398        #  This will take care of closing the socket when it's done.
399        return RemoteFileBuffer(self,path,mode,contents)
400
401    def exists(self,path):
402        pf = propfind(prop="<prop xmlns='DAV:'><resourcetype /></prop>")
403        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
404        response.close()
405        if response.status == 207:
406            return True
407        if response.status == 404:
408            return False
409        raise_generic_error(response,"exists",path)
410
411    def isdir(self,path):
412        pf = propfind(prop="<prop xmlns='DAV:'><resourcetype /></prop>")
413        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
414        try:
415            if response.status == 404:
416                return False
417            if response.status != 207:
418                raise_generic_error(response,"isdir",path)
419            body = response.read()
420            msres = multistatus.parse(body)
421            for res in msres.responses:
422                if self._isurl(path,res.href):
423                   for ps in res.propstats:
424                       if ps.props.getElementsByTagNameNS("DAV:","collection"):
425                           return True
426            return False
427        finally:
428            response.close()
429
430    def isfile(self,path):
431        pf = propfind(prop="<prop xmlns='DAV:'><resourcetype /></prop>")
432        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
433        try:
434            if response.status == 404:
435                return False
436            if response.status != 207:
437                raise_generic_error(response,"isfile",path)
438            msres = multistatus.parse(response.read())
439            for res in msres.responses:
440               if self._isurl(path,res.href):
441                  for ps in res.propstats:
442                     rt = ps.props.getElementsByTagNameNS("DAV:","resourcetype")
443                     cl = ps.props.getElementsByTagNameNS("DAV:","collection")
444                     if rt and not cl:
445                        return True
446            return False
447        finally:
448            response.close()
449
450    def listdir(self,path="./",wildcard=None,full=False,absolute=False,dirs_only=False,files_only=False):
451        return list(self.ilistdir(path=path,wildcard=wildcard,full=full,absolute=absolute,dirs_only=dirs_only,files_only=files_only))
452
453    def ilistdir(self,path="./",wildcard=None,full=False,absolute=False,dirs_only=False,files_only=False):
454        props = "<D:resourcetype />"
455        dir_ok = False
456        for res in self._do_propfind(path,props):
457            if self._isurl(path,res.href):
458               # The directory itself, check it's actually a directory
459               for ps in res.propstats:
460                   if ps.props.getElementsByTagNameNS("DAV:","collection"):
461                      dir_ok = True
462                      break
463            else:
464                nm = basename(self._url2path(res.href))
465                entry_ok = False
466                if dirs_only:
467                    for ps in res.propstats:
468                        if ps.props.getElementsByTagNameNS("DAV:","collection"):
469                            entry_ok = True
470                            break
471                elif files_only:
472                    for ps in res.propstats:
473                        if ps.props.getElementsByTagNameNS("DAV:","collection"):
474                            break
475                    else:
476                        entry_ok = True
477                else:
478                    entry_ok = True
479                if not entry_ok:
480                    continue
481                if wildcard is not None:
482                    if isinstance(wildcard,basestring):
483                        if not fnmatch.fnmatch(nm,wildcard):
484                            continue
485                    else:
486                        if not wildcard(nm):
487                            continue
488                if full:
489                    yield relpath(pathjoin(path,nm))
490                elif absolute:
491                    yield abspath(pathjoin(path,nm))
492                else:
493                    yield nm
494        if not dir_ok:
495            raise ResourceInvalidError(path)
496
497    def listdirinfo(self,path="./",wildcard=None,full=False,absolute=False,dirs_only=False,files_only=False):
498        return list(self.ilistdirinfo(path=path,wildcard=wildcard,full=full,absolute=absolute,dirs_only=dirs_only,files_only=files_only))
499
500    def ilistdirinfo(self,path="./",wildcard=None,full=False,absolute=False,dirs_only=False,files_only=False):
501        props = "<D:resourcetype /><D:getcontentlength />" \
502                "<D:getlastmodified /><D:getetag />"
503        dir_ok = False
504        for res in self._do_propfind(path,props):
505            if self._isurl(path,res.href):
506               # The directory itself, check it's actually a directory
507               for ps in res.propstats:
508                   if ps.props.getElementsByTagNameNS("DAV:","collection"):
509                      dir_ok = True
510                      break
511            else:
512                # An entry in the directory, check if it's of the
513                # appropriate type and add to entries list as required.
514                info = self._info_from_propfind(res)
515                nm = basename(self._url2path(res.href))
516                entry_ok = False
517                if dirs_only:
518                    for ps in res.propstats:
519                        if ps.props.getElementsByTagNameNS("DAV:","collection"):
520                            entry_ok = True
521                            break
522                elif files_only:
523                    for ps in res.propstats:
524                        if ps.props.getElementsByTagNameNS("DAV:","collection"):
525                            break
526                    else:
527                        entry_ok = True
528                else:
529                    entry_ok = True
530                if not entry_ok:
531                    continue
532                if wildcard is not None:
533                    if isinstance(wildcard,basestring):
534                        if not fnmatch.fnmatch(nm,wildcard):
535                            continue
536                    else:
537                        if not wildcard(nm):
538                            continue
539                if full:
540                    yield (relpath(pathjoin(path,nm)),info)
541                elif absolute:
542                    yield (abspath(pathjoin(path,nm)),info)
543                else:
544                    yield (nm,info)
545        if not dir_ok:
546            raise ResourceInvalidError(path)
547
548    def makedir(self,path,recursive=False,allow_recreate=False):
549        response = self._request(path,"MKCOL")
550        response.close()
551        if response.status == 201:
552            return True
553        if response.status == 409:
554            if not recursive:
555                raise ParentDirectoryMissingError(path)
556            self.makedir(dirname(path),recursive=True,allow_recreate=True)
557            self.makedir(path,recursive=False,allow_recreate=allow_recreate)
558            return True
559        if response.status == 405:
560            if not self.isdir(path):
561                raise ResourceInvalidError(path)
562            if not allow_recreate:
563                raise DestinationExistsError(path)
564            return True
565        if response.status < 200 or response.status >= 300:
566            raise_generic_error(response,"makedir",path)
567
568    def remove(self,path):
569        if self.isdir(path):
570            raise ResourceInvalidError(path)
571        response = self._request(path,"DELETE")
572        response.close()
573        if response.status == 405:
574            raise ResourceInvalidError(path)
575        if response.status < 200 or response.status >= 300:
576            raise_generic_error(response,"remove",path)
577        return True
578
579    def removedir(self,path,recursive=False,force=False):
580        if self.isfile(path):
581            raise ResourceInvalidError(path)
582        if not force and self.listdir(path):
583            raise DirectoryNotEmptyError(path)
584        response = self._request(path,"DELETE")
585        response.close()
586        if response.status == 405:
587            raise ResourceInvalidError(path)
588        if response.status < 200 or response.status >= 300:
589            raise_generic_error(response,"removedir",path)
590        if recursive and path not in ("","/"):
591            try:
592                self.removedir(dirname(path),recursive=True)
593            except DirectoryNotEmptyError:
594                pass
595        return True
596
597    def rename(self,src,dst):
598        self._move(src,dst)
599
600    def getinfo(self,path):
601        info = {}
602        info["name"] = basename(path)
603        pf = propfind(prop="<prop xmlns='DAV:'><resourcetype /><getcontentlength /><getlastmodified /><getetag /></prop>")
604        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
605        try:
606            if response.status != 207:
607                raise_generic_error(response,"getinfo",path)
608            msres = multistatus.parse(response.read())
609            for res in msres.responses:
610                if self._isurl(path,res.href):
611                    info.update(self._info_from_propfind(res))
612            if "st_mode" not in info:
613               info["st_mode"] = 0700 | statinfo.S_IFREG
614            return info
615        finally:
616            response.close()
617
618    def _do_propfind(self,path,props):
619        """Incremental PROPFIND parsing, for use with ilistdir/ilistdirinfo.
620
621        This generator method incrementally parses the results returned by
622        a PROPFIND, yielding each <response> object as it becomes available.
623        If the server is able to send responses in chunked encoding, then
624        this can substantially speed up iterating over the results.
625        """
626        pf = propfind(prop="<D:prop xmlns:D='DAV:'>"+props+"</D:prop>")
627        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"1"})
628        try:
629            if response.status == 404:
630                raise ResourceNotFoundError(path)
631            if response.status != 207:
632                raise_generic_error(response,"listdir",path)
633            xmlevents = xml.dom.pulldom.parse(response,bufsize=1024)
634            for (evt,node) in xmlevents:
635                if evt == xml.dom.pulldom.START_ELEMENT:
636                    if node.namespaceURI == "DAV:":
637                        if node.localName == "response":
638                            xmlevents.expandNode(node)
639                            yield xmlobj.response.parse(node)
640        finally:
641            response.close()
642
643    def _info_from_propfind(self,res):
644        info = {}
645        for ps in res.propstats:
646            findElements = ps.props.getElementsByTagNameNS
647            # TODO: should check for status of the propfind first...
648            # check for directory indicator
649            if findElements("DAV:","collection"):
650                info["st_mode"] = 0700 | statinfo.S_IFDIR
651            # check for content length
652            cl = findElements("DAV:","getcontentlength")
653            if cl:
654                cl = "".join(c.nodeValue for c in cl[0].childNodes)
655                try:
656                    info["size"] = int(cl)
657                except ValueError:
658                    pass
659            # check for last modified time
660            lm = findElements("DAV:","getlastmodified")
661            if lm:
662                lm = "".join(c.nodeValue for c in lm[0].childNodes)
663                try:
664                    # TODO: more robust datetime parsing
665                    fmt = "%a, %d %b %Y %H:%M:%S GMT"
666                    mtime = datetime.datetime.strptime(lm,fmt)
667                    info["modified_time"] = mtime
668                except ValueError:
669                    pass
670            # check for etag
671            etag = findElements("DAV:","getetag")
672            if etag:
673                etag = "".join(c.nodeValue for c in etag[0].childNodes)
674                if etag:
675                    info["etag"] = etag
676        if "st_mode" not in info:
677            info["st_mode"] = 0700 | statinfo.S_IFREG
678        return info
679
680
681    def copy(self,src,dst,overwrite=False,chunk_size=None):
682        if self.isdir(src):
683            msg = "Source is not a file: %(path)s"
684            raise ResourceInvalidError(src, msg=msg)
685        self._copy(src,dst,overwrite=overwrite)
686
687    def copydir(self,src,dst,overwrite=False,ignore_errors=False,chunk_size=0):
688        if self.isfile(src):
689            msg = "Source is not a directory: %(path)s"
690            raise ResourceInvalidError(src, msg=msg)
691        self._copy(src,dst,overwrite=overwrite)
692
693    def _copy(self,src,dst,overwrite=False):
694        headers = {"Destination":self.getpathurl(dst)}
695        if overwrite:
696            headers["Overwrite"] = "T"
697        else:
698            headers["Overwrite"] = "F"
699        response = self._request(src,"COPY",headers=headers)
700        response.close()
701        if response.status == 412:
702            raise DestinationExistsError(dst)
703        if response.status == 409:
704            raise ParentDirectoryMissingError(dst)
705        if response.status < 200 or response.status >= 300:
706            raise_generic_error(response,"copy",src)
707
708    def move(self,src,dst,overwrite=False,chunk_size=None):
709        if self.isdir(src):
710            msg = "Source is not a file: %(path)s"
711            raise ResourceInvalidError(src, msg=msg)
712        self._move(src,dst,overwrite=overwrite)
713
714    def movedir(self,src,dst,overwrite=False,ignore_errors=False,chunk_size=0):
715        if self.isfile(src):
716            msg = "Source is not a directory: %(path)s"
717            raise ResourceInvalidError(src, msg=msg)
718        self._move(src,dst,overwrite=overwrite)
719
720    def _move(self,src,dst,overwrite=False):
721        headers = {"Destination":self.getpathurl(dst)}
722        if overwrite:
723            headers["Overwrite"] = "T"
724        else:
725            headers["Overwrite"] = "F"
726        response = self._request(src,"MOVE",headers=headers)
727        response.close()
728        if response.status == 412:
729            raise DestinationExistsError(dst)
730        if response.status == 409:
731            raise ParentDirectoryMissingError(dst)
732        if response.status < 200 or response.status >= 300:
733            raise_generic_error(response,"move",src)
734
735    @staticmethod
736    def _split_xattr(name):
737        """Split extended attribute name into (namespace,localName) pair."""
738        idx = len(name)-1
739        while idx >= 0 and name[idx].isalnum():
740            idx -= 1
741        return (name[:idx+1],name[idx+1:])
742
743    def getxattr(self,path,name,default=None):
744        (namespaceURI,localName) = self._split_xattr(name)
745        # TODO: encode xml character entities in the namespace
746        if namespaceURI:
747            pf = propfind(prop="<D:prop xmlns:D='DAV:' xmlns='"+namespaceURI+"'><"+localName+" /></D:prop>")
748        else:
749            pf = propfind(prop="<D:prop xmlns:D='DAV:'><"+localName+" /></D:prop>")
750        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
751        try:
752            if response.status != 207:
753                raise_generic_error(response,"getxattr",path)
754            msres = multistatus.parse(response.read())
755        finally:
756            response.close()
757        for res in msres.responses:
758            if self._isurl(path,res.href):
759               for ps in res.propstats:
760                   if namespaceURI:
761                       findElements = ps.props.getElementsByTagNameNS
762                       propNode = findElements(namespaceURI,localName)
763                   else:
764                       findElements = ps.props.getElementsByTagName
765                       propNode = findElements(localName)
766                   if propNode:
767                       propNode = propNode[0]
768                       if ps.status.code == 200:
769                         return "".join(c.toxml() for c in propNode.childNodes)
770                       if ps.status.code == 404:
771                         return default
772                   raise OperationFailedError("getxattr",msres.render())
773        return default
774
775    def setxattr(self,path,name,value):
776        (namespaceURI,localName) = self._split_xattr(name)
777        # TODO: encode xml character entities in the namespace
778        if namespaceURI:
779            p = "<%s xmlns='%s'>%s</%s>" % (localName,namespaceURI,value,localName)
780        else:
781            p = "<%s>%s</%s>" % (localName,value,localName)
782        pu = propertyupdate()
783        pu.commands.append(set(props="<D:prop xmlns:D='DAV:'>"+p+"</D:prop>"))
784        response = self._request(path,"PROPPATCH",pu.render(),{"Depth":"0"})
785        response.close()
786        if response.status < 200 or response.status >= 300:
787            raise_generic_error(response,"setxattr",path)
788
789    def delxattr(self,path,name):
790        (namespaceURI,localName) = self._split_xattr(name)
791        # TODO: encode xml character entities in the namespace
792        if namespaceURI:
793            p = "<%s xmlns='%s' />" % (localName,namespaceURI,)
794        else:
795            p = "<%s />" % (localName,)
796        pu = propertyupdate()
797        pu.commands.append(remove(props="<D:prop xmlns:D='DAV:'>"+p+"</D:prop>"))
798        response = self._request(path,"PROPPATCH",pu.render(),{"Depth":"0"})
799        response.close()
800        if response.status < 200 or response.status >= 300:
801            raise_generic_error(response,"delxattr",path)
802
803    def listxattrs(self,path):
804        pf = propfind(propname=True)
805        response = self._request(path,"PROPFIND",pf.render(),{"Depth":"0"})
806        try:
807            if response.status != 207:
808                raise_generic_error(response,"listxattrs",path)
809            msres = multistatus.parse(response.read())
810        finally:
811            response.close()
812        props = []
813        for res in msres.responses:
814            if self._isurl(path,res.href):
815               for ps in res.propstats:
816                   for node in ps.props.childNodes:
817                       if node.nodeType != node.ELEMENT_NODE:
818                           continue
819                       if node.namespaceURI:
820                           if node.namespaceURI in ("DAV:","PYFS:",):
821                               continue
822                           propname = node.namespaceURI + node.localName
823                       else:
824                           propname = node.nodeName
825                       props.append(propname)
826        return props
827
828    # TODO: bulk getxattrs() and setxattrs() methods
829
830
831
832def raise_generic_error(response,opname,path):
833    if response.status == 404:
834        raise ResourceNotFoundError(path,details=response.read())
835    if response.status in (401,403):
836        raise PermissionDeniedError(opname,details=response.read())
837    if response.status == 423:
838        raise ResourceLockedError(path,opname=opname,details=response.read())
839    if response.status == 501:
840        raise UnsupportedError(opname,details=response.read())
841    if response.status == 405:
842        raise ResourceInvalidError(path,opname=opname,details=response.read())
843    raise OperationFailedError(opname,msg="Server Error: %s" % (response.status,),details=response.read())
844
845