Skip to content

Commit f7cdc46

Browse files
committed
[IMP] File object / CRUD / add methods : get_file, get_folder, list, download, upload_file, delete
1 parent a585f29 commit f7cdc46

File tree

4 files changed

+211
-25
lines changed

4 files changed

+211
-25
lines changed

src/nextcloud/api_wrappers/webdav.py

Lines changed: 183 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,34 @@
1515
from nextcloud.base import WebDAVApiWrapper
1616
from nextcloud.common.collections import PropertySet
1717
from nextcloud.common.properties import Property as Prop, NAMESPACES_MAP
18-
from nextcloud.common.value_parsing import timestamp_to_epoch_time
18+
from nextcloud.common.value_parsing import (
19+
timestamp_to_epoch_time,
20+
datetime_to_timestamp
21+
)
22+
23+
24+
class NextCloudFileConflict(Exception):
25+
""" Exception to raise when you try to create a File that alreay exists """
1926

2027

2128
class File(PropertySet):
29+
"""
30+
Define properties on a WebDav file/folder
31+
32+
Additionnally, provide an objective CRUD API
33+
(that probably consume more energy than fetching specific attributes)
34+
35+
Example :
36+
>>> root = nxc.get_folder() # get root
37+
>>> def _list_rec(d, indent=""):
38+
>>> # list files recursively
39+
>>> print("%s%s%s" % (indent, d.basename(), '/' if d.isdir() else ''))
40+
>>> if d.isdir():
41+
>>> for i in d.list():
42+
>>> _list_rec(i, indent=indent+" ")
43+
>>>
44+
>>> _list_rec(root)
45+
"""
2246
_attrs = [
2347
Prop('d:getlastmodified'),
2448
Prop('d:getetag'),
@@ -56,6 +80,97 @@ def isdir(self):
5680
""" say if the file is a directory /!\\ ressourcetype property shall be loaded """
5781
return self.resource_type == self.COLLECTION_RESOURCE_TYPE
5882

83+
def get_relative_path(self):
84+
""" get path relative to user root """
85+
return self._wrapper.get_relative_path(self.href)
86+
87+
def _get_remote_path(self, path=None):
88+
_url = self.get_relative_path()
89+
return '/'.join([_url, path]) if path else _url
90+
91+
def basename(self):
92+
""" basename """
93+
_path = self._get_remote_path()
94+
return _path.split('/')[-2] if _path.endswith('/') else _path.split('/')[-1]
95+
96+
def dirname(self):
97+
""" dirname """
98+
_path = self._get_remote_path()
99+
return '/'.join(_path.split('/')[:-2]) if _path.endswith('/') else '/'.join(_path.split('/')[:-1])
100+
101+
def __eq__(self, b):
102+
return self.href == b.href
103+
104+
# MINIMAL SET OF CRUD OPERATIONS
105+
def get_folder(self, path=None):
106+
"""
107+
Get folder (see WebDav wrapper)
108+
:param subpath: if empty list current dir
109+
:returns: a folder (File object)
110+
111+
Note : To check if sub folder exists, use get_file method
112+
"""
113+
return self._wrapper.get_folder(self._get_remote_path(path))
114+
115+
def get_folder(self, path=None):
116+
"""
117+
Get folder (see WebDav wrapper)
118+
:param subpath: if empty list current dir
119+
:returns: a file or folder (File object)
120+
"""
121+
return self._wrapper.get_file(self._get_remote_path(path))
122+
123+
def list(self, subpath=''):
124+
"""
125+
List folder (see WebDav wrapper)
126+
:param subpath: if empty list current dir
127+
:returns: list of Files
128+
"""
129+
resp = self._wrapper.list_folders(
130+
self._get_remote_path(subpath),
131+
depth=1,
132+
all_properties=True
133+
)
134+
if resp.is_ok and resp.data:
135+
_dirs = resp.data
136+
# remove current dir
137+
if _dirs[0] == self:
138+
_dirs = _dirs[1:]
139+
return _dirs
140+
return []
141+
142+
def upload_file(self, local_filepath, name, timestamp=None):
143+
"""
144+
Upload file (see WebDav wrapper)
145+
:param name: name of the new file
146+
:returns: True if success
147+
"""
148+
resp = self._wrapper.upload_file(local_filepath,
149+
self._get_remote_path(name),
150+
timestamp=timestamp)
151+
return resp.is_ok
152+
153+
def download(self, name=None, target_dir=None):
154+
"""
155+
file (see WebDav wrapper)
156+
:param name: name of the new file
157+
:returns: True if success
158+
"""
159+
path = self._get_remote_path(name)
160+
target_path, _file_info = self._wrapper.download_file(path,
161+
target_dir=target_dir)
162+
assert os.path.isfile(target_path), "Download failed"
163+
return target_path
164+
165+
def delete(self, subpath=''):
166+
"""
167+
Delete file or folder (see WebDav wrapper)
168+
:param subpath: if empty, delete current file
169+
:returns: True if success
170+
"""
171+
resp = self._wrapper.delete_path(self._get_remote_path(subpath))
172+
return resp.is_ok
173+
59174

60175
class WebDAV(WebDAVApiWrapper):
61176
""" WebDav API wrapper """
@@ -90,9 +205,10 @@ def list_folders(self, path=None, depth=1, all_properties=False,
90205
resp = self.requester.propfind(additional_url=self._get_path(path),
91206
headers={'Depth': str(depth)},
92207
data=data)
93-
return File.from_response(resp, json_output=(self.json_output))
208+
return File.from_response(resp, json_output=self.json_output,
209+
wrapper=self)
94210

95-
def download_file(self, path):
211+
def download_file(self, path, target_dir=None):
96212
"""
97213
Download file by path (for current user)
98214
File will be saved to working directory
@@ -108,32 +224,35 @@ def download_file(self, path):
108224
path (str): file path
109225
110226
Returns:
111-
None
227+
a tuple (target_path, File object)
112228
"""
229+
if not target_dir:
230+
target_dir='./'
113231
filename = path.split('/')[(-1)] if '/' in path else path
114-
file_data = self.list_folders(path=path, depth=0)
232+
file_data = self.get_file(path)
115233
if not file_data:
116234
raise ValueError("Given path doesn't exist")
117-
file_resource_type = (file_data.data[0].get('resource_type')
118-
if self.json_output
119-
else file_data.data[0].resource_type)
235+
file_resource_type = file_data.resource_type
120236
if file_resource_type == File.COLLECTION_RESOURCE_TYPE:
121237
raise ValueError("This is a collection, please specify file path")
122-
if filename in os.listdir('./'):
123-
raise ValueError( "File with such name already exists in this directory")
238+
if filename in os.listdir(target_dir):
239+
raise ValueError(
240+
"File with such name already exists in this directory")
241+
filename = os.path.join(target_dir, filename)
124242
res = self.requester.download(self._get_path(path))
125243
with open(filename, 'wb') as f:
126244
f.write(res.data)
127245

128246
# get timestamp of downloaded file from file property on Nextcloud
129247
# If it succeeded, set the timestamp to saved local file
130248
# If the timestamp string is invalid or broken, the timestamp is downloaded time.
131-
file_timestamp_str = (file_data.data[0].get('last_modified')
132-
if self.json_output
133-
else file_data.data[0].last_modified)
249+
file_timestamp_str = file_data.last_modified
134250
file_timestamp = timestamp_to_epoch_time(file_timestamp_str)
135251
if isinstance(file_timestamp, int):
136-
os.utime(filename, (datetime.now().timestamp(), file_timestamp))
252+
os.utime(filename, (
253+
datetime_to_timestamp(datetime.now()),
254+
file_timestamp))
255+
return (filename, file_data)
137256

138257
def upload_file(self, local_filepath, remote_filepath, timestamp=None):
139258
"""
@@ -233,7 +352,8 @@ def move_path(self, path, destination_path, overwrite=False):
233352
requester response
234353
"""
235354
return self.requester.move(url=self._get_path(path),
236-
destination=self._get_path(destination_path),
355+
destination=self._get_path(
356+
destination_path),
237357
overwrite=overwrite)
238358

239359
def copy_path(self, path, destination_path, overwrite=False):
@@ -249,7 +369,8 @@ def copy_path(self, path, destination_path, overwrite=False):
249369
requester response
250370
"""
251371
return self.requester.copy(url=self._get_path(path),
252-
destination=self._get_path(destination_path),
372+
destination=self._get_path(
373+
destination_path),
253374
overwrite=overwrite)
254375

255376
def set_favorites(self, path):
@@ -277,8 +398,10 @@ def list_favorites(self, path=''):
277398
"""
278399
data = File.build_xml_propfind(
279400
instr='oc:filter-files', filter_rules={'oc': {'favorite': 1}})
280-
resp = self.requester.report(additional_url=self._get_path(path), data=data)
281-
return File.from_response(resp, json_output=self.json_output)
401+
resp = self.requester.report(
402+
additional_url=self._get_path(path), data=data)
403+
return File.from_response(resp, json_output=self.json_output,
404+
wrapper=self)
282405

283406
def get_file_property(self, path, field, tag='oc'):
284407
"""
@@ -310,3 +433,45 @@ def get_file_property(self, path, field, tag='oc'):
310433
break
311434

312435
return resp
436+
437+
def get_file(self, path):
438+
"""
439+
Return the File object associated to the path
440+
441+
:param path: path to the file
442+
:returns: File object or None
443+
"""
444+
resp = self.client.with_attr(json_output=False).list_folders(
445+
path, all_properties=True, depth=0)
446+
if resp.is_ok:
447+
if resp.data:
448+
return resp.data[0]
449+
return None
450+
451+
def get_folder(self, path=None):
452+
"""
453+
Return the File object associated to the path
454+
If the file (folder or 'collection') doesn't exists, create it.
455+
456+
:param path: path to the file/folder, if empty use root
457+
:returns: File object
458+
"""
459+
fileobj = self.get_file(path)
460+
if fileobj:
461+
if not fileobj.isdir():
462+
raise NextCloudFileConflict(fileobj.href)
463+
else:
464+
self.client.create_folder(path)
465+
fileobj = self.get_file(path)
466+
467+
return fileobj
468+
469+
def get_relative_path(self, href):
470+
"""
471+
Returns relative (to application / user) path
472+
473+
:param href(str): file href
474+
:returns (str): relative path
475+
"""
476+
_app_root = '/'.join([self.API_URL, self.client.user])
477+
return href[len(_app_root):]

src/nextcloud/common/collections.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
class PropertySet(object):
1212
"""
1313
Set of nextcloud.common.properties.Prop
14-
defined in _attrs class variable
14+
defined in _attrs class variable.
15+
16+
The inherited classes can do additionnal complex operations
17+
if wrapper instance is defined at initialization.
1518
"""
1619
SUCCESS_STATUS = 'HTTP/1.1 200 OK'
1720
COLLECTION_RESOURCE_TYPE = 'collection'
@@ -31,11 +34,12 @@ def _fetch_property(cls, key, attr='xml_key'):
3134
if getattr(k, attr) == key:
3235
return k
3336

34-
def __init__(self, xml_data, init_attrs=False):
37+
def __init__(self, xml_data, init_attrs=False, wrapper=None):
3538
if init_attrs:
3639
for attr in self._attrs:
3740
setattr(self, attr.attr_name, None)
3841

42+
self._wrapper = wrapper
3943
self.href = xml_data.find('d:href', NAMESPACES_MAP).text
4044
for propstat in xml_data.iter('{DAV:}propstat'):
4145
if propstat.find('d:status', NAMESPACES_MAP).text != self.SUCCESS_STATUS:
@@ -83,15 +87,16 @@ def build_xml_propupdate(cls, values):
8387
return SimpleXml.build_propupdate_datas(values)
8488

8589
@classmethod
86-
def from_response(cls, resp, json_output=None, filtered=None, init_attrs=None):
90+
def from_response(cls, resp, json_output=None, filtered=None,
91+
init_attrs=None, wrapper=None):
8792
""" Build list of PropertySet from a NextcloudResponse """
8893
if not resp.is_ok:
8994
resp.data = None
9095
return resp
9196
else:
9297
response_data = resp.data
9398
response_xml_data = SimpleXml.fromstring(response_data)
94-
attr_datas = [cls(xml_data, init_attrs=init_attrs)
99+
attr_datas = [cls(xml_data, init_attrs=init_attrs, wrapper=wrapper)
95100
for xml_data in response_xml_data]
96101
if filtered:
97102
if callable(filtered):

src/nextcloud/common/value_parsing.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Extra tools for value parsing
44
"""
55
from datetime import datetime
6+
from nextcloud.compat import datetime_to_timestamp
67

78

89
def timestamp_to_epoch_time(rfc1123_date=''):
@@ -18,9 +19,9 @@ def timestamp_to_epoch_time(rfc1123_date=''):
1819
int or None : Epoch time, if date string value is invalid return None
1920
"""
2021
try:
21-
epoch_time = datetime.strptime(
22-
rfc1123_date, '%a, %d %b %Y %H:%M:%S GMT').timestamp()
22+
_time = datetime.strptime(
23+
rfc1123_date, '%a, %d %b %Y %H:%M:%S GMT')
2324
except ValueError:
2425
return
2526
else:
26-
return int(epoch_time)
27+
return datetime_to_timestamp(_time)

src/nextcloud/compat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Tools for python2/3 unicode compatibility
44
"""
55
import six
6+
import time
67

78

89
def encode_requests_password(word):
@@ -39,3 +40,17 @@ def encode_string(string):
3940
if isinstance(string, six.text_type):
4041
return string.encode('utf-8')
4142
return string
43+
44+
45+
def datetime_to_timestamp(_time):
46+
"""
47+
Returns int(<datetime>.timestamp())
48+
"""
49+
if six.PY2:
50+
return int(
51+
time.mktime(_time.timetuple()) + _time.microsecond/1000000.0
52+
)
53+
else:
54+
return int(
55+
_time.timestamp()
56+
)

0 commit comments

Comments
 (0)