Skip to content

Commit f19179a

Browse files
authored
added new FileAPI: get_versions and restore_version (#108)
This was the last part of FileAPI that was missing --------- Signed-off-by: Alexander Piskun <[email protected]>
1 parent a2a1fcc commit f19179a

File tree

5 files changed

+141
-37
lines changed

5 files changed

+141
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
1111
* `trashbin_restore`
1212
* `trashbin_delete`
1313
* `trashbin_cleanup`
14+
- File Versions API: `get_versions` and `restore_version`.
1415

1516
### Fixed
1617

README.md

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,20 @@ Python library that provides a robust and well-documented API that allows develo
2323
* **Easy**: Designed to be easy to use with excellent documentation.
2424

2525
### Capabilities
26-
| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 |
27-
|-------------------|:------------:|:------------:|:------------:|
28-
| Filesystem* ||||
29-
| Shares ||||
30-
| Users & Groups ||||
31-
| User status ||||
32-
| Weather status ||||
33-
| Notifications ||||
34-
| Nextcloud Talk ||||
35-
| Talk Bot API** | N/A |||
36-
| Text Processing** | N/A |||
37-
| SpeechToText** | N/A |||
38-
39-
&ast;missing `File version` support.<br>
40-
&ast;&ast;available only for NextcloudApp
26+
| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 |
27+
|------------------|:------------:|:------------:|:------------:|
28+
| File System ||||
29+
| Shares ||||
30+
| Users & Groups ||||
31+
| User status ||||
32+
| Weather status ||||
33+
| Notifications ||||
34+
| Nextcloud Talk ||||
35+
| Talk Bot API* | N/A |||
36+
| Text Processing* | N/A |||
37+
| SpeechToText* | N/A |||
38+
39+
&ast;_available only for NextcloudApp_
4140

4241
### Differences between the Nextcloud and NextcloudApp classes
4342

nc_py_api/files/__init__.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,23 @@
1111
class FsNodeInfo:
1212
"""Extra FS object attributes from Nextcloud."""
1313

14-
size: int
15-
"""For directories it is size of all content in it, for files it is equal to ``size``."""
16-
content_length: int
17-
"""Length of file in bytes, zero for directories."""
18-
permissions: str
19-
"""Permissions for the object."""
20-
favorite: bool
21-
"""Flag indicating if the object is marked as favorite."""
2214
fileid: int
2315
"""Clear file ID without Nextcloud instance ID."""
16+
favorite: bool
17+
"""Flag indicating if the object is marked as favorite."""
18+
is_version: bool
19+
"""Flag indicating if the object is File Version representation"""
2420
_last_modified: datetime.datetime
2521
_trashbin: dict
2622

2723
def __init__(self, **kwargs):
28-
self.size = kwargs.get("size", 0)
29-
self.content_length = kwargs.get("content_length", 0)
30-
self.permissions = kwargs.get("permissions", "")
24+
self._raw_data = {
25+
"content_length": kwargs.get("content_length", 0),
26+
"size": kwargs.get("size", 0),
27+
"permissions": kwargs.get("permissions", ""),
28+
}
3129
self.favorite = kwargs.get("favorite", False)
30+
self.is_version = False
3231
self.fileid = kwargs.get("fileid", 0)
3332
try:
3433
self.last_modified = kwargs.get("last_modified", datetime.datetime(1970, 1, 1))
@@ -39,6 +38,21 @@ def __init__(self, **kwargs):
3938
if i in kwargs:
4039
self._trashbin[i] = kwargs[i]
4140

41+
@property
42+
def content_length(self) -> int:
43+
"""Length of file in bytes, zero for directories."""
44+
return self._raw_data["content_length"]
45+
46+
@property
47+
def size(self) -> int:
48+
"""In the case of directories it is the size of all content, for files it is equal to ``content_length``."""
49+
return self._raw_data["size"]
50+
51+
@property
52+
def permissions(self) -> str:
53+
"""Permissions for the object."""
54+
return self._raw_data["permissions"]
55+
4256
@property
4357
def last_modified(self) -> datetime.datetime:
4458
"""Time when the object was last modified.
@@ -106,6 +120,11 @@ def is_dir(self) -> bool:
106120
return self.full_path.endswith("/")
107121

108122
def __str__(self):
123+
if self.info.is_version:
124+
return (
125+
f"File version: `{self.name}` for FileID={self.file_id}"
126+
f" last modified at {str(self.info.last_modified)} with {self.info.content_length} bytes size."
127+
)
109128
return (
110129
f"{'Dir' if self.is_dir else 'File'}: `{self.name}` with id={self.file_id}"
111130
f" last modified at {str(self.info.last_modified)} and {self.info.permissions} permissions."

nc_py_api/files/files.py

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Nextcloud API for working with the file system."""
22

33
import builtins
4+
import enum
45
import os
56
from io import BytesIO
67
from json import dumps, loads
@@ -15,6 +16,7 @@
1516
from httpx import Response
1617

1718
from .._exceptions import NextcloudException, check_error
19+
from .._misc import require_capabilities
1820
from .._session import NcSessionBasic
1921
from . import FsNode
2022
from .sharing import _FilesSharingAPI
@@ -53,6 +55,16 @@
5355
}
5456

5557

58+
class PropFindType(enum.IntEnum):
59+
"""Internal enum types for ``_listdir`` and ``_lf_parse_webdav_records`` methods."""
60+
61+
DEFAULT = 0
62+
TRASHBIN = 1
63+
FAVORITE = 2
64+
VERSIONS_FILEID = 3
65+
VERSIONS_FILE_ID = 4
66+
67+
5668
class FilesAPI:
5769
"""Class that encapsulates the file system and file sharing functionality."""
5870

@@ -305,7 +317,7 @@ def listfav(self) -> list[FsNode]:
305317
)
306318
request_info = f"listfav: {self._session.user}"
307319
check_error(webdav_response.status_code, request_info)
308-
return self._lf_parse_webdav_records(webdav_response, request_info, favorite=True)
320+
return self._lf_parse_webdav_records(webdav_response, request_info, PropFindType.FAVORITE)
309321

310322
def setfav(self, path: Union[str, FsNode], value: Union[int, bool]) -> None:
311323
"""Sets or unsets favourite flag for specific file.
@@ -330,7 +342,9 @@ def trashbin_list(self) -> list[FsNode]:
330342
"""Returns a list of all entries in the TrashBin."""
331343
properties = PROPFIND_PROPERTIES
332344
properties += ["nc:trashbin-filename", "nc:trashbin-original-location", "nc:trashbin-deletion-time"]
333-
return self._listdir(self._session.user, "", properties=properties, depth=1, exclude_self=False, trashbin=True)
345+
return self._listdir(
346+
self._session.user, "", properties=properties, depth=1, exclude_self=False, prop_type=PropFindType.TRASHBIN
347+
)
334348

335349
def trashbin_restore(self, path: Union[str, FsNode]) -> None:
336350
"""Restore a file/directory from the TrashBin.
@@ -366,8 +380,41 @@ def trashbin_cleanup(self) -> None:
366380
response = self._session.dav(method="DELETE", path=f"/trashbin/{self._session.user}/trash")
367381
check_error(response.status_code, f"trashbin_cleanup: user={self._session.user}")
368382

383+
def get_versions(self, file_object: FsNode) -> list[FsNode]:
384+
"""Returns a list of all file versions if any."""
385+
require_capabilities("files.versioning", self._session.capabilities)
386+
return self._listdir(
387+
self._session.user,
388+
str(file_object.info.fileid) if file_object.info.fileid else file_object.file_id,
389+
properties=PROPFIND_PROPERTIES,
390+
depth=1,
391+
exclude_self=False,
392+
prop_type=PropFindType.VERSIONS_FILEID if file_object.info.fileid else PropFindType.VERSIONS_FILE_ID,
393+
)
394+
395+
def restore_version(self, file_object: FsNode) -> None:
396+
"""Restore a file with specified version.
397+
398+
:param file_object: The **FsNode** class from :py:meth:`~nc_py_api.files.files.FilesAPI.get_versions`.
399+
"""
400+
require_capabilities("files.versioning", self._session.capabilities)
401+
dest = self._session.cfg.dav_endpoint + f"/versions/{self._session.user}/restore/{file_object.name}"
402+
headers = {"Destination": dest}
403+
response = self._session.dav(
404+
"MOVE",
405+
path=f"/versions/{self._session.user}/{file_object.user_path}",
406+
headers=headers,
407+
)
408+
check_error(response.status_code, f"restore_version: user={self._session.user}, src={file_object.user_path}")
409+
369410
def _listdir(
370-
self, user: str, path: str, properties: list[str], depth: int, exclude_self: bool, trashbin: bool = False
411+
self,
412+
user: str,
413+
path: str,
414+
properties: list[str],
415+
depth: int,
416+
exclude_self: bool,
417+
prop_type: PropFindType = PropFindType.DEFAULT,
371418
) -> list[FsNode]:
372419
root = ElementTree.Element(
373420
"d:propfind",
@@ -376,7 +423,9 @@ def _listdir(
376423
prop = ElementTree.SubElement(root, "d:prop")
377424
for i in properties:
378425
ElementTree.SubElement(prop, i)
379-
if trashbin:
426+
if prop_type in (PropFindType.VERSIONS_FILEID, PropFindType.VERSIONS_FILE_ID):
427+
dav_path = self._dav_get_obj_path(f"versions/{user}/versions", path, root_path="")
428+
elif prop_type == PropFindType.TRASHBIN:
380429
dav_path = self._dav_get_obj_path(f"trashbin/{user}/trash", path, root_path="")
381430
else:
382431
dav_path = self._dav_get_obj_path(user, path)
@@ -386,23 +435,38 @@ def _listdir(
386435
self._element_tree_as_str(root),
387436
headers={"Depth": "infinity" if depth == -1 else str(depth)},
388437
)
389-
request_info = f"list: {user}, {path}, {properties}"
390-
result = self._lf_parse_webdav_records(webdav_response, request_info)
438+
439+
result = self._lf_parse_webdav_records(
440+
webdav_response,
441+
f"list: {user}, {path}, {properties}",
442+
prop_type,
443+
)
391444
if exclude_self:
392445
for index, v in enumerate(result):
393446
if v.user_path.rstrip("/") == path.rstrip("/"):
394447
del result[index]
395448
break
396449
return result
397450

398-
def _parse_records(self, fs_records: list[dict], favorite: bool):
451+
def _parse_records(self, fs_records: list[dict], response_type: PropFindType) -> list[FsNode]:
399452
result: list[FsNode] = []
400453
for record in fs_records:
401454
obj_full_path = unquote(record.get("d:href", ""))
402455
obj_full_path = obj_full_path.replace(self._session.cfg.dav_url_suffix, "").lstrip("/")
403456
propstat = record["d:propstat"]
404457
fs_node = self._parse_record(obj_full_path, propstat if isinstance(propstat, list) else [propstat])
405-
if favorite and not fs_node.file_id:
458+
if fs_node.etag and response_type in (
459+
PropFindType.VERSIONS_FILE_ID,
460+
PropFindType.VERSIONS_FILEID,
461+
):
462+
fs_node.full_path = fs_node.full_path.rstrip("/")
463+
fs_node.info.is_version = True
464+
if response_type == PropFindType.VERSIONS_FILEID:
465+
fs_node.info.fileid = int(fs_node.full_path.rsplit("/", 2)[-2])
466+
fs_node.file_id = str(fs_node.info.fileid)
467+
else:
468+
fs_node.file_id = fs_node.full_path.rsplit("/", 2)[-2]
469+
if response_type == PropFindType.FAVORITE and not fs_node.file_id:
406470
_fs_node = self.by_path(fs_node.user_path)
407471
if _fs_node:
408472
_fs_node.info.favorite = True
@@ -444,7 +508,9 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
444508
# xz = prop.get("oc:dDC", "")
445509
return FsNode(full_path, **fs_node_args)
446510

447-
def _lf_parse_webdav_records(self, webdav_res: Response, info: str, favorite=False) -> list[FsNode]:
511+
def _lf_parse_webdav_records(
512+
self, webdav_res: Response, info: str, response_type: PropFindType = PropFindType.DEFAULT
513+
) -> list[FsNode]:
448514
check_error(webdav_res.status_code, info=info)
449515
if webdav_res.status_code != 207: # multistatus
450516
raise NextcloudException(webdav_res.status_code, "Response is not a multistatus.", info=info)
@@ -453,7 +519,7 @@ def _lf_parse_webdav_records(self, webdav_res: Response, info: str, favorite=Fal
453519
err = response_data["d:error"]
454520
raise NextcloudException(reason=f'{err["s:exception"]}: {err["s:message"]}'.replace("\n", ""), info=info)
455521
response = response_data["d:multistatus"].get("d:response", [])
456-
return self._parse_records([response] if isinstance(response, dict) else response, favorite)
522+
return self._parse_records([response] if isinstance(response, dict) else response, response_type)
457523

458524
@staticmethod
459525
def _dav_get_obj_path(user: str, path: str = "", root_path="/files") -> str:

tests/files_test.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ def test_trashbin(nc):
614614
# one object now in a trashbin
615615
r = nc.files.trashbin_list()
616616
assert len(r) == 1
617-
# check properties types of FsNode
617+
# check types of FsNode properties
618618
i: FsNode = r[0]
619619
assert i.info.in_trash is True
620620
assert i.info.trashbin_filename.find("nc_py_api_temp.txt") != -1
@@ -640,3 +640,22 @@ def test_trashbin(nc):
640640
# no files in trashbin
641641
r = nc.files.trashbin_list()
642642
assert not r
643+
644+
645+
def test_file_versions(nc):
646+
if nc.check_capabilities("files.versioning"):
647+
pytest.skip("Need 'Versions' App to be enabled.")
648+
for i in (0, 1):
649+
nc.files.delete("nc_py_api_file_versions_test.txt", not_fail=True)
650+
nc.files.upload("nc_py_api_file_versions_test.txt", content=b"22")
651+
new_file = nc.files.upload("nc_py_api_file_versions_test.txt", content=b"333")
652+
if i:
653+
new_file = nc.files.by_id(new_file)
654+
versions = nc.files.get_versions(new_file)
655+
assert versions
656+
version_str = str(versions[0])
657+
assert version_str.find("File version") != -1
658+
assert version_str.find("bytes size") != -1
659+
nc.files.restore_version(versions[0])
660+
assert nc.files.download(new_file) == b"22"
661+
nc.files.delete(new_file)

0 commit comments

Comments
 (0)