nc-py-api 0.12.0__py3-none-any.whl → 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ """Nextcloud API for working with the file system."""
2
+
3
+ import builtins
4
+ import os
5
+ from pathlib import Path
6
+ from urllib.parse import quote
7
+
8
+ from httpx import Headers
9
+
10
+ from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error
11
+ from .._misc import random_string, require_capabilities
12
+ from .._session import AsyncNcSessionBasic
13
+ from . import FsNode, LockType, SystemTag
14
+ from ._files import (
15
+ PROPFIND_PROPERTIES,
16
+ PropFindType,
17
+ build_find_request,
18
+ build_list_by_criteria_req,
19
+ build_list_tag_req,
20
+ build_list_tags_response,
21
+ build_listdir_req,
22
+ build_listdir_response,
23
+ build_setfav_req,
24
+ build_tags_ids_for_object,
25
+ build_update_tag_req,
26
+ dav_get_obj_path,
27
+ element_tree_as_str,
28
+ etag_fileid_from_response,
29
+ get_propfind_properties,
30
+ lf_parse_webdav_response,
31
+ )
32
+ from .sharing import _AsyncFilesSharingAPI
33
+
34
+
35
+ class AsyncFilesAPI:
36
+ """Class that encapsulates async file system and file sharing API."""
37
+
38
+ sharing: _AsyncFilesSharingAPI
39
+ """API for managing Files Shares"""
40
+
41
+ def __init__(self, session: AsyncNcSessionBasic):
42
+ self._session = session
43
+ self.sharing = _AsyncFilesSharingAPI(session)
44
+
45
+ async def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=True) -> list[FsNode]:
46
+ """Returns a list of all entries in the specified directory.
47
+
48
+ :param path: path to the directory to get the list.
49
+ :param depth: how many directory levels should be included in output. Default = **1** (only specified directory)
50
+ :param exclude_self: boolean value indicating whether the `path` itself should be excluded from the list or not.
51
+ Default = **True**.
52
+ """
53
+ if exclude_self and not depth:
54
+ raise ValueError("Wrong input parameters, query will return nothing.")
55
+ properties = get_propfind_properties(await self._session.capabilities)
56
+ path = path.user_path if isinstance(path, FsNode) else path
57
+ return await self._listdir(
58
+ await self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self
59
+ )
60
+
61
+ async def by_id(self, file_id: int | str | FsNode) -> FsNode | None:
62
+ """Returns :py:class:`~nc_py_api.files.FsNode` by file_id if any.
63
+
64
+ :param file_id: can be full file ID with Nextcloud instance ID or only clear file ID.
65
+ """
66
+ file_id = file_id.file_id if isinstance(file_id, FsNode) else file_id
67
+ result = await self.find(req=["eq", "fileid", file_id])
68
+ return result[0] if result else None
69
+
70
+ async def by_path(self, path: str | FsNode) -> FsNode | None:
71
+ """Returns :py:class:`~nc_py_api.files.FsNode` by exact path if any."""
72
+ path = path.user_path if isinstance(path, FsNode) else path
73
+ result = await self.listdir(path, depth=0, exclude_self=False)
74
+ return result[0] if result else None
75
+
76
+ async def find(self, req: list, path: str | FsNode = "") -> list[FsNode]:
77
+ """Searches a directory for a file or subdirectory with a name.
78
+
79
+ :param req: list of conditions to search for. Detailed description here...
80
+ :param path: path where to search from. Default = **""**.
81
+ """
82
+ # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
83
+ root = build_find_request(req, path, await self._session.user, await self._session.capabilities)
84
+ webdav_response = await self._session.adapter_dav.request(
85
+ "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
86
+ )
87
+ request_info = f"find: {await self._session.user}, {req}, {path}"
88
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
89
+
90
+ async def download(self, path: str | FsNode) -> bytes:
91
+ """Downloads and returns the content of a file."""
92
+ path = path.user_path if isinstance(path, FsNode) else path
93
+ response = await self._session.adapter_dav.get(quote(dav_get_obj_path(await self._session.user, path)))
94
+ check_error(response, f"download: user={await self._session.user}, path={path}")
95
+ return response.content
96
+
97
+ async def download2stream(self, path: str | FsNode, fp, **kwargs) -> None:
98
+ """Downloads file to the given `fp` object.
99
+
100
+ :param path: path to download file.
101
+ :param fp: filename (string), pathlib.Path object or a file object.
102
+ The object must implement the ``file.write`` method and be able to write binary data.
103
+ :param kwargs: **chunk_size** an int value specifying chunk size to write. Default = **5Mb**
104
+ """
105
+ path = quote(dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path))
106
+ await self._session.download2stream(path, fp, dav=True, **kwargs)
107
+
108
+ async def download_directory_as_zip(
109
+ self, path: str | FsNode, local_path: str | Path | None = None, **kwargs
110
+ ) -> Path:
111
+ """Downloads a remote directory as zip archive.
112
+
113
+ :param path: path to directory to download.
114
+ :param local_path: relative or absolute file path to save zip file.
115
+ :returns: Path to the saved zip archive.
116
+
117
+ .. note:: This works only for directories, you should not use this to download a file.
118
+ """
119
+ path = path.user_path if isinstance(path, FsNode) else path
120
+ result_path = local_path if local_path else os.path.basename(path)
121
+ with open(result_path, "wb") as fp:
122
+ await self._session.download2fp(
123
+ "/index.php/apps/files/ajax/download.php", fp, dav=False, params={"dir": path}, **kwargs
124
+ )
125
+ return Path(result_path)
126
+
127
+ async def upload(self, path: str | FsNode, content: bytes | str) -> FsNode:
128
+ """Creates a file with the specified content at the specified path.
129
+
130
+ :param path: file's upload path.
131
+ :param content: content to create the file. If it is a string, it will be encoded into bytes using UTF-8.
132
+ """
133
+ path = path.user_path if isinstance(path, FsNode) else path
134
+ full_path = dav_get_obj_path(await self._session.user, path)
135
+ response = await self._session.adapter_dav.put(quote(full_path), content=content)
136
+ check_error(response, f"upload: user={await self._session.user}, path={path}, size={len(content)}")
137
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
138
+
139
+ async def upload_stream(self, path: str | FsNode, fp, **kwargs) -> FsNode:
140
+ """Creates a file with content provided by `fp` object at the specified path.
141
+
142
+ :param path: file's upload path.
143
+ :param fp: filename (string), pathlib.Path object or a file object.
144
+ The object must implement the ``file.read`` method providing data with str or bytes type.
145
+ :param kwargs: **chunk_size** an int value specifying chunk size to read. Default = **5Mb**
146
+ """
147
+ path = path.user_path if isinstance(path, FsNode) else path
148
+ chunk_size = kwargs.get("chunk_size", 5 * 1024 * 1024)
149
+ if isinstance(fp, str | Path):
150
+ with builtins.open(fp, "rb") as f:
151
+ return await self.__upload_stream(path, f, chunk_size)
152
+ elif hasattr(fp, "read"):
153
+ return await self.__upload_stream(path, fp, chunk_size)
154
+ else:
155
+ raise TypeError("`fp` must be a path to file or an object with `read` method.")
156
+
157
+ async def mkdir(self, path: str | FsNode) -> FsNode:
158
+ """Creates a new directory.
159
+
160
+ :param path: path of the directory to be created.
161
+ """
162
+ path = path.user_path if isinstance(path, FsNode) else path
163
+ full_path = dav_get_obj_path(await self._session.user, path)
164
+ response = await self._session.adapter_dav.request("MKCOL", quote(full_path))
165
+ check_error(response)
166
+ full_path += "/" if not full_path.endswith("/") else ""
167
+ return FsNode(full_path.lstrip("/"), **etag_fileid_from_response(response))
168
+
169
+ async def makedirs(self, path: str | FsNode, exist_ok=False) -> FsNode | None:
170
+ """Creates a new directory and subdirectories.
171
+
172
+ :param path: path of the directories to be created.
173
+ :param exist_ok: ignore error if any of pathname components already exists.
174
+ :returns: `FsNode` if directory was created or ``None`` if it was already created.
175
+ """
176
+ _path = ""
177
+ path = path.user_path if isinstance(path, FsNode) else path
178
+ path = path.lstrip("/")
179
+ result = None
180
+ for i in Path(path).parts:
181
+ _path = os.path.join(_path, i)
182
+ if not exist_ok:
183
+ result = await self.mkdir(_path)
184
+ else:
185
+ try:
186
+ result = await self.mkdir(_path)
187
+ except NextcloudException as e:
188
+ if e.status_code != 405:
189
+ raise e from None
190
+ return result
191
+
192
+ async def delete(self, path: str | FsNode, not_fail=False) -> None:
193
+ """Deletes a file/directory (moves to trash if trash is enabled).
194
+
195
+ :param path: path to delete.
196
+ :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception.
197
+ """
198
+ path = path.user_path if isinstance(path, FsNode) else path
199
+ response = await self._session.adapter_dav.delete(quote(dav_get_obj_path(await self._session.user, path)))
200
+ if response.status_code == 404 and not_fail:
201
+ return
202
+ check_error(response)
203
+
204
+ async def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode:
205
+ """Moves an existing file or a directory.
206
+
207
+ :param path_src: path of an existing file/directory.
208
+ :param path_dest: name of the new one.
209
+ :param overwrite: if ``True`` and the destination object already exists, it gets overwritten.
210
+ Default = **False**.
211
+ """
212
+ path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
213
+ full_dest_path = dav_get_obj_path(
214
+ await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
215
+ )
216
+ dest = self._session.cfg.dav_endpoint + quote(full_dest_path)
217
+ headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
218
+ response = await self._session.adapter_dav.request(
219
+ "MOVE",
220
+ quote(dav_get_obj_path(await self._session.user, path_src)),
221
+ headers=headers,
222
+ )
223
+ check_error(response, f"move: user={await self._session.user}, src={path_src}, dest={dest}, {overwrite}")
224
+ return (await self.find(req=["eq", "fileid", response.headers["OC-FileId"]]))[0]
225
+
226
+ async def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode:
227
+ """Copies an existing file/directory.
228
+
229
+ :param path_src: path of an existing file/directory.
230
+ :param path_dest: name of the new one.
231
+ :param overwrite: if ``True`` and the destination object already exists, it gets overwritten.
232
+ Default = **False**.
233
+ """
234
+ path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
235
+ full_dest_path = dav_get_obj_path(
236
+ await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
237
+ )
238
+ dest = self._session.cfg.dav_endpoint + quote(full_dest_path)
239
+ headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
240
+ response = await self._session.adapter_dav.request(
241
+ "COPY",
242
+ quote(dav_get_obj_path(await self._session.user, path_src)),
243
+ headers=headers,
244
+ )
245
+ check_error(response, f"copy: user={await self._session.user}, src={path_src}, dest={dest}, {overwrite}")
246
+ return (await self.find(req=["eq", "fileid", response.headers["OC-FileId"]]))[0]
247
+
248
+ async def list_by_criteria(
249
+ self, properties: list[str] | None = None, tags: list[int | SystemTag] | None = None
250
+ ) -> list[FsNode]:
251
+ """Returns a list of all files/directories for the current user filtered by the specified values.
252
+
253
+ :param properties: List of ``properties`` that should have been set for the file.
254
+ Supported values: **favorite**
255
+ :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
256
+ """
257
+ root = build_list_by_criteria_req(properties, tags, await self._session.capabilities)
258
+ webdav_response = await self._session.adapter_dav.request(
259
+ "REPORT", dav_get_obj_path(await self._session.user), content=element_tree_as_str(root)
260
+ )
261
+ request_info = f"list_files_by_criteria: {await self._session.user}"
262
+ check_error(webdav_response, request_info)
263
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
264
+
265
+ async def setfav(self, path: str | FsNode, value: int | bool) -> None:
266
+ """Sets or unsets favourite flag for specific file.
267
+
268
+ :param path: path to the object to set the state.
269
+ :param value: value to set for the ``favourite`` state.
270
+ """
271
+ path = path.user_path if isinstance(path, FsNode) else path
272
+ root = build_setfav_req(value)
273
+ webdav_response = await self._session.adapter_dav.request(
274
+ "PROPPATCH", quote(dav_get_obj_path(await self._session.user, path)), content=element_tree_as_str(root)
275
+ )
276
+ check_error(webdav_response, f"setfav: path={path}, value={value}")
277
+
278
+ async def trashbin_list(self) -> list[FsNode]:
279
+ """Returns a list of all entries in the TrashBin."""
280
+ properties = PROPFIND_PROPERTIES
281
+ properties += ["nc:trashbin-filename", "nc:trashbin-original-location", "nc:trashbin-deletion-time"]
282
+ return await self._listdir(
283
+ await self._session.user,
284
+ "",
285
+ properties=properties,
286
+ depth=1,
287
+ exclude_self=False,
288
+ prop_type=PropFindType.TRASHBIN,
289
+ )
290
+
291
+ async def trashbin_restore(self, path: str | FsNode) -> None:
292
+ """Restore a file/directory from the TrashBin.
293
+
294
+ :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself.
295
+ """
296
+ restore_name = path.name if isinstance(path, FsNode) else path.split("/", maxsplit=1)[-1]
297
+ path = path.user_path if isinstance(path, FsNode) else path
298
+
299
+ dest = self._session.cfg.dav_endpoint + f"/trashbin/{await self._session.user}/restore/{restore_name}"
300
+ headers = Headers({"Destination": dest}, encoding="utf-8")
301
+ response = await self._session.adapter_dav.request(
302
+ "MOVE",
303
+ quote(f"/trashbin/{await self._session.user}/{path}"),
304
+ headers=headers,
305
+ )
306
+ check_error(response, f"trashbin_restore: user={await self._session.user}, src={path}, dest={dest}")
307
+
308
+ async def trashbin_delete(self, path: str | FsNode, not_fail=False) -> None:
309
+ """Deletes a file/directory permanently from the TrashBin.
310
+
311
+ :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself.
312
+ :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception.
313
+ """
314
+ path = path.user_path if isinstance(path, FsNode) else path
315
+ response = await self._session.adapter_dav.delete(quote(f"/trashbin/{await self._session.user}/{path}"))
316
+ if response.status_code == 404 and not_fail:
317
+ return
318
+ check_error(response)
319
+
320
+ async def trashbin_cleanup(self) -> None:
321
+ """Empties the TrashBin."""
322
+ check_error(await self._session.adapter_dav.delete(f"/trashbin/{await self._session.user}/trash"))
323
+
324
+ async def get_versions(self, file_object: FsNode) -> list[FsNode]:
325
+ """Returns a list of all file versions if any."""
326
+ require_capabilities("files.versioning", await self._session.capabilities)
327
+ return await self._listdir(
328
+ await self._session.user,
329
+ str(file_object.info.fileid) if file_object.info.fileid else file_object.file_id,
330
+ properties=PROPFIND_PROPERTIES,
331
+ depth=1,
332
+ exclude_self=False,
333
+ prop_type=PropFindType.VERSIONS_FILEID if file_object.info.fileid else PropFindType.VERSIONS_FILE_ID,
334
+ )
335
+
336
+ async def restore_version(self, file_object: FsNode) -> None:
337
+ """Restore a file with specified version.
338
+
339
+ :param file_object: The **FsNode** class from :py:meth:`~nc_py_api.files.files.FilesAPI.get_versions`.
340
+ """
341
+ require_capabilities("files.versioning", await self._session.capabilities)
342
+ dest = self._session.cfg.dav_endpoint + f"/versions/{await self._session.user}/restore/{file_object.name}"
343
+ headers = Headers({"Destination": dest}, encoding="utf-8")
344
+ response = await self._session.adapter_dav.request(
345
+ "MOVE",
346
+ quote(f"/versions/{await self._session.user}/{file_object.user_path}"),
347
+ headers=headers,
348
+ )
349
+ check_error(response, f"restore_version: user={await self._session.user}, src={file_object.user_path}")
350
+
351
+ async def list_tags(self) -> list[SystemTag]:
352
+ """Returns list of the avalaible Tags."""
353
+ root = build_list_tag_req()
354
+ response = await self._session.adapter_dav.request("PROPFIND", "/systemtags", content=element_tree_as_str(root))
355
+ return build_list_tags_response(response)
356
+
357
+ async def get_tags(self, file_id: FsNode | int) -> list[SystemTag]:
358
+ """Returns list of Tags assigned to the File or Directory."""
359
+ fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
360
+ url_to_fetch = f"/systemtags-relations/files/{fs_object}/"
361
+ response = await self._session.adapter_dav.request("PROPFIND", url_to_fetch)
362
+ object_tags_ids = build_tags_ids_for_object(self._session.cfg.dav_url_suffix + url_to_fetch, response)
363
+ if not object_tags_ids:
364
+ return []
365
+ all_tags = await self.list_tags()
366
+ return [tag for tag in all_tags if tag.tag_id in object_tags_ids]
367
+
368
+ async def create_tag(self, name: str, user_visible: bool = True, user_assignable: bool = True) -> None:
369
+ """Creates a new Tag.
370
+
371
+ :param name: Name of the tag.
372
+ :param user_visible: Should be Tag visible in the UI.
373
+ :param user_assignable: Can Tag be assigned from the UI.
374
+ """
375
+ response = await self._session.adapter_dav.post(
376
+ "/systemtags",
377
+ json={
378
+ "name": name,
379
+ "userVisible": user_visible,
380
+ "userAssignable": user_assignable,
381
+ },
382
+ )
383
+ check_error(response, info=f"create_tag({name})")
384
+
385
+ async def update_tag(
386
+ self,
387
+ tag_id: int | SystemTag,
388
+ name: str | None = None,
389
+ user_visible: bool | None = None,
390
+ user_assignable: bool | None = None,
391
+ ) -> None:
392
+ """Updates the Tag information."""
393
+ tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
394
+ root = build_update_tag_req(name, user_visible, user_assignable)
395
+ response = await self._session.adapter_dav.request(
396
+ "PROPPATCH", f"/systemtags/{tag_id}", content=element_tree_as_str(root)
397
+ )
398
+ check_error(response)
399
+
400
+ async def delete_tag(self, tag_id: int | SystemTag) -> None:
401
+ """Deletes the tag."""
402
+ tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
403
+ response = await self._session.adapter_dav.delete(f"/systemtags/{tag_id}")
404
+ check_error(response)
405
+
406
+ async def tag_by_name(self, tag_name: str) -> SystemTag:
407
+ """Returns Tag info by its name if found or ``None`` otherwise."""
408
+ r = [i for i in await self.list_tags() if i.display_name == tag_name]
409
+ if not r:
410
+ raise NextcloudExceptionNotFound(f"Tag with name='{tag_name}' not found.")
411
+ return r[0]
412
+
413
+ async def assign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None:
414
+ """Assigns Tag to a file/directory."""
415
+ await self._file_change_tag_state(file_id, tag_id, True)
416
+
417
+ async def unassign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None:
418
+ """Removes Tag from a file/directory."""
419
+ await self._file_change_tag_state(file_id, tag_id, False)
420
+
421
+ async def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None:
422
+ """Locks the file.
423
+
424
+ .. note:: Exception codes: 423 - existing lock present.
425
+ """
426
+ require_capabilities("files.locking", await self._session.capabilities)
427
+ full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path)
428
+ response = await self._session.adapter_dav.request(
429
+ "LOCK",
430
+ quote(full_path),
431
+ headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)},
432
+ )
433
+ check_error(response, f"lock: user={self._session.user}, path={full_path}")
434
+
435
+ async def unlock(self, path: FsNode | str) -> None:
436
+ """Unlocks the file.
437
+
438
+ .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user.
439
+ """
440
+ require_capabilities("files.locking", await self._session.capabilities)
441
+ full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path)
442
+ response = await self._session.adapter_dav.request(
443
+ "UNLOCK",
444
+ quote(full_path),
445
+ headers={"X-User-Lock": "1"},
446
+ )
447
+ check_error(response, f"unlock: user={self._session.user}, path={full_path}")
448
+
449
+ async def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None:
450
+ fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
451
+ tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
452
+ response = await self._session.adapter_dav.request(
453
+ "PUT" if tag_state else "DELETE", f"/systemtags-relations/files/{fs_object}/{tag}"
454
+ )
455
+ check_error(
456
+ response,
457
+ info=f"({'Adding' if tag_state else 'Removing'} `{tag}` {'to' if tag_state else 'from'} {fs_object})",
458
+ )
459
+
460
+ async def _listdir(
461
+ self,
462
+ user: str,
463
+ path: str,
464
+ properties: list[str],
465
+ depth: int,
466
+ exclude_self: bool,
467
+ prop_type: PropFindType = PropFindType.DEFAULT,
468
+ ) -> list[FsNode]:
469
+ root, dav_path = build_listdir_req(user, path, properties, prop_type)
470
+ webdav_response = await self._session.adapter_dav.request(
471
+ "PROPFIND",
472
+ quote(dav_path),
473
+ content=element_tree_as_str(root),
474
+ headers={"Depth": "infinity" if depth == -1 else str(depth)},
475
+ )
476
+ return build_listdir_response(
477
+ self._session.cfg.dav_url_suffix, webdav_response, user, path, properties, exclude_self, prop_type
478
+ )
479
+
480
+ async def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode:
481
+ _tmp_path = "nc-py-api-" + random_string(56)
482
+ _dav_path = quote(dav_get_obj_path(await self._session.user, _tmp_path, root_path="/uploads"))
483
+ _v2 = bool(self._session.cfg.options.upload_chunk_v2 and chunk_size >= 5 * 1024 * 1024)
484
+ full_path = dav_get_obj_path(await self._session.user, path)
485
+ headers = Headers({"Destination": self._session.cfg.dav_endpoint + quote(full_path)}, encoding="utf-8")
486
+ if _v2:
487
+ response = await self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers)
488
+ else:
489
+ response = await self._session.adapter_dav.request("MKCOL", _dav_path)
490
+ check_error(response)
491
+ try:
492
+ start_bytes = end_bytes = chunk_number = 0
493
+ while True:
494
+ piece = fp.read(chunk_size)
495
+ if not piece:
496
+ break
497
+ end_bytes = start_bytes + len(piece)
498
+ if _v2:
499
+ response = await self._session.adapter_dav.put(
500
+ _dav_path + "/" + str(chunk_number), content=piece, headers=headers
501
+ )
502
+ else:
503
+ _filename = str(start_bytes).rjust(15, "0") + "-" + str(end_bytes).rjust(15, "0")
504
+ response = await self._session.adapter_dav.put(_dav_path + "/" + _filename, content=piece)
505
+ check_error(
506
+ response,
507
+ f"upload_stream(v={_v2}): user={await self._session.user}, path={path}, cur_size={end_bytes}",
508
+ )
509
+ start_bytes = end_bytes
510
+ chunk_number += 1
511
+
512
+ response = await self._session.adapter_dav.request(
513
+ "MOVE",
514
+ _dav_path + "/.file",
515
+ headers=headers,
516
+ )
517
+ check_error(
518
+ response,
519
+ f"upload_stream(v={_v2}): user={await self._session.user}, path={path}, total_size={end_bytes}",
520
+ )
521
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
522
+ finally:
523
+ await self._session.adapter_dav.delete(_dav_path)
@@ -0,0 +1,161 @@
1
+ """Login flow v2 API wrapper."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+ from ._exceptions import check_error
11
+ from ._session import AsyncNcSession, NcSession
12
+
13
+ MAX_TIMEOUT = 60 * 20
14
+
15
+
16
+ @dataclass
17
+ class LoginFlow:
18
+ """The Nextcloud Login flow v2 initialization response representation."""
19
+
20
+ def __init__(self, raw_data: dict) -> None:
21
+ self.raw_data = raw_data
22
+
23
+ @property
24
+ def login(self) -> str:
25
+ """The URL for user authorization.
26
+
27
+ Should be opened by the user in the default browser to authorize in Nextcloud.
28
+ """
29
+ return self.raw_data["login"]
30
+
31
+ @property
32
+ def token(self) -> str:
33
+ """Token for a polling for confirmation of user authorization."""
34
+ return self.raw_data["poll"]["token"]
35
+
36
+ @property
37
+ def endpoint(self) -> str:
38
+ """Endpoint for polling."""
39
+ return self.raw_data["poll"]["endpoint"]
40
+
41
+ def __repr__(self) -> str:
42
+ return f"<{self.__class__.__name__} login_url={self.login}>"
43
+
44
+
45
+ @dataclass
46
+ class Credentials:
47
+ """The Nextcloud Login flow v2 response with app credentials representation."""
48
+
49
+ def __init__(self, raw_data: dict) -> None:
50
+ self.raw_data = raw_data
51
+
52
+ @property
53
+ def server(self) -> str:
54
+ """The address of Nextcloud to connect to.
55
+
56
+ The server may specify a protocol (http or https). If no protocol is specified https will be used.
57
+ """
58
+ return self.raw_data["server"]
59
+
60
+ @property
61
+ def login_name(self) -> str:
62
+ """The username for authenticating with Nextcloud."""
63
+ return self.raw_data["loginName"]
64
+
65
+ @property
66
+ def app_password(self) -> str:
67
+ """The application password generated for authenticating with Nextcloud."""
68
+ return self.raw_data["appPassword"]
69
+
70
+ def __repr__(self) -> str:
71
+ return f"<{self.__class__.__name__} login={self.login_name} app_password={self.app_password}>"
72
+
73
+
74
+ class _LoginFlowV2API:
75
+ """Class implementing Nextcloud Login flow v2."""
76
+
77
+ _ep_init: str = "/index.php/login/v2"
78
+ _ep_poll: str = "/index.php/login/v2/poll"
79
+
80
+ def __init__(self, session: NcSession) -> None:
81
+ self._session = session
82
+
83
+ def init(self, user_agent: str = "nc_py_api") -> LoginFlow:
84
+ """Init a Login flow v2.
85
+
86
+ :param user_agent: Application name. Application password will be associated with this name.
87
+ """
88
+ r = self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent})
89
+ return LoginFlow(_res_to_json(r))
90
+
91
+ def poll(self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True) -> Credentials:
92
+ """Poll the Login flow v2 credentials.
93
+
94
+ :param token: Token for a polling for confirmation of user authorization.
95
+ :param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT.
96
+ :param step: Interval for polling in seconds, defaults to 1.
97
+ :param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True.
98
+ :raises ValueError: If timeout more than 20 minutes.
99
+ """
100
+ if timeout > MAX_TIMEOUT:
101
+ msg = "Timeout can't be more than 20 minutes."
102
+ raise ValueError(msg)
103
+ for _ in range(timeout // step):
104
+ r = self._session.adapter.post(self._ep_poll, data={"token": token})
105
+ if r.status_code == 200:
106
+ break
107
+ time.sleep(step)
108
+ r_model = Credentials(_res_to_json(r))
109
+ if overwrite_auth:
110
+ self._session.cfg.auth = (r_model.login_name, r_model.app_password)
111
+ self._session.init_adapter(restart=True)
112
+ self._session.init_adapter_dav(restart=True)
113
+ return r_model
114
+
115
+
116
+ class _AsyncLoginFlowV2API:
117
+ """Class implementing Async Nextcloud Login flow v2."""
118
+
119
+ _ep_init: str = "/index.php/login/v2"
120
+ _ep_poll: str = "/index.php/login/v2/poll"
121
+
122
+ def __init__(self, session: AsyncNcSession) -> None:
123
+ self._session = session
124
+
125
+ async def init(self, user_agent: str = "nc_py_api") -> LoginFlow:
126
+ """Init a Login flow v2.
127
+
128
+ :param user_agent: Application name. Application password will be associated with this name.
129
+ """
130
+ r = await self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent})
131
+ return LoginFlow(_res_to_json(r))
132
+
133
+ async def poll(
134
+ self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True
135
+ ) -> Credentials:
136
+ """Poll the Login flow v2 credentials.
137
+
138
+ :param token: Token for a polling for confirmation of user authorization.
139
+ :param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT.
140
+ :param step: Interval for polling in seconds, defaults to 1.
141
+ :param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True.
142
+ :raises ValueError: If timeout more than 20 minutes.
143
+ """
144
+ if timeout > MAX_TIMEOUT:
145
+ raise ValueError("Timeout can't be more than 20 minutes.")
146
+ for _ in range(timeout // step):
147
+ r = await self._session.adapter.post(self._ep_poll, data={"token": token})
148
+ if r.status_code == 200:
149
+ break
150
+ await asyncio.sleep(step)
151
+ r_model = Credentials(_res_to_json(r))
152
+ if overwrite_auth:
153
+ self._session.cfg.auth = (r_model.login_name, r_model.app_password)
154
+ self._session.init_adapter(restart=True)
155
+ self._session.init_adapter_dav(restart=True)
156
+ return r_model
157
+
158
+
159
+ def _res_to_json(response: httpx.Response) -> dict:
160
+ check_error(response)
161
+ return json.loads(response.text)