nc-py-api 0.10.0__py3-none-any.whl → 0.18.1__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.
@@ -4,10 +4,76 @@ import dataclasses
4
4
  import datetime
5
5
  import email.utils
6
6
  import enum
7
+ import os
8
+ import re
7
9
  import warnings
8
10
 
11
+ from pydantic import BaseModel
12
+
9
13
  from .. import _misc
10
14
 
15
+ user_regex = re.compile(r"(?:files|trashbin|versions)/([^/]+)/")
16
+ """Regex for evaluating user from full path string; instantiated once on import."""
17
+ user_path_regex = re.compile(r".*?(files|trashbin|versions)/([^/]+)/")
18
+ """Regex for evaluating user path from full path string; instantiated once on import."""
19
+
20
+
21
+ class LockType(enum.IntEnum):
22
+ """Nextcloud File Locks types."""
23
+
24
+ MANUAL_LOCK = 0
25
+ COLLABORATIVE_LOCK = 1
26
+ WEBDAV_TOKEN = 2
27
+
28
+
29
+ @dataclasses.dataclass
30
+ class FsNodeLockInfo:
31
+ """File Lock information if Nextcloud `files_lock` is enabled."""
32
+
33
+ def __init__(self, **kwargs):
34
+ self._is_locked = bool(int(kwargs.get("is_locked", False)))
35
+ self._lock_owner_type = LockType(int(kwargs.get("lock_owner_type", 0)))
36
+ self._lock_owner = kwargs.get("lock_owner", "")
37
+ self._owner_display_name = kwargs.get("owner_display_name", "")
38
+ self._owner_editor = kwargs.get("lock_owner_editor", "")
39
+ self._lock_time = int(kwargs.get("lock_time", 0))
40
+ self._lock_ttl = int(kwargs.get("_lock_ttl", 0))
41
+
42
+ @property
43
+ def is_locked(self) -> bool:
44
+ """Returns ``True`` if the file is locked, ``False`` otherwise."""
45
+ return self._is_locked
46
+
47
+ @property
48
+ def type(self) -> LockType:
49
+ """Type of the lock."""
50
+ return LockType(self._lock_owner_type)
51
+
52
+ @property
53
+ def owner(self) -> str:
54
+ """User id of the lock owner."""
55
+ return self._lock_owner
56
+
57
+ @property
58
+ def owner_display_name(self) -> str:
59
+ """Display name of the lock owner."""
60
+ return self._owner_display_name
61
+
62
+ @property
63
+ def owner_editor(self) -> str:
64
+ """App id of an app owned lock to allow clients to suggest joining the collaborative editing session."""
65
+ return self._owner_editor
66
+
67
+ @property
68
+ def lock_creation_time(self) -> datetime.datetime:
69
+ """Lock creation time."""
70
+ return datetime.datetime.utcfromtimestamp(self._lock_time).replace(tzinfo=datetime.timezone.utc)
71
+
72
+ @property
73
+ def lock_ttl(self) -> int:
74
+ """TTL of the lock in seconds staring from the creation time. A value of 0 means the timeout is infinite."""
75
+ return self._lock_ttl
76
+
11
77
 
12
78
  @dataclasses.dataclass
13
79
  class FsNodeInfo:
@@ -116,11 +182,15 @@ class FsNode:
116
182
  info: FsNodeInfo
117
183
  """Additional extra information for the object"""
118
184
 
185
+ lock_info: FsNodeLockInfo
186
+ """Class describing `lock` information if any."""
187
+
119
188
  def __init__(self, full_path: str, **kwargs):
120
189
  self.full_path = full_path
121
190
  self.file_id = kwargs.get("file_id", "")
122
191
  self.etag = kwargs.get("etag", "")
123
192
  self.info = FsNodeInfo(**kwargs)
193
+ self.lock_info = FsNodeLockInfo(**kwargs)
124
194
 
125
195
  @property
126
196
  def is_dir(self) -> bool:
@@ -139,9 +209,7 @@ class FsNode:
139
209
  )
140
210
 
141
211
  def __eq__(self, other):
142
- if self.file_id and self.file_id == other.file_id:
143
- return True
144
- return False
212
+ return bool(self.file_id and self.file_id == other.file_id)
145
213
 
146
214
  @property
147
215
  def has_extra(self) -> bool:
@@ -156,12 +224,12 @@ class FsNode:
156
224
  @property
157
225
  def user(self) -> str:
158
226
  """Returns user ID extracted from the `full_path`."""
159
- return self.full_path.lstrip("/").split("/", maxsplit=2)[1]
227
+ return user_regex.findall(self.full_path)[0]
160
228
 
161
229
  @property
162
230
  def user_path(self) -> str:
163
231
  """Returns path relative to the user's root directory."""
164
- return self.full_path.lstrip("/").split("/", maxsplit=2)[-1]
232
+ return user_path_regex.sub("", self.full_path, count=1)
165
233
 
166
234
  @property
167
235
  def is_shared(self) -> bool:
@@ -218,12 +286,13 @@ class FilePermissions(enum.IntFlag):
218
286
  """Access to re-share object(s)"""
219
287
 
220
288
 
221
- def permissions_to_str(permissions: int, is_dir: bool = False) -> str:
289
+ def permissions_to_str(permissions: int | str, is_dir: bool = False) -> str:
222
290
  """Converts integer permissions to string permissions.
223
291
 
224
292
  :param permissions: concatenation of ``FilePermissions`` integer flags.
225
293
  :param is_dir: Flag indicating is permissions related to the directory object or not.
226
294
  """
295
+ permissions = int(permissions) if not isinstance(permissions, int) else permissions
227
296
  r = ""
228
297
  if permissions & FilePermissions.PERMISSION_SHARE:
229
298
  r += "R"
@@ -400,3 +469,65 @@ class Share:
400
469
  f"{self.share_type.name}: `{self.path}` with id={self.share_id}"
401
470
  f" from {self.share_owner} to {self.share_with}"
402
471
  )
472
+
473
+
474
+ class ActionFileInfo(BaseModel):
475
+ """Information Nextcloud sends to the External Application about File Nodes affected in action."""
476
+
477
+ fileId: int
478
+ """FileID without Nextcloud instance ID"""
479
+ name: str
480
+ """Name of the file/directory"""
481
+ directory: str
482
+ """Directory relative to the user's home directory"""
483
+ etag: str
484
+ mime: str
485
+ fileType: str
486
+ """**file** or **dir**"""
487
+ size: int
488
+ """size of file/directory"""
489
+ favorite: str
490
+ """**true** or **false**"""
491
+ permissions: int
492
+ """Combination of :py:class:`~nc_py_api.files.FilePermissions` values"""
493
+ mtime: int
494
+ """Last modified time"""
495
+ userId: str
496
+ """The ID of the user performing the action."""
497
+ shareOwner: str | None = None
498
+ """If the object is shared, this is a display name of the share owner."""
499
+ shareOwnerId: str | None = None
500
+ """If the object is shared, this is the owner ID of the share."""
501
+ instanceId: str | None = None
502
+ """Nextcloud instance ID."""
503
+
504
+ def to_fs_node(self) -> FsNode:
505
+ """Returns usual :py:class:`~nc_py_api.files.FsNode` created from this class."""
506
+ user_path = os.path.join(self.directory, self.name).rstrip("/")
507
+ is_dir = bool(self.fileType.lower() == "dir")
508
+ if is_dir:
509
+ user_path += "/"
510
+ full_path = os.path.join(f"files/{self.userId}", user_path.lstrip("/"))
511
+ file_id = str(self.fileId).rjust(8, "0")
512
+
513
+ permissions = "S" if self.shareOwnerId else ""
514
+ permissions += permissions_to_str(self.permissions, is_dir)
515
+ return FsNode(
516
+ full_path,
517
+ etag=self.etag,
518
+ size=self.size,
519
+ content_length=0 if is_dir else self.size,
520
+ permissions=permissions,
521
+ favorite=bool(self.favorite.lower() == "true"),
522
+ file_id=file_id + self.instanceId if self.instanceId else file_id,
523
+ fileid=self.fileId,
524
+ last_modified=datetime.datetime.utcfromtimestamp(self.mtime).replace(tzinfo=datetime.timezone.utc),
525
+ mimetype=self.mime,
526
+ )
527
+
528
+
529
+ class ActionFileInfoEx(BaseModel):
530
+ """New ``register_ex`` uses new data format which allowing receiving multiple NC Nodes in one request."""
531
+
532
+ files: list[ActionFileInfo]
533
+ """Always list of ``ActionFileInfo`` with one element minimum."""
nc_py_api/files/_files.py CHANGED
@@ -10,7 +10,7 @@ import xmltodict
10
10
  from httpx import Response
11
11
 
12
12
  from .._exceptions import NextcloudException, check_error
13
- from .._misc import clear_from_params_empty
13
+ from .._misc import check_capabilities, clear_from_params_empty
14
14
  from . import FsNode, SystemTag
15
15
 
16
16
  PROPFIND_PROPERTIES = [
@@ -29,13 +29,16 @@ PROPFIND_PROPERTIES = [
29
29
  "oc:share-types",
30
30
  "oc:favorite",
31
31
  "nc:is-encrypted",
32
+ ]
33
+
34
+ PROPFIND_LOCKING_PROPERTIES = [
32
35
  "nc:lock",
33
36
  "nc:lock-owner-displayname",
34
37
  "nc:lock-owner",
35
38
  "nc:lock-owner-type",
36
- "nc:lock-owner-editor",
37
- "nc:lock-time",
38
- "nc:lock-timeout",
39
+ "nc:lock-owner-editor", # App id of an app owned lock
40
+ "nc:lock-time", # Timestamp of the log creation time
41
+ "nc:lock-timeout", # TTL of the lock in seconds staring from the creation time
39
42
  ]
40
43
 
41
44
  SEARCH_PROPERTIES_MAP = {
@@ -57,7 +60,14 @@ class PropFindType(enum.IntEnum):
57
60
  VERSIONS_FILE_ID = 3
58
61
 
59
62
 
60
- def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.Element:
63
+ def get_propfind_properties(capabilities: dict) -> list:
64
+ r = PROPFIND_PROPERTIES
65
+ if not check_capabilities("files.locking", capabilities):
66
+ r += PROPFIND_LOCKING_PROPERTIES
67
+ return r
68
+
69
+
70
+ def build_find_request(req: list, path: str | FsNode, user: str, capabilities: dict) -> ElementTree.Element:
61
71
  path = path.user_path if isinstance(path, FsNode) else path
62
72
  root = ElementTree.Element(
63
73
  "d:searchrequest",
@@ -65,7 +75,7 @@ def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.
65
75
  )
66
76
  xml_search = ElementTree.SubElement(root, "d:basicsearch")
67
77
  xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop")
68
- for i in PROPFIND_PROPERTIES:
78
+ for i in get_propfind_properties(capabilities):
69
79
  ElementTree.SubElement(xml_select_prop, i)
70
80
  xml_from_scope = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:from"), "d:scope")
71
81
  href = f"/files/{user}/{path.removeprefix('/')}"
@@ -76,7 +86,9 @@ def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.
76
86
  return root
77
87
 
78
88
 
79
- def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | SystemTag] | None) -> ElementTree.Element:
89
+ def build_list_by_criteria_req(
90
+ properties: list[str] | None, tags: list[int | SystemTag] | None, capabilities: dict
91
+ ) -> ElementTree.Element:
80
92
  if not properties and not tags:
81
93
  raise ValueError("Either specify 'properties' or 'tags' to filter results.")
82
94
  root = ElementTree.Element(
@@ -84,7 +96,7 @@ def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | Sy
84
96
  attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
85
97
  )
86
98
  prop = ElementTree.SubElement(root, "d:prop")
87
- for i in PROPFIND_PROPERTIES:
99
+ for i in get_propfind_properties(capabilities):
88
100
  ElementTree.SubElement(prop, i)
89
101
  xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
90
102
  if properties and "favorite" in properties:
@@ -156,6 +168,18 @@ def build_list_tags_response(response: Response) -> list[SystemTag]:
156
168
  return result
157
169
 
158
170
 
171
+ def build_tags_ids_for_object(url_to_fetch: str, response: Response) -> list[int]:
172
+ result = []
173
+ records = _webdav_response_to_records(response, "list_tags_ids")
174
+ for record in records:
175
+ prop_stat = record["d:propstat"]
176
+ if str(prop_stat.get("d:status", "")).find("200 OK") != -1:
177
+ href_suffix = str(record["d:href"]).removeprefix(url_to_fetch).strip("/")
178
+ if href_suffix:
179
+ result.append(int(href_suffix))
180
+ return result
181
+
182
+
159
183
  def build_update_tag_req(
160
184
  name: str | None, user_visible: bool | None, user_assignable: bool | None
161
185
  ) -> ElementTree.Element:
@@ -243,7 +267,7 @@ def etag_fileid_from_response(response: Response) -> dict:
243
267
  return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]}
244
268
 
245
269
 
246
- def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
270
+ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pylint: disable = too-many-branches
247
271
  fs_node_args = {}
248
272
  for prop_stat in prop_stats:
249
273
  if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
@@ -274,7 +298,17 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
274
298
  fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"]
275
299
  if "nc:trashbin-deletion-time" in prop_keys:
276
300
  fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"]
277
- # xz = prop.get("oc:dDC", "")
301
+ for k, v in {
302
+ "nc:lock": "is_locked",
303
+ "nc:lock-owner-type": "lock_owner_type",
304
+ "nc:lock-owner": "lock_owner",
305
+ "nc:lock-owner-displayname": "lock_owner_displayname",
306
+ "nc:lock-owner-editor": "lock_owner_editor",
307
+ "nc:lock-time": "lock_time",
308
+ "nc:lock-timeout": "lock_ttl",
309
+ }.items():
310
+ if k in prop_keys and prop[k] is not None:
311
+ fs_node_args[v] = prop[k]
278
312
  return FsNode(full_path, **fs_node_args)
279
313
 
280
314