"""Module containing classes for client-side WebDAV access. DAVResource is the class to use; it points to a location (URL) and offers some methods to retrieve informations about the refered to resource. """ import urllib from urlparse import urlparse, urlunparse, urljoin import httplib from pprint import pprint import libxml2 import davbase from davxml import xml_from_string ### try: False True except NameError: True = 1 False = not True pass _DEFAULT_OWNER = u'pydav-client' _DEFAULT_OWNER2 = u'pydav-client-2' ### def xml_escape( s ): """Escape (quote) special chars for use with xml. """ s = s.replace("&", "&") s = s.replace('"', """) s = s.replace("<", "<") return s.replace(">", ">",) # def _mk_nsdict ( names ): preflist = list('ABCDEFGHIJKLMOPQRSTUVWXYZ') nsdict = {} for n in names: nn, ns = n if not nsdict.has_key(ns): nsdict[ns] = preflist[0] preflist = preflist[1:] return nsdict # def _mk_if_data ( url, locktoken ): s = '<%(url)s>(<%(locktoken)s>)' % locals() return s # def _get_nsuri ( node ): try: ns = node.ns() nsu = ns.get_content() except libxml2.treeError: nsu = None pass return nsu # def _get_nsprefix ( node ): try: ns = node.ns() nsp = ns.get_name() except libxml2.treeError: nsp = None pass return nsp # def _find_child ( node, name ): ## chlds = node.get_children() chlds = node.children ret = [] if type(chlds) != type([]): # XXX introduced this with Py2.1 support while chlds: if chlds.get_name() == name: ret.append(chlds) chlds = chlds.next return ret for n in chlds: if n.get_name() == name: ret.append(n) return ret # ### class DAVError ( Exception ): """Generic DAV exception """ pass # class DAVNoFileError ( DAVError ): """Exception raised if a DAVFile specific method is invoked on a collection. """ pass # class DAVNoCollectionError ( DAVError ): """Exception raised if a collection specific method is invoked on a non-collection. """ pass # class DAVNotFoundError ( DAVError ): """Exception raised if a resource or a property was not found. """ pass # class DAVNotConnectedError ( DAVError ): """Exception raised if there is no connection to the server. """ pass # class DAVLockFailedError ( DAVError ): """Exception raised if an attempt to lock a resource failed. """ pass # class DAVUnlockFailedError ( DAVError ): """Exception raised if an attempt to lock a resource failed. """ pass # class DAVLockedError ( DAVError ): """Exception raised if an atempt to modify or lock a locked resource was made. """ pass # class DAVNotLockedError ( DAVError ): """Exception raised if an atempt to unlock a not locked resource was made. """ pass # class DAVNotOwnerError ( DAVError ): """Exception raised if an atempt to unlock a resource not owned was made. """ pass # class DAVInvalidLocktokenError ( DAVError ): """Exception raised if an atempt to unlock a not locked resource was made. """ pass # class DAVCreationFailedError ( DAVError ): """Exception raised if an atempt to create a resource failed. """ pass # class DAVUploadFailedError ( DAVError ): """Exception raised if an atempt to create a resource failed. """ pass # class DAVDeleteFailedError ( DAVError ): """Exception raised if an atempt to create a resource failed. """ pass # ### class DAVPropstat: def __init__ ( self, doc, ps_node ): self.status = None self.reason = '' self.properties = {} self.locking_info = {} self.description = '' self._parse_ps(doc, ps_node) return def _parse_ps( self, doc, ps_node): xpe = ps_node.nodePath() + '/' st = doc.xpathEval(xpe + 'D:status') if st: st = st[0] stmsg = st.get_content() t = stmsg.split(' ', 3) self.status = int(t[1]) self.reason = t[2] desc = doc.xpathEval(xpe + 'D:responsedescription') if desc: self.description = desc[0].get_content().strip() props = doc.xpathEval(xpe + 'D:prop/*') for p in props: pname = p.get_name() pnsuri = _get_nsuri(p) pkey = (pname, pnsuri) pvalue = p.get_content().strip() ## pprint({'name':pkey, 'value':pvalue}) self.properties[pkey] = pvalue # special cases # resourcetype path = xpe + 'D:prop/D:resourcetype/*' re = doc.xpathEval(path) if not re: # resourcetype not filled self.properties[('resourcetype','DAV:')] = '' else: self.properties[('resourcetype','DAV:')] = re[0].serialize().strip() # locking info linfo = {} path = xpe + 'D:prop/D:lockdiscovery/D:activelock' try: ldelement = doc.xpathEval(path)[0] path = ldelement.nodePath() + '/' linfo['locktype'] = doc.xpathEval(path + 'D:locktype/*')[0].get_name().strip() linfo['lockscope'] = doc.xpathEval(path + 'D:lockscope/*')[0].get_name().strip() linfo['depth'] = doc.xpathEval(path + 'D:depth')[0].get_content().strip() try: linfo['owner'] = doc.xpathEval(path + 'D:owner')[0].serialize().strip() except IndexError: linfo['owner'] = None pass linfo['timeout'] = doc.xpathEval(path + 'D:timeout')[0].get_content().strip() linfo['locktoken'] = doc.xpathEval(path + 'D:locktoken/D:href')[0].get_content().strip() except IndexError: pass self.locking_info = linfo return def has_errors ( self ): s = self.status if s is not None and s >= 300: return True return False def dump ( self ): print "\t\tDAVPropstat Status:", self.status print "\t\tDAVPropstat Reason:", self.reason print "\t\tDAVPropstat Desc:", self.description print "\t\tDAVPropstat Properties:", pprint(self.properties) print "\t\tDAVPropstat Locking info:", pprint(self.locking_info) print # class DAVResponse: def __init__ ( self, doc, res_node ): self.propstats = [] self.url = None self.status = None self.reason = None self._parse_res(doc, res_node) return def _parse_res ( self, doc, res_node ): href_nodes = _find_child(res_node, 'href') if not href_nodes: raise DAVNotFoundError, ('No href found in node %s!' % res_node.nodePath()) url_node = href_nodes[0] self.url = urllib.unquote(url_node.get_content().strip()) status_nodes = _find_child(res_node, 'status') if status_nodes: st = status_nodes[0].get_content() proto, status, reason = st.split(' ', 2) self.status = status self.reason = reason pslist = _find_child(res_node, 'propstat') for node in pslist: ps = DAVPropstat(doc, node) self.propstats.append(ps) return def has_errors ( self ): s = self.status if s is not None and s >= 300: return True for p in self.propstats: if p.has_errors(): return True return False def propstat_count ( self ): return len(self.propstats) def get_propstat ( self, idx=0 ): return self.propstats[idx] def get_all_properties ( self ): ret = {} psl = [ ps.properties for ps in self.propstats if ps.status < 300 ] for p in psl: ret.update(p) return ret def get_locking_info ( self ): ret = {} iil = [ ps.locking_info for ps in self.propstats if ps.status < 300 ] for i in iil: ret.update(i) return ret def dump ( self ): print "\tDAVResponse for", self.url print "\tDAVResponse status:", self.status, self.reason for p in self.propstats: p.dump() # class DAVResult: def __init__ ( self, http_response=None ): """Initialize a DAVResult instance. If http_response is given (and a httplib.HTTPResponse instance) the status code and the reason are copied. If the status code equals 207 (Multi-Status), the body of the response is read and parsed. """ self.responses = {} self.etag = self.status = self.reason = None if http_response is None: return data = http_response.read() self.status = int(http_response.status) self.reason = http_response.reason self.etag = http_response.getheader('ETag', None) self.lock_token = http_response.getheader('Lock-Token', None) if self.lock_token and self.lock_token[0] == '<': self.lock_token = self.lock_token[1:-1] if self.status != 207: return self.parse_data(data) return def parse_data ( self, data ): try: doc = xml_from_string(data) self._parse_response(doc) doc.free() except Exception, ex: raise Exception, (data,) return def _parse_response ( self, doc ): self.responses = {} responses = doc.get_response_nodes() for node in responses: r = DAVResponse(doc, node) self.responses[r.url] = r return def has_errors ( self ): if self.status >= 300: return True for r in self.responses.values(): if r.has_errors(): return True return False def response_count ( self ): return len(self.responses) def get_response ( self, uri ): try: return self.responses[uri] except KeyError as exc: if uri[-1] == '/': return self.responses[uri[:-1]] else: raise def get_locktoken ( self, url ): r = self.responses[url] li = r.get_locking_info() if li.has_key('locktoken'): return li['locktoken'] return None def get_etag ( self, url ): r = self.responses[url] pd = r.get_all_properties() etag = pd.get(('getetag','DAV:'), None) return etag def dump ( self ): print '='*60 print "DAVResult Dump" print "HTTP Status:", self.status print "HTTP Reason:", self.reason for r in self.responses.values(): r.dump() # ### class DAVResource: """Basic class describing an arbitrary DAV resource (file or collection) """ def __init__ ( self, url, conn=None, auto_request=False ): """Setup a fresh instance. """ self._set_url(url) self.auto_request = auto_request self.collection = None self.size = None self.locktoken = None self._conn = conn self._result = None return def _set_url ( self, url ): # extract server/port from url, needed for DAV self.url = url url_tuple = urlparse(url, 'http', 0) self.scheme = url_tuple[0] self.host = url_tuple[1] self.path = url_tuple[2] return def _make_url_for ( self, path ): t = (self.scheme, self.host, path) + ('','','') return urlunparse(t) def get_server ( self ): return self._make_url_for('/') def invalidate ( self ): """Invalidate the internal cache, so that the next method call will invoke a request to the server. """ self.size = None self.etag = None self.collection = None self._result = None return def update ( self, conn=None, depth=0 ): """Update all local data for this resource. Returns a DAVResult instance as result. This result is also stored internally, so don't mess with it. Issues a propfind request with depth 'depth' to the server. If the conn parameter is not None, it sets the connection to the given one. """ if conn is not None: self.set_connection(conn) else: self.invalidate() # just to be sure try: self._result = self._propfind(depth=depth) ## # XXX debug XXX ## self._result.dump() v = self.get_property_value( ('resourcetype', 'DAV:') ) self.collection = v.find('collection') >= 0 except DAVError, ex: if ex.args[0] == 404: raise DAVNotFoundError, ex.args else: raise return self._result def is_connected ( self ): """Return True if there is a connection established. """ return self._conn is not None def set_connection ( self, conn ): """Set the connection. conn has to be a DAV instance from the davlib module. """ self._conn = conn self.invalidate() return def connect ( self ): """Create a connection for this resource. """ h = self.host ht = h.split(':') try: port = int(ht[1]) except (ValueError, IndexError): port = None pass con = davbase.DAVConnection(ht[0], port) self.set_connection(con) return def get_property_namespaces ( self ): """Return a list of tuples with all used namespace uris and their prefixes used for properties. """ if self.auto_request or not self._result: self.update() result = self._result response = result.get_response(self.path) names = response.get_all_properties().keys() d = {} for name, ns in names: d[ns] = None return d.keys() def get_property_names ( self, nsuri=None ): """Return the names of all properties within the given namespace uri If nsuri is empty (or None), return all property names from all namespaces. The result is a list of tuples (name, nsuri). """ props = [] if self.auto_request or not self._result: self.update() result = self._result response = result.get_response(self.path) # get all properties if not nsuri: res = response.get_all_properties().keys() else: res = [ t for t in response.get_all_properties().keys() if t[1] == nsuri ] return res def get_property_value ( self, propname ): """Return the value of the given property. propname is a tuple of (name, nsuri). """ if self.auto_request or not self._result: self.update() result = self._result response = result.get_response(self.path) props = response.get_all_properties() ret = props.get(propname, None) return ret def get_all_properties ( self ): r = self._result.get_response(self.path) ret = r.get_all_properties() return ret def get_etag ( self ): """Return the etag for this resource or None. """ etag = self._result.get_etag(self.path) return etag ## def get_src_link ( self ): ## """If there is a source property return the src link otherwise return None. ## """ ## if self.auto_request or not self._result: ## self.update() ## result = self._result ## res = result.res_doc.xpathEval('//D:source/D:link/D:src') ## if not res: ## return None ## return res[0].get_content() ## def get_dst_link ( self ): ## """If there is a source property, return the dst link otherwise return None. ## """ ## if self.auto_request or not self._result: ## self.update() ## result = self._result ## res = result.res_doc.xpathEval('//D:source/D:link/D:dst') ## if not res: ## return None ## return res[0].get_content() ## def set_dst_link ( self, url ): ## """Set the link/dst element of the source property to the given url. ## Returns a DAVResult instance as result. ## The link/src element is set to the url this resource refers to. ## The urls are xml escaped before they're stored. ## """ ## xml_head = '\n\n' ## xml_head += '' ## xml_tail = '' ## body = '' + xml_escape(url) + '' + xml_escape(self.url) + '' ## xml = xml_head + body + xml_tail ## res = self._proppatch(xml) ## return res def set_properties ( self, pdict ): """Set or update the properties in pdict on this resource. Returns a DAVResult instance as result. The property names (keys of pdict) *have* to be tuples of (name, nsuri). The property values will be xml escaped before they're stored and should be strings. """ # generate xml body for request # make xml header incl. namspace declarations nsdict = _mk_nsdict(pdict.keys()) xml_head = u'\n\n' xml_head += u'' xml_tail = u'' xset = u'' # create body for k, v in pdict.items(): name, nsuri = k if type(v) == type(''): v = v.decode('utf-8') prefix = nsdict[nsuri].decode('utf-8') pn = prefix + u':' + name.decode('utf-8') if not v: v = u'' ## v = v.encode('utf-8') v = xml_escape(v) xset += u'<%s>%s\n' % (pn, v, pn) xml_body = xml_head + xset + xml_tail xml_body = xml_body.encode('utf-8') res = self._proppatch(xml_body) return res def del_properties ( self, plist ): """Delete the properties in plist on this resource. Returns a DAVResult instance as result. plist has to be a list of (name, nsuri) tuples. """ # generate xml body for request nsdict = _mk_nsdict(plist) xml_head = '\n' hdrs['Lock-Token'] = self.locktoken hdrs['If'] = '<%s>(%s)' % (self.url, lt) xml = '\n' xml += '\n' try: self._conn._con._http_vsn_str = 'HTTP/1.0' self._conn._con._http_vsn = 10 try: response = self._conn.propfind(self.url, body=xml, depth=depth, extra_hdrs=hdrs) except davbase.RedirectError, err: new_url = err.args[0] self._set_url(new_url) # re-issue request response = self._conn.propfind(self.url, body=xml, depth=depth, extra_hdrs=hdrs) pass davres = DAVResult(response) finally: self._conn._con._http_vsn_str = 'HTTP/1.1' self._conn._con._http_vsn = 11 if davres.status >= 300: # or davres.status in (404,200): raise DAVError, (davres.status, davres.reason, davres) return davres def _proppatch ( self, body ): """Issue a PROPPATCH request with the given xml body. Returns a DAVResult instance as result. """ if not self.is_connected(): self.connect() hdrs = {} # if we have a locktoken, supply it if self.locktoken is not None: if self.locktoken[0] != '<': lt = '<' + self.locktoken + '>' hdrs['Lock-Token'] = self.locktoken hdrs['If'] = '<%s>(%s)' % (self.url, lt) try: self._conn._con._http_vsn_str = 'HTTP/1.0' self._conn._con._http_vsn = 10 response = self._conn.proppatch(self.url, body=body, extra_hdrs=hdrs) davres = DAVResult(response) finally: self._conn._con._http_vsn_str = 'HTTP/1.1' self._conn._con._http_vsn = 11 if davres.status in (200,404) or davres.status >= 300: raise DAVError, (davres.status, davres.reason, davres) return davres # class DAVFile ( DAVResource ): def __init__ ( self, url, conn=None, auto_request=False ): DAVResource.__init__(self, url, conn, auto_request) self.update() if self.is_collection(): raise DAVNoFileError return def file_size ( self ): """Return the size of this DAVFile in bytes. The size is taken out of the getcontentlength property. If the getcontentlength property is not found, -1 is returned. """ if self.auto_request or not self._result: self.update() try: fs = self.get_property_value( ('getcontentlength', 'DAV:') ) if not fs: fs = 0 except DAVNotFoundError: fs = -1 pass self.size = int(fs) return self.size def upload ( self, data, mime_type=None, encoding=None ): """Upload data to this file via a PUT request. """ if mime_type is None: mime_type = 'application/octet-stream' self.update() if self.is_locked(): linfo = self.get_locking_info() if not (self.locktoken and self.locktoken == linfo['locktoken']): return DAVLockedError hdrs = {} if self.locktoken: hdrs['Lock-Token'] = '<' + self.locktoken + '>' hdrs['If'] = '<%s>(<%s>)' % (self.url, self.locktoken) etag = self.get_etag() ## print 'upload: ETAG:', etag if etag: try: ifclause = hdrs['If'] ifclause += '([%s])' % etag except KeyError: ifclause = '<%s>([%s])' % (self.url, etag) pass hdrs['If'] = ifclause res = self._conn.put(self.url, data, content_type=mime_type, content_enc=encoding, extra_hdrs=hdrs) res = DAVResult(res) if res.status not in (200, 201, 204): raise DAVUploadFailedError, (res.status, res.reason) self.update() return # class DAVCollection ( DAVResource ): def __init__ ( self, url, conn=None, auto_request=False ): """Initialize a fresh DAVCollection instance. Call DAVResource.__init__ and checks if the url points to a collection. If the url does not point to a collection, DAVNoCollectionError is raised. """ if url[-1] != '/': url += '/' DAVResource.__init__(self, url, conn, auto_request) if not self.is_collection(): raise DAVNoCollectionError return def update ( self, conn=None, depth=1 ): return DAVResource.update(self, conn=conn, depth=depth) def get_child_names ( self ): """Return all children of this collection as absolute path names on this server. """ ret = [] if self.auto_request or not self._result: self.update() result = self._result uq = urllib.unquote mypath = uq(self.path) for url, e in result.responses.items(): href = uq(url) if href == mypath: continue ret.append(href) return ret def get_child_objects ( self ): """Return all children of this collection as DAVResources. """ ret = [] if self.auto_request or not self._result: self.update() # for all (except ourself) responses get the href element # and create the appropiate instance for it result = self._result uq = urllib.unquote for url, e in result.responses.items(): if url == self.path: continue href = uq(url) if href == self.path: continue furl = self._make_url_for(href) try: fo = DAVFile(furl, self._conn, self.auto_request) except DAVNoFileError: fo = DAVCollection(furl, self._conn, self.auto_request) pass except DAVError, ex: if ex.args[0] in (403, 404, 405): # forbidden, not found, mehtod not allowed # ignore files one does not have access to continue raise ret.append(fo) return ret def _do_create_collection ( self, name ): conn = self._conn # construct path while name and name[0] == '/': name = name[1:] if name[-1] != '/': name += '/' url = urljoin(self.url, name ) path = urlparse(url, 'http', 0)[2] res = conn.mkcol(path) if res: res = DAVResult(res) return (res, url) def _do_create_file ( self, name, data='', content_type=None, encoding=None ): conn = self._conn # construct path while name and name[0] == '/': name = name[1:] url = urljoin(self.url, name) path = urlparse(url, 'http', 0)[2] # lock resource, should be ok even if the resource does not exist res = self._lock_path(path, owner=_DEFAULT_OWNER2, depth='0') if res.status not in (200, 201): raise DAVLockedError, (res.status, res.reason, url) locktoken = res.lock_token try: # check if there is a resource with that name r = conn.head(url) r.read() if r.status != 404: # resource exists! raise DAVCreationFailedError, (r.status, r.reason, url) # resource does not exist, create file # headers needed to honor the lock hdr = { 'If': '<%s>(<%s>)' % (url, locktoken), 'Lock-Token': '<%s>' % locktoken } res = None res = conn.put(path, data, content_type=content_type, content_enc=encoding, extra_hdrs=hdr) if res: res = DAVResult(res) finally: # unlock resource, even in case of exception self._unlock_path(path, locktoken) return (res, url) def create_collection ( self, name ): """Create a new sub-collection as direct child of this collection. Path names like 'xxx/aaa' are not allowed and will result in an error. Returns a DAVCollection instance refering to the newly create collection. """ res, url = self._do_create_collection(name) if res.status in (200, 201): # created, return collection return DAVCollection(url, self._conn) raise DAVCreationFailedError(res.status, res.reason, url) def create_file ( self, name, data='', content_type=None ): """Create a new file as direct child of this collection. Path names like 'xxx/aaa' are not allowed and will result in an error. Returns a DAVFile instance refering to the newly created file. """ res, url = self._do_create_file(name, data=data, content_type=content_type) if res.status in (200, 201): # created, return file self.update() return DAVFile(url, self._conn) raise DAVCreationFailedError, (res.status, res.reason, url) def _do_del ( self, url, path ): locktoken = self.lock(owner=_DEFAULT_OWNER, depth='infinity') # issue del request and hold result hdr = { 'If': _mk_if_data(url, locktoken) } try: res = self._conn.delete(path, hdr) finally: # unlock resource asap self.unlock(locktoken) return res def delete ( self, name ): """Delete a resource from this collection """ if self.is_locked(): raise DAVLockedError # construct path while name and name[0] == '/': name = name[1:] url = urljoin(self.url, name) path = urlparse(url, 'http', 0)[2] # do delete res = self._do_del(url, path) if res.status >= 300: raise DAVDeleteFailedError, (res.status, res.reason, url) # deleted and done self.update() return # ###