Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [0.30.4 - 2026-08-11]

### Added

- `extra_properties` argument for `listdir`, `by_path`, `by_id`, `find`, `list_by_criteria`, `trashbin_list` and `get_versions`, to request WebDAV properties the library does not model itself, e.g. `nc:has-preview`. The values are available in the new `FsNode.extra_properties`, which also exposes the properties that were requested by default but silently dropped until now, such as `oc:share-types` and `oc:checksums`. #453

### Fixed

- ExApps: downloading a model from a direct link is retried when the host is throttling or temporarily failing (`408`, `425`, `429`, `500`, `502`, `503`, `504`) instead of failing the whole `init`. `Retry-After` is honoured up to a minute, otherwise the wait backs off exponentially. The number of extra attempts defaults to 5 and can be set per model with the new `max_retries` download option.
Expand Down
11 changes: 11 additions & 0 deletions nc_py_api/files/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import enum
import os
import re
import typing
import warnings

from pydantic import BaseModel
Expand Down Expand Up @@ -223,13 +224,23 @@ class FsNode:
lock_info: FsNodeLockInfo
"""Class describing `lock` information if any."""

extra_properties: dict[str, typing.Any]
"""WebDAV properties the server returned that :py:class:`FsNode` does not model itself, keyed by their
prefixed name, e.g. ``nc:has-preview``.

Values arrive as the server sent them: usually a ``str``, ``None`` when the property is empty, and a
``dict``/``list`` for the nested ones such as ``oc:share-types``. Request additional properties with the
``extra_properties`` argument of :py:meth:`~nc_py_api.files.files.FilesAPI.listdir` and friends.
"""

def __init__(self, full_path: str, **kwargs):
self.full_path = full_path
self.file_id = kwargs.get("file_id", "")
# the trashbin sends an empty `<d:getetag/>`, which arrives here as None
self.etag = kwargs.get("etag") or ""
self.info = FsNodeInfo(**kwargs)
self.lock_info = FsNodeLockInfo(**kwargs)
self.extra_properties = kwargs.get("extra_properties") or {}

@property
def is_dir(self) -> bool:
Expand Down
70 changes: 64 additions & 6 deletions nc_py_api/files/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,60 @@ class PropFindType(enum.IntEnum):
VERSIONS_FILE_ID = 3


def get_propfind_properties(capabilities: dict) -> list[str]:
PROPFIND_NAMESPACES: typing.Final[tuple[str, ...]] = ("d", "oc", "nc")
"""XML namespace prefixes declared in the requests, the only ones ``extra_properties`` can use."""

MAPPED_PROPERTIES: typing.Final[frozenset[str]] = frozenset(
{
"d:creationdate",
"d:getcontentlength",
"d:getcontenttype",
"d:getetag",
"d:getlastmodified",
"nc:download-url-expiration",
"nc:lock",
"nc:lock-owner",
"nc:lock-owner-displayname",
"nc:lock-owner-editor",
"nc:lock-owner-type",
"nc:lock-time",
"nc:lock-timeout",
"nc:trashbin-deletion-time",
"nc:trashbin-filename",
"nc:trashbin-original-location",
"oc:downloadURL",
"oc:favorite",
"oc:fileid",
"oc:id",
"oc:permissions",
"oc:size",
"d:resourcetype",
}
)
"""Properties :py:class:`~nc_py_api.files.FsNode` models itself, the rest end up in ``extra_properties``."""


def get_propfind_properties(capabilities: dict, extra_properties: Sequence[str] | None = None) -> list[str]:
r = list(PROPFIND_PROPERTIES)
if not check_capabilities("files.locking", capabilities):
r += PROPFIND_LOCKING_PROPERTIES
return r + _validate_extra_properties(extra_properties, r)


def _validate_extra_properties(extra_properties: Sequence[str] | None, already_requested: Sequence[str]) -> list[str]:
"""Returns the additional properties to request, without the ones that are asked for anyway."""
if not extra_properties:
return []
r = []
for i in extra_properties:
prefix = i.split(":", maxsplit=1)[0] if ":" in i else ""
if prefix not in PROPFIND_NAMESPACES:
raise ValueError(
f"Invalid property `{i}`: expected one of the {', '.join(PROPFIND_NAMESPACES)} namespaces,"
f" e.g. `nc:has-preview`."
)
if i not in already_requested and i not in r:
r.append(i)
return r


Expand All @@ -85,15 +135,17 @@ def _dav_literal(val: Any) -> str:
return str(val)


def build_find_request(req: list, path: str | FsNode, user: str, capabilities: dict) -> ElementTree.Element:
def build_find_request(
req: list, path: str | FsNode, user: str, capabilities: dict, extra_properties: Sequence[str] | None = None
) -> ElementTree.Element:
path = path.user_path if isinstance(path, FsNode) else path
root = ElementTree.Element(
"d:searchrequest",
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
)
xml_search = ElementTree.SubElement(root, "d:basicsearch")
xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop")
for i in get_propfind_properties(capabilities):
for i in get_propfind_properties(capabilities, extra_properties):
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('/')}"
Expand All @@ -105,7 +157,10 @@ def build_find_request(req: list, path: str | FsNode, user: str, capabilities: d


def build_list_by_criteria_req(
properties: list[str] | None, tags: list[int | SystemTag] | None, capabilities: dict
properties: list[str] | None,
tags: list[int | SystemTag] | None,
capabilities: dict,
extra_properties: Sequence[str] | None = None,
) -> ElementTree.Element:
if not properties and not tags:
raise ValueError("Either specify 'properties' or 'tags' to filter results.")
Expand All @@ -114,7 +169,7 @@ def build_list_by_criteria_req(
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 get_propfind_properties(capabilities):
for i in get_propfind_properties(capabilities, extra_properties):
ElementTree.SubElement(prop, i)
xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
if properties and "favorite" in properties:
Expand Down Expand Up @@ -287,6 +342,7 @@ def etag_fileid_from_response(response: Response) -> dict:

def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pylint: disable = too-many-branches
fs_node_args = {}
extra_properties: dict[str, typing.Any] = {}
for prop_stat in prop_stats:
if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
continue
Expand Down Expand Up @@ -336,7 +392,9 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pyl
}.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)
# everything the server returned that FsNode does not model itself, values as the server sent them
extra_properties.update({k: v for k, v in prop.items() if k not in MAPPED_PROPERTIES and not k.startswith("@")})
return FsNode(full_path, extra_properties=extra_properties, **fs_node_args)


def _parse_records(dav_url_suffix: str, fs_records: list[dict], response_type: PropFindType) -> list[FsNode]:
Expand Down
63 changes: 48 additions & 15 deletions nc_py_api/files/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ._files import (
PROPFIND_PROPERTIES,
PropFindType,
_validate_extra_properties,
build_find_request,
build_list_by_criteria_req,
build_list_tag_req,
Expand Down Expand Up @@ -43,43 +44,58 @@ def __init__(self, session: NcSessionBasic):
self._session = session
self.sharing = _FilesSharingAPI(session)

def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=True) -> list[FsNode]:
def listdir(
self,
path: str | FsNode = "",
depth: int = 1,
exclude_self=True,
extra_properties: Sequence[str] | None = None,
) -> list[FsNode]:
"""Returns a list of all entries in the specified directory.

:param path: path to the directory to get the list.
:param depth: how many directory levels should be included in output. Default = **1** (only specified directory)
:param exclude_self: boolean value indicating whether the `path` itself should be excluded from the list or not.
Default = **True**.
:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
if exclude_self and not depth:
raise ValueError("Wrong input parameters, query will return nothing.")
properties = get_propfind_properties(self._session.capabilities)
properties = get_propfind_properties(self._session.capabilities, extra_properties)
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)

def by_id(self, file_id: int | str | FsNode) -> FsNode | None:
def by_id(self, file_id: int | str | FsNode, extra_properties: Sequence[str] | None = None) -> FsNode | None:
"""Returns :py:class:`~nc_py_api.files.FsNode` by file_id if any.

:param file_id: can be full file ID with Nextcloud instance ID or only clear file ID.
:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
file_id = file_id.file_id if isinstance(file_id, FsNode) else file_id
result = self.find(req=["eq", "fileid", file_id])
result = self.find(req=["eq", "fileid", file_id], extra_properties=extra_properties)
return result[0] if result else None

def by_path(self, path: str | FsNode) -> FsNode | None:
def by_path(self, path: str | FsNode, extra_properties: Sequence[str] | None = None) -> FsNode | None:
"""Returns :py:class:`~nc_py_api.files.FsNode` by exact path if any."""
path = path.user_path if isinstance(path, FsNode) else path
result = self.listdir(path, depth=0, exclude_self=False)
result = self.listdir(path, depth=0, exclude_self=False, extra_properties=extra_properties)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return result[0] if result else None

def find(self, req: list, path: str | FsNode = "") -> list[FsNode]:
def find(self, req: list, path: str | FsNode = "", extra_properties: Sequence[str] | None = None) -> list[FsNode]:
"""Searches a directory for a file or subdirectory with a name.

:param req: list of conditions to search for. Detailed description here...
:param path: path where to search from. Default = **""**.
:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
# `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
root = build_find_request(req, path, self._session.user, self._session.capabilities)
root = build_find_request(req, path, self._session.user, self._session.capabilities, extra_properties)
webdav_response = self._session.adapter_dav.request(
"SEARCH", "", data=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
)
Expand Down Expand Up @@ -248,15 +264,21 @@ def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False)
return self.find(req=["eq", "fileid", response.headers["OC-FileId"]])[0]

def list_by_criteria(
self, properties: list[str] | None = None, tags: list[int | SystemTag] | None = None
self,
properties: list[str] | None = None,
tags: list[int | SystemTag] | None = None,
extra_properties: Sequence[str] | None = None,
) -> list[FsNode]:
"""Returns a list of all files/directories for the current user filtered by the specified values.

:param properties: List of ``properties`` that should have been set for the file.
Supported values: **favorite**
:param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
root = build_list_by_criteria_req(properties, tags, self._session.capabilities)
root = build_list_by_criteria_req(properties, tags, self._session.capabilities, extra_properties)
webdav_response = self._session.adapter_dav.request(
"REPORT", dav_get_obj_path(self._session.user), data=element_tree_as_str(root)
)
Expand All @@ -277,13 +299,19 @@ def setfav(self, path: str | FsNode, value: int | bool) -> None:
)
check_error(webdav_response, f"setfav: path={path}, value={value}")

def trashbin_list(self) -> list[FsNode]:
"""Returns a list of all entries in the TrashBin."""
def trashbin_list(self, extra_properties: Sequence[str] | None = None) -> list[FsNode]:
"""Returns a list of all entries in the TrashBin.

:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
properties = [
*PROPFIND_PROPERTIES,
"nc:trashbin-filename",
"nc:trashbin-original-location",
"nc:trashbin-deletion-time",
*_validate_extra_properties(extra_properties, PROPFIND_PROPERTIES),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
]
return self._listdir(
self._session.user, "", properties=properties, depth=1, exclude_self=False, prop_type=PropFindType.TRASHBIN
Expand Down Expand Up @@ -322,13 +350,18 @@ def trashbin_cleanup(self) -> None:
"""Empties the TrashBin."""
check_error(self._session.adapter_dav.delete(f"/trashbin/{self._session.user}/trash"))

def get_versions(self, file_object: FsNode) -> list[FsNode]:
"""Returns a list of all file versions if any."""
def get_versions(self, file_object: FsNode, extra_properties: Sequence[str] | None = None) -> list[FsNode]:
"""Returns a list of all file versions if any.

:param extra_properties: additional WebDAV properties to request, e.g. ``["nc:has-preview"]``. They are
returned in :py:attr:`~nc_py_api.files.FsNode.extra_properties`, and must use the ``d``, ``oc``
or ``nc`` namespace.
"""
require_capabilities("files.versioning", self._session.capabilities)
return self._listdir(
self._session.user,
str(file_object.info.fileid) if file_object.info.fileid else file_object.file_id,
properties=PROPFIND_PROPERTIES,
properties=[*PROPFIND_PROPERTIES, *_validate_extra_properties(extra_properties, PROPFIND_PROPERTIES)],
depth=1,
exclude_self=False,
prop_type=PropFindType.VERSIONS_FILEID if file_object.info.fileid else PropFindType.VERSIONS_FILE_ID,
Expand Down
Loading