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