Skip to content

Commit 9aef8be

Browse files
committed
FsNode can be accepted as a filepath.
Signed-off-by: Alexander Piskun <[email protected]>
1 parent 901a2fe commit 9aef8be

File tree

4 files changed

+133
-24
lines changed

4 files changed

+133
-24
lines changed

docs/reference/Files.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
.. py:currentmodule:: nc_py_api.files
2+
13
File System
24
===========
35

46
The Files API is universal for both modes and provides all the necessary methods for working with the Nextcloud file system.
57
Refer to the **fs examples** to see how to use them nicely.
68

7-
.. autoclass:: nc_py_api.files.FilesAPI
9+
All File APIs are designed to work relative to the current user.
10+
11+
.. autoclass:: FsNode
12+
:members:
13+
14+
.. autoclass:: FilesAPI
815
:members:

nc_py_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ._version import __version__
66
from .constants import ApiScope, LogLvl
77
from .exceptions import NextcloudException, check_error
8-
from .files import FsNodeInfo
8+
from .files import FsNode, FsNodeInfo
99
from .integration_fastapi import (
1010
enable_heartbeat,
1111
nc_app,

nc_py_api/files.py

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"""
44

55
import builtins
6+
import os
67
from dataclasses import dataclass
78
from datetime import datetime
89
from email.utils import parsedate_to_datetime
910
from io import BytesIO
1011
from json import dumps, loads
11-
from os import path as p
1212
from pathlib import Path
1313
from random import choice
1414
from string import ascii_lowercase, digits
@@ -24,8 +24,12 @@
2424

2525

2626
class FsNodeInfo(TypedDict):
27+
"""Extra FS object attributes from Nextcloud"""
28+
2729
nc_id: str
30+
"""Nextcloud instance ID."""
2831
fileid: int
32+
"""Object file ID."""
2933
etag: str
3034
size: int
3135
content_length: int
@@ -36,6 +40,19 @@ class FsNodeInfo(TypedDict):
3640

3741
@dataclass
3842
class FsNode:
43+
"""A class that represents a Nextcloud file object.
44+
45+
Acceptable itself as a ``path`` parameter for the most file APIs."""
46+
47+
user: str
48+
"""The username of the object. May be different from the owner of the object if it is shared."""
49+
50+
path: str
51+
"""Path to the object. Does not include the username, but includes the object name."""
52+
53+
name: str
54+
"""last ``pathname`` component."""
55+
3956
def __init__(self, user: str, path: str, name: str, **kwargs):
4057
self.user = user
4158
self.path = path
@@ -66,10 +83,14 @@ def last_modified(self, value: Union[str, datetime]):
6683

6784
@property
6885
def is_dir(self) -> bool:
86+
"""Returns ``True`` for the directories, ``False`` otherwise."""
87+
6988
return self.path.endswith("/")
7089

7190
@property
7291
def full_path(self) -> str:
92+
"""Full path including username."""
93+
7394
return f"{self.user}/{self.path.lstrip('/')}" if self.user else self.path
7495

7596
def __str__(self):
@@ -117,20 +138,40 @@ class FilesAPI:
117138
def __init__(self, session: NcSessionBasic):
118139
self._session = session
119140

120-
def listdir(self, path="", exclude_self=True) -> list[FsNode]:
141+
def listdir(self, path: Union[str, FsNode] = "", exclude_self=True) -> list[FsNode]:
142+
"""Returns a list of all entries in the specified directory.
143+
144+
:param path: Path to the directory to get the list.
145+
:param exclude_self: Boolean value indicating whether the `path` itself should be excluded from the list or not.
146+
Default = **True**.
147+
"""
148+
121149
properties = PROPFIND_PROPERTIES
150+
path = path.path if isinstance(path, FsNode) else path
122151
return self._listdir(self._session.user, path, properties=properties, exclude_self=exclude_self)
123152

124153
def by_id(self, fileid: int) -> Optional[FsNode]:
154+
"""Returns :py:class:`FsNode` by fileid if any."""
155+
125156
result = self.find(req=["eq", "fileid", fileid])
126157
return result[0] if result else None
127158

128159
def by_path(self, path: str) -> Optional[FsNode]:
160+
"""Returns :py:class:`FsNode` by exact path if any."""
161+
129162
result = self.listdir(path, exclude_self=False)
130163
return result[0] if result else None
131164

132-
def find(self, req: list, path="", depth=-1) -> list[FsNode]:
165+
def find(self, req: list, path: Union[str, FsNode] = "", depth=-1) -> list[FsNode]:
166+
"""Searches a directory for a file or subdirectory with a name.
167+
168+
:param req: list of conditions to search for. Detailed description here...
169+
:param path: Path where to search from. Default = **""**.
170+
:param depth: In how many levels of subdirectories to search. Default = **-1**.
171+
"""
172+
133173
# `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
174+
path = path.path if isinstance(path, FsNode) else path
134175
root = ElementTree.Element(
135176
"d:searchrequest",
136177
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
@@ -158,25 +199,27 @@ def find(self, req: list, path="", depth=-1) -> list[FsNode]:
158199
request_info = f"find: {self._session.user}, {req}, {path}, {depth}"
159200
return self._lf_parse_webdav_records(webdav_response, self._session.user, request_info)
160201

161-
def download(self, path: str) -> bytes:
202+
def download(self, path: Union[str, FsNode]) -> bytes:
162203
"""Downloads and returns the content of a file.
163204
164-
:param path: Path to a file to download relative to root directory of the user.
205+
:param path: Path to download file.
165206
"""
166207

208+
path = path.path if isinstance(path, FsNode) else path
167209
response = self._session.dav("GET", self._dav_get_obj_path(self._session.user, path))
168210
check_error(response.status_code, f"download: user={self._session.user}, path={path}")
169211
return response.content
170212

171-
def download2stream(self, path: str, fp, **kwargs) -> None:
213+
def download2stream(self, path: Union[str, FsNode], fp, **kwargs) -> None:
172214
"""Downloads file to the given `fp` object.
173215
174-
:param path: Path to a file to download relative to root directory of the user.
216+
:param path: Path to download file.
175217
:param fp: A filename (string), pathlib.Path object or a file object.
176218
The object must implement the ``file.write`` method and be able to write binary data.
177219
:param kwargs: **chunk_size** an int value specifying chunk size to write. Default = **4Mb**
178220
"""
179221

222+
path = path.path if isinstance(path, FsNode) else path
180223
if isinstance(fp, (str, Path)):
181224
with builtins.open(fp, "wb") as f:
182225
self.__download2stream(path, f, **kwargs)
@@ -185,25 +228,27 @@ def download2stream(self, path: str, fp, **kwargs) -> None:
185228
else:
186229
raise TypeError("`fp` must be a path to file or an object with `write` method.")
187230

188-
def upload(self, path: str, content: Union[bytes, str]) -> None:
231+
def upload(self, path: Union[str, FsNode], content: Union[bytes, str]) -> None:
189232
"""Creates a file with the specified content at the specified path.
190233
191-
:param path: Path to a file to download relative to root directory of the user.
234+
:param path: File upload path.
192235
:param content: content to create the file. If it is a string, it will be encoded into bytes using UTF-8.
193236
"""
194237

238+
path = path.path if isinstance(path, FsNode) else path
195239
response = self._session.dav("PUT", self._dav_get_obj_path(self._session.user, path), data=content)
196240
check_error(response.status_code, f"upload: user={self._session.user}, path={path}, size={len(content)}")
197241

198-
def upload_stream(self, path: str, fp, **kwargs) -> None:
242+
def upload_stream(self, path: Union[str, FsNode], fp, **kwargs) -> None:
199243
"""Creates a file with content provided by `fp` object at the specified path.
200244
201-
:param path: Path to a file to download relative to root directory of the user.
245+
:param path: File upload path.
202246
:param fp: A filename (string), pathlib.Path object or a file object.
203247
The object must implement the ``file.read`` method providing data with str or bytes type.
204248
:param kwargs: **chunk_size** an int value specifying chunk size to read. Default = **4Mb**
205249
"""
206250

251+
path = path.path if isinstance(path, FsNode) else path
207252
if isinstance(fp, (str, Path)):
208253
with builtins.open(fp, "rb") as f:
209254
self.__upload_stream(path, f, **kwargs)
@@ -212,14 +257,27 @@ def upload_stream(self, path: str, fp, **kwargs) -> None:
212257
else:
213258
raise TypeError("`fp` must be a path to file or an object with `read` method.")
214259

215-
def mkdir(self, path: str) -> None:
260+
def mkdir(self, path: Union[str, FsNode]) -> None:
261+
"""Creates a new directory.
262+
263+
:param path: The path of the directory to be created.
264+
"""
265+
266+
path = path.path if isinstance(path, FsNode) else path
216267
response = self._session.dav("MKCOL", self._dav_get_obj_path(self._session.user, path))
217268
check_error(response.status_code, f"mkdir: user={self._session.user}, path={path}")
218269

219-
def makedirs(self, path: str, exist_ok=False) -> None:
270+
def makedirs(self, path: Union[str, FsNode], exist_ok=False) -> None:
271+
"""Creates a new directory and subdirectories.
272+
273+
:param path: The path of the directories to be created.
274+
:param exist_ok: Ignore error if any of pathname components already exists.
275+
"""
276+
220277
_path = ""
278+
path = path.path if isinstance(path, FsNode) else path
221279
for i in Path(path).parts:
222-
_path = p.join(_path, i)
280+
_path = os.path.join(_path, i)
223281
if not exist_ok:
224282
self.mkdir(_path)
225283
else:
@@ -229,13 +287,30 @@ def makedirs(self, path: str, exist_ok=False) -> None:
229287
if e.status_code != 405:
230288
raise e from None
231289

232-
def delete(self, path: str, not_fail=False) -> None:
290+
def delete(self, path: Union[str, FsNode], not_fail=False) -> None:
291+
"""Deletes a file/directory (moves to trash if trash is enabled).
292+
293+
:param path: Path to delete.
294+
:param not_fail: if set to ``True`` and object is not found, does not raise an exception.
295+
"""
296+
297+
path = path.path if isinstance(path, FsNode) else path
233298
response = self._session.dav("DELETE", self._dav_get_obj_path(self._session.user, path))
234299
if response.status_code == 404 and not_fail:
235300
return
236301
check_error(response.status_code, f"delete: user={self._session.user}, path={path}")
237302

238-
def move(self, path_src: str, path_dest: str, overwrite=False) -> None:
303+
def move(self, path_src: Union[str, FsNode], path_dest: Union[str, FsNode], overwrite=False) -> None:
304+
"""Moves an existing file or a directory.
305+
306+
:param path_src: The path of an existing file/directory.
307+
:param path_dest: The name of the new one.
308+
:param overwrite: If ``True`` and destination object already exists it gets overwritten.
309+
Default = **False**.
310+
"""
311+
312+
path_src = path_src.path if isinstance(path_src, FsNode) else path_src
313+
path_dest = path_dest.path if isinstance(path_dest, FsNode) else path_dest
239314
dest = self._session.cfg.dav_endpoint + self._dav_get_obj_path(self._session.user, path_dest)
240315
headers = {"Destination": dest, "Overwrite": "T" if overwrite else "F"}
241316
response = self._session.dav(
@@ -245,7 +320,17 @@ def move(self, path_src: str, path_dest: str, overwrite=False) -> None:
245320
)
246321
check_error(response.status_code, f"move: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}")
247322

248-
def copy(self, path_src: str, path_dest: str, overwrite=False) -> None:
323+
def copy(self, path_src: Union[str, FsNode], path_dest: Union[str, FsNode], overwrite=False) -> None:
324+
"""Copies an existing file/directory.
325+
326+
:param path_src: The path of an existing file/directory.
327+
:param path_dest: The name of the new one.
328+
:param overwrite: If ``True`` and destination object already exists it gets overwritten.
329+
Default = **False**.
330+
"""
331+
332+
path_src = path_src.path if isinstance(path_src, FsNode) else path_src
333+
path_dest = path_dest.path if isinstance(path_dest, FsNode) else path_dest
249334
dest = self._session.cfg.dav_endpoint + self._dav_get_obj_path(self._session.user, path_dest)
250335
headers = {"Destination": dest, "Overwrite": "T" if overwrite else "F"}
251336
response = self._session.dav(
@@ -256,6 +341,8 @@ def copy(self, path_src: str, path_dest: str, overwrite=False) -> None:
256341
check_error(response.status_code, f"copy: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}")
257342

258343
def listfav(self) -> list[FsNode]:
344+
"""Returns a list of the current user's favorite files."""
345+
259346
root = ElementTree.Element(
260347
"oc:filter-files",
261348
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
@@ -269,7 +356,14 @@ def listfav(self) -> list[FsNode]:
269356
check_error(webdav_response.status_code, request_info)
270357
return self._lf_parse_webdav_records(webdav_response, self._session.user, request_info, favorite=True)
271358

272-
def setfav(self, path: str, value: Union[int, bool]) -> None:
359+
def setfav(self, path: Union[str, FsNode], value: Union[int, bool]) -> None:
360+
"""Sets or unsets favourite flag for specific file.
361+
362+
:param path: Path to the object to set the state.
363+
:param value: The value to set for the ``favourite`` state.
364+
"""
365+
366+
path = path.path if isinstance(path, FsNode) else path
273367
root = ElementTree.Element(
274368
"d:propertyupdate",
275369
attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
@@ -326,8 +420,8 @@ def _parse_records(self, fs_records: list[dict], user: str, favorite: bool):
326420
return result
327421

328422
@staticmethod
329-
def _parse_record(prop_stats: list[dict], user: str, obg_rel_path: str, obj_name: str) -> FsNode:
330-
fs_node = FsNode(user=user, path=obg_rel_path, name=obj_name)
423+
def _parse_record(prop_stats: list[dict], user: str, obj_rel_path: str, obj_name: str) -> FsNode:
424+
fs_node = FsNode(user=user, path=obj_rel_path, name=obj_name)
331425
for prop_stat in prop_stats:
332426
if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
333427
continue

tests/files_test.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
from PIL import Image
11-
from nc_py_api import NextcloudException
11+
from nc_py_api import NextcloudException, FsNode
1212

1313
from gfixture import NC_TO_TEST
1414

@@ -36,6 +36,9 @@ def test_list_user_root(nc):
3636
assert obj.user
3737
assert obj.info["nc_id"]
3838
assert obj.info["fileid"]
39+
root_node = FsNode(user=nc.user, path="", name="")
40+
user_root2 = nc.files.listdir(root_node)
41+
assert user_root == user_root2
3942

4043

4144
@pytest.mark.parametrize("nc", NC_TO_TEST)
@@ -208,7 +211,12 @@ def test_favorites(nc):
208211
for n in files:
209212
nc.files.upload(n, content=n)
210213
nc.files.setfav(n, True)
211-
assert len(nc.files.listfav()) == 3
214+
favorites = nc.files.listfav()
215+
assert len(favorites) == 3
216+
for favorite in favorites:
217+
assert isinstance(favorite, FsNode)
218+
nc.files.setfav(favorite, False)
219+
assert len(nc.files.listfav()) == 0
212220
for n in files:
213221
nc.files.delete(n)
214222

0 commit comments

Comments
 (0)