diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index 9ae6afbd..17b09ca8 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -505,6 +505,14 @@ jobs: ref: "main" path: apps/notes + - name: Checkout Files Locking + uses: actions/checkout@v4 + if: ${{ !startsWith(matrix.nextcloud, 'master') }} + with: + repository: nextcloud/files_lock + ref: ${{ matrix.nextcloud }} + path: apps/files_lock + - name: Set up & run Nextcloud env: DB_PORT: 4444 @@ -518,6 +526,10 @@ jobs: ./occ app:enable notifications PHP_CLI_SERVER_WORKERS=2 php -S localhost:8080 & + - name: Enable Files Locking + if: ${{ !startsWith(matrix.nextcloud, 'master') }} + run: ./occ app:enable files_lock + - name: Enable Notes if: ${{ !startsWith(matrix.nextcloud, 'master') }} run: ./occ app:enable notes @@ -831,6 +843,14 @@ jobs: ref: "main" path: apps/notes + - name: Checkout Files Locking + uses: actions/checkout@v4 + if: ${{ !startsWith(matrix.nextcloud, 'stable26') && !startsWith(matrix.nextcloud, 'master') }} + with: + repository: nextcloud/files_lock + ref: ${{ matrix.nextcloud }} + path: apps/files_lock + - name: Set up & run Nextcloud env: DB_PORT: 4444 @@ -848,6 +868,10 @@ jobs: if: ${{ !startsWith(matrix.nextcloud, 'master') }} run: ./occ app:enable notes + - name: Enable Files Locking + if: ${{ !startsWith(matrix.nextcloud, 'stable26') && !startsWith(matrix.nextcloud, 'master') }} + run: ./occ app:enable files_lock + - name: Checkout NcPyApi uses: actions/checkout@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c1dc5a..1a4d4d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.11.0 - 2024-0x-xx] + +### Added + +- Files: `lock` and `unlock` methods, lock file information to `FsNode`. #227 + ## [0.10.0 - 2024-02-14] ### Added diff --git a/docs/reference/Files/Files.rst b/docs/reference/Files/Files.rst index a91d0cc8..f2bb37f6 100644 --- a/docs/reference/Files/Files.rst +++ b/docs/reference/Files/Files.rst @@ -20,3 +20,9 @@ All File APIs are designed to work relative to the current user. .. autoclass:: nc_py_api.files.SystemTag :members: + +.. autoclass:: nc_py_api.files.LockType + :members: + +.. autoclass:: nc_py_api.files.FsNodeLockInfo + :members: diff --git a/nc_py_api/__init__.py b/nc_py_api/__init__.py index 51ea41ce..a279e474 100644 --- a/nc_py_api/__init__.py +++ b/nc_py_api/__init__.py @@ -7,6 +7,6 @@ NextcloudMissingCapabilities, ) from ._version import __version__ -from .files import FilePermissions, FsNode +from .files import FilePermissions, FsNode, LockType from .files.sharing import ShareType from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp diff --git a/nc_py_api/_version.py b/nc_py_api/_version.py index bfa92cb2..b26672b5 100644 --- a/nc_py_api/_version.py +++ b/nc_py_api/_version.py @@ -1,3 +1,3 @@ """Version of nc_py_api.""" -__version__ = "0.10.0" +__version__ = "0.11.0.dev0" diff --git a/nc_py_api/files/__init__.py b/nc_py_api/files/__init__.py index 2a8ca812..828576f5 100644 --- a/nc_py_api/files/__init__.py +++ b/nc_py_api/files/__init__.py @@ -9,6 +9,63 @@ from .. import _misc +class LockType(enum.IntEnum): + """Nextcloud File Locks types.""" + + MANUAL_LOCK = 0 + COLLABORATIVE_LOCK = 1 + WEBDAV_TOKEN = 2 + + +@dataclasses.dataclass +class FsNodeLockInfo: + """File Lock information if Nextcloud `files_lock` is enabled.""" + + def __init__(self, **kwargs): + self._is_locked = bool(int(kwargs.get("is_locked", False))) + self._lock_owner_type = LockType(int(kwargs.get("lock_owner_type", 0))) + self._lock_owner = kwargs.get("lock_owner", "") + self._owner_display_name = kwargs.get("owner_display_name", "") + self._owner_editor = kwargs.get("lock_owner_editor", "") + self._lock_time = int(kwargs.get("lock_time", 0)) + self._lock_ttl = int(kwargs.get("_lock_ttl", 0)) + + @property + def is_locked(self) -> bool: + """Returns ``True`` if the file is locked, ``False`` otherwise.""" + return self._is_locked + + @property + def type(self) -> LockType: + """Type of the lock.""" + return LockType(self._lock_owner_type) + + @property + def owner(self) -> str: + """User id of the lock owner.""" + return self._lock_owner + + @property + def owner_display_name(self) -> str: + """Display name of the lock owner.""" + return self._owner_display_name + + @property + def owner_editor(self) -> str: + """App id of an app owned lock to allow clients to suggest joining the collaborative editing session.""" + return self._owner_editor + + @property + def lock_creation_time(self) -> datetime.datetime: + """Lock creation time.""" + return datetime.datetime.utcfromtimestamp(self._lock_time).replace(tzinfo=datetime.timezone.utc) + + @property + def lock_ttl(self) -> int: + """TTL of the lock in seconds staring from the creation time. A value of 0 means the timeout is infinite.""" + return self._lock_ttl + + @dataclasses.dataclass class FsNodeInfo: """Extra FS object attributes from Nextcloud.""" @@ -116,11 +173,15 @@ class FsNode: info: FsNodeInfo """Additional extra information for the object""" + lock_info: FsNodeLockInfo + """Class describing `lock` information if any.""" + def __init__(self, full_path: str, **kwargs): self.full_path = full_path self.file_id = kwargs.get("file_id", "") self.etag = kwargs.get("etag", "") self.info = FsNodeInfo(**kwargs) + self.lock_info = FsNodeLockInfo(**kwargs) @property def is_dir(self) -> bool: diff --git a/nc_py_api/files/_files.py b/nc_py_api/files/_files.py index 8863c1fe..7b7d4415 100644 --- a/nc_py_api/files/_files.py +++ b/nc_py_api/files/_files.py @@ -10,7 +10,7 @@ from httpx import Response from .._exceptions import NextcloudException, check_error -from .._misc import clear_from_params_empty +from .._misc import check_capabilities, clear_from_params_empty from . import FsNode, SystemTag PROPFIND_PROPERTIES = [ @@ -29,13 +29,16 @@ "oc:share-types", "oc:favorite", "nc:is-encrypted", +] + +PROPFIND_LOCKING_PROPERTIES = [ "nc:lock", "nc:lock-owner-displayname", "nc:lock-owner", "nc:lock-owner-type", - "nc:lock-owner-editor", - "nc:lock-time", - "nc:lock-timeout", + "nc:lock-owner-editor", # App id of an app owned lock + "nc:lock-time", # Timestamp of the log creation time + "nc:lock-timeout", # TTL of the lock in seconds staring from the creation time ] SEARCH_PROPERTIES_MAP = { @@ -57,7 +60,14 @@ class PropFindType(enum.IntEnum): VERSIONS_FILE_ID = 3 -def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.Element: +def get_propfind_properties(capabilities: dict) -> list: + r = PROPFIND_PROPERTIES + if not check_capabilities("files.locking", capabilities): + r += PROPFIND_LOCKING_PROPERTIES + return r + + +def build_find_request(req: list, path: str | FsNode, user: str, capabilities: dict) -> ElementTree.Element: path = path.user_path if isinstance(path, FsNode) else path root = ElementTree.Element( "d:searchrequest", @@ -65,7 +75,7 @@ def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree. ) xml_search = ElementTree.SubElement(root, "d:basicsearch") xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop") - for i in PROPFIND_PROPERTIES: + for i in get_propfind_properties(capabilities): ElementTree.SubElement(xml_select_prop, i) xml_from_scope = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:from"), "d:scope") href = f"/files/{user}/{path.removeprefix('/')}" @@ -76,7 +86,9 @@ def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree. return root -def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | SystemTag] | None) -> ElementTree.Element: +def build_list_by_criteria_req( + properties: list[str] | None, tags: list[int | SystemTag] | None, capabilities: dict +) -> ElementTree.Element: if not properties and not tags: raise ValueError("Either specify 'properties' or 'tags' to filter results.") root = ElementTree.Element( @@ -84,7 +96,7 @@ def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | Sy attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"}, ) prop = ElementTree.SubElement(root, "d:prop") - for i in PROPFIND_PROPERTIES: + for i in get_propfind_properties(capabilities): ElementTree.SubElement(prop, i) xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules") if properties and "favorite" in properties: @@ -243,7 +255,7 @@ def etag_fileid_from_response(response: Response) -> dict: return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]} -def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: +def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pylint: disable = too-many-branches fs_node_args = {} for prop_stat in prop_stats: if str(prop_stat.get("d:status", "")).find("200 OK") == -1: @@ -274,7 +286,17 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"] if "nc:trashbin-deletion-time" in prop_keys: fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"] - # xz = prop.get("oc:dDC", "") + for k, v in { + "nc:lock": "is_locked", + "nc:lock-owner-type": "lock_owner_type", + "nc:lock-owner": "lock_owner", + "nc:lock-owner-displayname": "lock_owner_displayname", + "nc:lock-owner-editor": "lock_owner_editor", + "nc:lock-time": "lock_time", + "nc:lock-timeout": "lock_ttl", + }.items(): + if k in prop_keys and prop[k] is not None: + fs_node_args[v] = prop[k] return FsNode(full_path, **fs_node_args) diff --git a/nc_py_api/files/files.py b/nc_py_api/files/files.py index 503152f7..f0180c0c 100644 --- a/nc_py_api/files/files.py +++ b/nc_py_api/files/files.py @@ -10,7 +10,7 @@ from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error from .._misc import random_string, require_capabilities from .._session import AsyncNcSessionBasic, NcSessionBasic -from . import FsNode, SystemTag +from . import FsNode, LockType, SystemTag from ._files import ( PROPFIND_PROPERTIES, PropFindType, @@ -25,6 +25,7 @@ dav_get_obj_path, element_tree_as_str, etag_fileid_from_response, + get_propfind_properties, lf_parse_webdav_response, ) from .sharing import _AsyncFilesSharingAPI, _FilesSharingAPI @@ -50,7 +51,7 @@ def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=True) -> """ if exclude_self and not depth: raise ValueError("Wrong input parameters, query will return nothing.") - properties = PROPFIND_PROPERTIES + properties = get_propfind_properties(self._session.capabilities) path = path.user_path if isinstance(path, FsNode) else path return self._listdir(self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self) @@ -76,7 +77,7 @@ def find(self, req: list, path: str | FsNode = "") -> list[FsNode]: :param path: path where to search from. Default = **""**. """ # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid" - root = build_find_request(req, path, self._session.user) + root = build_find_request(req, path, self._session.user, self._session.capabilities) webdav_response = self._session.adapter_dav.request( "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"} ) @@ -248,7 +249,7 @@ def list_by_criteria( Supported values: **favorite** :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file. """ - root = build_list_by_criteria_req(properties, tags) + root = build_list_by_criteria_req(properties, tags, self._session.capabilities) webdav_response = self._session.adapter_dav.request( "REPORT", dav_get_obj_path(self._session.user), content=element_tree_as_str(root) ) @@ -396,6 +397,34 @@ def unassign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None: """Removes Tag from a file/directory.""" self._file_change_tag_state(file_id, tag_id, False) + def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None: + """Locks the file. + + .. note:: Exception codes: 423 - existing lock present. + """ + require_capabilities("files.locking", self._session.capabilities) + full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = self._session.adapter_dav.request( + "LOCK", + quote(full_path), + headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)}, + ) + check_error(response, f"lock: user={self._session.user}, path={full_path}") + + def unlock(self, path: FsNode | str) -> None: + """Unlocks the file. + + .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user. + """ + require_capabilities("files.locking", self._session.capabilities) + full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = self._session.adapter_dav.request( + "UNLOCK", + quote(full_path), + headers={"X-User-Lock": "1"}, + ) + check_error(response, f"unlock: user={self._session.user}, path={full_path}") + def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None: fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id @@ -493,7 +522,7 @@ async def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=Tr """ if exclude_self and not depth: raise ValueError("Wrong input parameters, query will return nothing.") - properties = PROPFIND_PROPERTIES + properties = get_propfind_properties(await self._session.capabilities) path = path.user_path if isinstance(path, FsNode) else path return await self._listdir( await self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self @@ -521,7 +550,7 @@ async def find(self, req: list, path: str | FsNode = "") -> list[FsNode]: :param path: path where to search from. Default = **""**. """ # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid" - root = build_find_request(req, path, await self._session.user) + root = build_find_request(req, path, await self._session.user, await self._session.capabilities) webdav_response = await self._session.adapter_dav.request( "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"} ) @@ -695,7 +724,7 @@ async def list_by_criteria( Supported values: **favorite** :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file. """ - root = build_list_by_criteria_req(properties, tags) + root = build_list_by_criteria_req(properties, tags, await self._session.capabilities) webdav_response = await self._session.adapter_dav.request( "REPORT", dav_get_obj_path(await self._session.user), content=element_tree_as_str(root) ) @@ -848,6 +877,34 @@ async def unassign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> """Removes Tag from a file/directory.""" await self._file_change_tag_state(file_id, tag_id, False) + async def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None: + """Locks the file. + + .. note:: Exception codes: 423 - existing lock present. + """ + require_capabilities("files.locking", await self._session.capabilities) + full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = await self._session.adapter_dav.request( + "LOCK", + quote(full_path), + headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)}, + ) + check_error(response, f"lock: user={self._session.user}, path={full_path}") + + async def unlock(self, path: FsNode | str) -> None: + """Unlocks the file. + + .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user. + """ + require_capabilities("files.locking", await self._session.capabilities) + full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = await self._session.adapter_dav.request( + "UNLOCK", + quote(full_path), + headers={"X-User-Lock": "1"}, + ) + check_error(response, f"unlock: user={self._session.user}, path={full_path}") + async def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None: fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id diff --git a/tests/actual_tests/files_test.py b/tests/actual_tests/files_test.py index 9018e85a..4f44788d 100644 --- a/tests/actual_tests/files_test.py +++ b/tests/actual_tests/files_test.py @@ -12,7 +12,7 @@ import pytest -from nc_py_api import FsNode, NextcloudException, NextcloudExceptionNotFound +from nc_py_api import FsNode, LockType, NextcloudException, NextcloudExceptionNotFound class MyBytesIO(BytesIO): @@ -1207,3 +1207,49 @@ async def test_assign_unassign_tag_async(anc_any): await anc_any.files.assign_tag(new_file, tag1) with pytest.raises(ValueError): await anc_any.files.list_by_criteria() + + +def __check_lock_info(fs_node: FsNode): + assert isinstance(fs_node.lock_info.owner, str) + assert isinstance(fs_node.lock_info.owner_display_name, str) + assert isinstance(fs_node.lock_info.type, LockType) + assert isinstance(fs_node.lock_info.lock_creation_time, datetime) + assert isinstance(fs_node.lock_info.lock_ttl, int) + assert isinstance(fs_node.lock_info.owner_editor, str) + + +def test_file_locking(nc_any): + if nc_any.check_capabilities("files.locking"): + pytest.skip("Files Locking App is not installed") + test_file = nc_any.files.upload("/test_dir/test_lock", content="") + assert nc_any.files.by_id(test_file).lock_info.is_locked is False + with pytest.raises(NextcloudException) as e: + nc_any.files.unlock(test_file) + assert e.value.status_code == 412 + nc_any.files.lock(test_file) + locked_file = nc_any.files.by_id(test_file) + assert locked_file.lock_info.is_locked is True + __check_lock_info(locked_file) + nc_any.files.unlock(test_file) + with pytest.raises(NextcloudException) as e: + nc_any.files.unlock(test_file) + assert e.value.status_code == 412 + + +@pytest.mark.asyncio(scope="session") +async def test_file_locking_async(anc_any): + if await anc_any.check_capabilities("files.locking"): + pytest.skip("Files Locking App is not installed") + test_file = await anc_any.files.upload("/test_dir/test_lock_async", content="") + assert (await anc_any.files.by_id(test_file)).lock_info.is_locked is False + with pytest.raises(NextcloudException) as e: + await anc_any.files.unlock(test_file) + assert e.value.status_code == 412 + await anc_any.files.lock(test_file) + locked_file = await anc_any.files.by_id(test_file) + assert locked_file.lock_info.is_locked is True + __check_lock_info(locked_file) + await anc_any.files.unlock(test_file) + with pytest.raises(NextcloudException) as e: + await anc_any.files.unlock(test_file) + assert e.value.status_code == 412