nc-py-api 0.10.0__tar.gz → 0.12.0__tar.gz

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.
Files changed (53) hide show
  1. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/CHANGELOG.md +24 -0
  2. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/PKG-INFO +3 -3
  3. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/__init__.py +1 -1
  4. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_session.py +4 -7
  5. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_version.py +1 -1
  6. nc_py_api-0.12.0/nc_py_api/ex_app/__init__.py +24 -0
  7. nc_py_api-0.12.0/nc_py_api/ex_app/defs.py +37 -0
  8. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/integration_fastapi.py +8 -7
  9. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/misc.py +5 -0
  10. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/providers/translations.py +9 -0
  11. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/files_actions.py +0 -60
  12. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/files/__init__.py +119 -0
  13. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/files/_files.py +32 -10
  14. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/files/files.py +66 -9
  15. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/pyproject.toml +2 -2
  16. nc_py_api-0.10.0/nc_py_api/ex_app/__init__.py +0 -15
  17. nc_py_api-0.10.0/nc_py_api/ex_app/defs.py +0 -49
  18. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/.gitignore +0 -0
  19. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/AUTHORS +0 -0
  20. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/LICENSE.txt +0 -0
  21. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/README.md +0 -0
  22. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_deffered_error.py +0 -0
  23. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_exceptions.py +0 -0
  24. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_misc.py +0 -0
  25. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_preferences.py +0 -0
  26. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_preferences_ex.py +0 -0
  27. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_talk_api.py +0 -0
  28. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/_theming.py +0 -0
  29. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/activity.py +0 -0
  30. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/apps.py +0 -0
  31. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/calendar.py +0 -0
  32. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/persist_transformers_cache.py +0 -0
  33. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/providers/__init__.py +0 -0
  34. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/providers/providers.py +0 -0
  35. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/providers/speech_to_text.py +0 -0
  36. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/providers/text_processing.py +0 -0
  37. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/__init__.py +0 -0
  38. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/resources.py +0 -0
  39. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/settings.py +0 -0
  40. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/top_menu.py +0 -0
  41. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/ui/ui.py +0 -0
  42. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/ex_app/uvicorn_fastapi.py +0 -0
  43. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/files/sharing.py +0 -0
  44. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/nextcloud.py +0 -0
  45. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/notes.py +0 -0
  46. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/notifications.py +0 -0
  47. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/options.py +0 -0
  48. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/talk.py +0 -0
  49. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/talk_bot.py +0 -0
  50. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/user_status.py +0 -0
  51. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/users.py +0 -0
  52. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/users_groups.py +0 -0
  53. {nc_py_api-0.10.0 → nc_py_api-0.12.0}/nc_py_api/weather_status.py +0 -0
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.12.0 - 2024-04-02]
6
+
7
+ Update with new features only for `NextcloudApp` class. #233
8
+
9
+ ### Added
10
+
11
+ - `ex_app.get_computation_device` function for retrieving GPU type(only with AppAPI `2.4.0`+).
12
+ - `ex_app.integration_fastapi.fetch_models_task` are now public function, added `progress_init_start_value` param.
13
+ - Global authentication when used now sets `request.scope["username"]` for easy use.
14
+
15
+ ### Changed
16
+
17
+ - `UiActionFileInfo` class marked as deprecated, instead `ActionFileInfo` class should be used.
18
+
19
+ ## [0.11.0 - 2024-02-17]
20
+
21
+ ### Added
22
+
23
+ - Files: `lock` and `unlock` methods, lock file information to `FsNode`. #227
24
+
25
+ ### Fixed
26
+
27
+ - NextcloudApp: `MachineTranslation` provider registration - added optional `actionDetectLang` param. #229
28
+
5
29
  ## [0.10.0 - 2024-02-14]
6
30
 
7
31
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: nc-py-api
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: Nextcloud Python Framework
5
5
  Project-URL: Changelog, https://github.com/cloud-py-api/nc_py_api/blob/main/CHANGELOG.md
6
6
  Project-URL: Documentation, https://cloud-py-api.github.io/nc_py_api/
@@ -10,7 +10,7 @@ License-Expression: BSD-3-Clause
10
10
  License-File: AUTHORS
11
11
  License-File: LICENSE.txt
12
12
  Keywords: api,client,framework,library,nextcloud
13
- Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: BSD License
16
16
  Classifier: Operating System :: MacOS :: MacOS X
@@ -7,6 +7,6 @@ from ._exceptions import (
7
7
  NextcloudMissingCapabilities,
8
8
  )
9
9
  from ._version import __version__
10
- from .files import FilePermissions, FsNode
10
+ from .files import FilePermissions, FsNode, LockType
11
11
  from .files.sharing import ShareType
12
12
  from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp
@@ -123,7 +123,7 @@ class AppConfig(BasicConfig):
123
123
  super().__init__(**kwargs)
124
124
  self.aa_version = self._get_config_value("aa_version", raise_not_found=False, **kwargs)
125
125
  if not self.aa_version:
126
- self.aa_version = "1.0.0"
126
+ self.aa_version = "2.2.0"
127
127
  self.app_name = self._get_config_value("app_id", **kwargs)
128
128
  self.app_version = self._get_config_value("app_version", **kwargs)
129
129
  self.app_secret = self._get_config_value("app_secret", **kwargs)
@@ -459,7 +459,7 @@ class NcSessionAppBasic(ABC):
459
459
  self.cfg = AppConfig(**kwargs)
460
460
  super().__init__(**kwargs)
461
461
 
462
- def sign_check(self, request: HTTPConnection) -> None:
462
+ def sign_check(self, request: HTTPConnection) -> str:
463
463
  headers = {
464
464
  "AA-VERSION": request.headers.get("AA-VERSION", ""),
465
465
  "EX-APP-ID": request.headers.get("EX-APP-ID", ""),
@@ -474,13 +474,10 @@ class NcSessionAppBasic(ABC):
474
474
  if headers["EX-APP-ID"] != self.cfg.app_name:
475
475
  raise ValueError(f"Invalid EX-APP-ID:{headers['EX-APP-ID']} != {self.cfg.app_name}")
476
476
 
477
- our_version = self.adapter.headers.get("EX-APP-VERSION", "")
478
- if headers["EX-APP-VERSION"] != our_version:
479
- raise ValueError(f"Invalid EX-APP-VERSION:{headers['EX-APP-VERSION']} <=> {our_version}")
480
-
481
- app_secret = get_username_secret_from_headers(headers)[1]
477
+ username, app_secret = get_username_secret_from_headers(headers)
482
478
  if app_secret != self.cfg.app_secret:
483
479
  raise ValueError(f"Invalid App secret:{app_secret} != {self.cfg.app_secret}")
480
+ return username
484
481
 
485
482
 
486
483
  class NcSessionApp(NcSessionAppBasic, NcSessionBasic):
@@ -1,3 +1,3 @@
1
1
  """Version of nc_py_api."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.12.0"
@@ -0,0 +1,24 @@
1
+ """All possible ExApp stuff for NextcloudApp that can be used."""
2
+
3
+ from ..files import ActionFileInfo
4
+ from .defs import FileSystemEventNotification, LogLvl
5
+ from .integration_fastapi import (
6
+ AppAPIAuthMiddleware,
7
+ anc_app,
8
+ atalk_bot_msg,
9
+ nc_app,
10
+ set_handlers,
11
+ talk_bot_msg,
12
+ )
13
+ from .misc import (
14
+ get_computation_device,
15
+ get_model_path,
16
+ persistent_storage,
17
+ verify_version,
18
+ )
19
+ from .ui.settings import SettingsField, SettingsFieldType, SettingsForm
20
+ from .uvicorn_fastapi import run_app
21
+
22
+
23
+ class UiActionFileInfo(ActionFileInfo):
24
+ """``Deprecated``: use :py:class:`~nc_py_api.ex_app.ActionFileInfo` instead."""
@@ -0,0 +1,37 @@
1
+ """Additional definitions for NextcloudApp."""
2
+
3
+ import enum
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from ..files import ActionFileInfo
8
+
9
+
10
+ class LogLvl(enum.IntEnum):
11
+ """Log levels."""
12
+
13
+ DEBUG = 0
14
+ """Debug log level"""
15
+ INFO = 1
16
+ """Informational log level"""
17
+ WARNING = 2
18
+ """Warning log level. ``Default``"""
19
+ ERROR = 3
20
+ """Error log level"""
21
+ FATAL = 4
22
+ """Fatal log level"""
23
+
24
+
25
+ class FileSystemEventData(BaseModel):
26
+ """FileSystem events format."""
27
+
28
+ target: ActionFileInfo
29
+ source: ActionFileInfo | None = None
30
+
31
+
32
+ class FileSystemEventNotification(BaseModel):
33
+ """AppAPI event notification common data."""
34
+
35
+ event_type: str
36
+ event_subtype: str
37
+ event_data: FileSystemEventData
@@ -103,7 +103,7 @@ def set_handlers(
103
103
 
104
104
  @fast_api_app.post("/init")
105
105
  async def init_callback(b_tasks: BackgroundTasks, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]):
106
- b_tasks.add_task(__fetch_models_task, nc, models_to_fetch if models_to_fetch else {})
106
+ b_tasks.add_task(fetch_models_task, nc, models_to_fetch if models_to_fetch else {}, 0)
107
107
  return JSONResponse(content={})
108
108
 
109
109
  if map_app_static:
@@ -120,10 +120,11 @@ def __map_app_static_folders(fast_api_app: FastAPI):
120
120
  fast_api_app.mount(f"/{mnt_dir}", staticfiles.StaticFiles(directory=mnt_dir_path), name=mnt_dir)
121
121
 
122
122
 
123
- def __fetch_models_task(nc: NextcloudApp, models: dict[str, dict]) -> None:
123
+ def fetch_models_task(nc: NextcloudApp, models: dict[str, dict], progress_init_start_value: int) -> None:
124
+ """Use for cases when you want to define custom `/init` but still need to easy download models."""
124
125
  if models:
125
- current_progress = 0
126
- percent_for_each = min(int(100 / len(models)), 99)
126
+ current_progress = progress_init_start_value
127
+ percent_for_each = min(int((100 - progress_init_start_value) / len(models)), 99)
127
128
  for model in models:
128
129
  if model.startswith(("http://", "https://")):
129
130
  __fetch_model_as_file(current_progress, percent_for_each, nc, model, models[model])
@@ -206,9 +207,9 @@ def __request_sign_check_if_needed(request: HTTPConnection, nextcloud_app: Nextc
206
207
  _request_sign_check(request, nextcloud_app)
207
208
 
208
209
 
209
- def _request_sign_check(request: HTTPConnection, nextcloud_app: NextcloudApp | AsyncNextcloudApp) -> None:
210
+ def _request_sign_check(request: HTTPConnection, nextcloud_app: NextcloudApp | AsyncNextcloudApp) -> str:
210
211
  try:
211
- nextcloud_app._session.sign_check(request) # noqa pylint: disable=protected-access
212
+ return nextcloud_app._session.sign_check(request) # noqa pylint: disable=protected-access
212
213
  except ValueError as e:
213
214
  print(e)
214
215
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from None
@@ -238,7 +239,7 @@ class AppAPIAuthMiddleware:
238
239
  url_path = conn.url.path.lstrip("/")
239
240
  if not fnmatch.filter(self._disable_for, url_path):
240
241
  try:
241
- _request_sign_check(conn, AsyncNextcloudApp())
242
+ scope["username"] = _request_sign_check(conn, AsyncNextcloudApp())
242
243
  except HTTPException as exc:
243
244
  response = self._on_error(exc.status_code, exc.detail)
244
245
  await response(scope, receive, send)
@@ -50,3 +50,8 @@ def get_model_path(model_name: str) -> str:
50
50
  from huggingface_hub import snapshot_download # noqa isort:skip pylint: disable=C0415 disable=E0401
51
51
 
52
52
  return snapshot_download(model_name, local_files_only=True, cache_dir=persistent_storage())
53
+
54
+
55
+ def get_computation_device() -> str:
56
+ """Returns computation device(`ROCM` or `CUDA`) if it is defined in the environment variable."""
57
+ return os.environ.get("COMPUTE_DEVICE", "")
@@ -42,6 +42,11 @@ class TranslationsProvider:
42
42
  """Relative ExApp url which will be called by Nextcloud."""
43
43
  return self._raw_data["action_handler"]
44
44
 
45
+ @property
46
+ def action_handler_detect_lang(self) -> str:
47
+ """Relative ExApp url which will be called by Nextcloud to detect language."""
48
+ return self._raw_data.get("action_detect_lang", "")
49
+
45
50
  def __repr__(self):
46
51
  return f"<{self.__class__.__name__} name={self.name}, handler={self.action_handler}>"
47
52
 
@@ -59,6 +64,7 @@ class _TranslationsProviderAPI:
59
64
  callback_url: str,
60
65
  from_languages: dict[str, str],
61
66
  to_languages: dict[str, str],
67
+ detect_lang_callback_url: str = "",
62
68
  ) -> None:
63
69
  """Registers or edit the Translations provider."""
64
70
  require_capabilities("app_api", self._session.capabilities)
@@ -68,6 +74,7 @@ class _TranslationsProviderAPI:
68
74
  "fromLanguages": from_languages,
69
75
  "toLanguages": to_languages,
70
76
  "actionHandler": callback_url,
77
+ "actionDetectLang": detect_lang_callback_url,
71
78
  }
72
79
  self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
73
80
 
@@ -114,6 +121,7 @@ class _AsyncTranslationsProviderAPI:
114
121
  callback_url: str,
115
122
  from_languages: dict[str, str],
116
123
  to_languages: dict[str, str],
124
+ detect_lang_callback_url: str = "",
117
125
  ) -> None:
118
126
  """Registers or edit the Translations provider."""
119
127
  require_capabilities("app_api", await self._session.capabilities)
@@ -123,6 +131,7 @@ class _AsyncTranslationsProviderAPI:
123
131
  "fromLanguages": from_languages,
124
132
  "toLanguages": to_languages,
125
133
  "actionHandler": callback_url,
134
+ "actionDetectLang": detect_lang_callback_url,
126
135
  }
127
136
  await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
128
137
 
@@ -1,15 +1,10 @@
1
1
  """Nextcloud API for working with drop-down file's menu."""
2
2
 
3
3
  import dataclasses
4
- import datetime
5
- import os
6
-
7
- from pydantic import BaseModel
8
4
 
9
5
  from ..._exceptions import NextcloudExceptionNotFound
10
6
  from ..._misc import require_capabilities
11
7
  from ..._session import AsyncNcSessionApp, NcSessionApp
12
- from ...files import FsNode, permissions_to_str
13
8
 
14
9
 
15
10
  @dataclasses.dataclass
@@ -63,61 +58,6 @@ class UiFileActionEntry:
63
58
  return f"<{self.__class__.__name__} name={self.name}, mime={self.mime}, handler={self.action_handler}>"
64
59
 
65
60
 
66
- class UiActionFileInfo(BaseModel):
67
- """File Information Nextcloud sends to the External Application."""
68
-
69
- fileId: int
70
- """FileID without Nextcloud instance ID"""
71
- name: str
72
- """Name of the file/directory"""
73
- directory: str
74
- """Directory relative to the user's home directory"""
75
- etag: str
76
- mime: str
77
- fileType: str
78
- """**file** or **dir**"""
79
- size: int
80
- """size of file/directory"""
81
- favorite: str
82
- """**true** or **false**"""
83
- permissions: int
84
- """Combination of :py:class:`~nc_py_api.files.FilePermissions` values"""
85
- mtime: int
86
- """Last modified time"""
87
- userId: str
88
- """The ID of the user performing the action."""
89
- shareOwner: str | None
90
- """If the object is shared, this is a display name of the share owner."""
91
- shareOwnerId: str | None
92
- """If the object is shared, this is the owner ID of the share."""
93
- instanceId: str | None
94
- """Nextcloud instance ID."""
95
-
96
- def to_fs_node(self) -> FsNode:
97
- """Returns usual :py:class:`~nc_py_api.files.FsNode` created from this class."""
98
- user_path = os.path.join(self.directory, self.name).rstrip("/")
99
- is_dir = bool(self.fileType.lower() == "dir")
100
- if is_dir:
101
- user_path += "/"
102
- full_path = os.path.join(f"files/{self.userId}", user_path.lstrip("/"))
103
- file_id = str(self.fileId).rjust(8, "0")
104
-
105
- permissions = "S" if self.shareOwnerId else ""
106
- permissions += permissions_to_str(self.permissions, is_dir)
107
- return FsNode(
108
- full_path,
109
- etag=self.etag,
110
- size=self.size,
111
- content_length=0 if is_dir else self.size,
112
- permissions=permissions,
113
- favorite=bool(self.favorite.lower() == "true"),
114
- file_id=file_id + self.instanceId if self.instanceId else file_id,
115
- fileid=self.fileId,
116
- last_modified=datetime.datetime.utcfromtimestamp(self.mtime).replace(tzinfo=datetime.timezone.utc),
117
- mimetype=self.mime,
118
- )
119
-
120
-
121
61
  class _UiFilesActionsAPI:
122
62
  """API for the drop-down menu in Nextcloud **Files app**, avalaible as **nc.ui.files_dropdown_menu.<method>**."""
123
63
 
@@ -4,11 +4,71 @@ import dataclasses
4
4
  import datetime
5
5
  import email.utils
6
6
  import enum
7
+ import os
7
8
  import warnings
8
9
 
10
+ from pydantic import BaseModel
11
+
9
12
  from .. import _misc
10
13
 
11
14
 
15
+ class LockType(enum.IntEnum):
16
+ """Nextcloud File Locks types."""
17
+
18
+ MANUAL_LOCK = 0
19
+ COLLABORATIVE_LOCK = 1
20
+ WEBDAV_TOKEN = 2
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class FsNodeLockInfo:
25
+ """File Lock information if Nextcloud `files_lock` is enabled."""
26
+
27
+ def __init__(self, **kwargs):
28
+ self._is_locked = bool(int(kwargs.get("is_locked", False)))
29
+ self._lock_owner_type = LockType(int(kwargs.get("lock_owner_type", 0)))
30
+ self._lock_owner = kwargs.get("lock_owner", "")
31
+ self._owner_display_name = kwargs.get("owner_display_name", "")
32
+ self._owner_editor = kwargs.get("lock_owner_editor", "")
33
+ self._lock_time = int(kwargs.get("lock_time", 0))
34
+ self._lock_ttl = int(kwargs.get("_lock_ttl", 0))
35
+
36
+ @property
37
+ def is_locked(self) -> bool:
38
+ """Returns ``True`` if the file is locked, ``False`` otherwise."""
39
+ return self._is_locked
40
+
41
+ @property
42
+ def type(self) -> LockType:
43
+ """Type of the lock."""
44
+ return LockType(self._lock_owner_type)
45
+
46
+ @property
47
+ def owner(self) -> str:
48
+ """User id of the lock owner."""
49
+ return self._lock_owner
50
+
51
+ @property
52
+ def owner_display_name(self) -> str:
53
+ """Display name of the lock owner."""
54
+ return self._owner_display_name
55
+
56
+ @property
57
+ def owner_editor(self) -> str:
58
+ """App id of an app owned lock to allow clients to suggest joining the collaborative editing session."""
59
+ return self._owner_editor
60
+
61
+ @property
62
+ def lock_creation_time(self) -> datetime.datetime:
63
+ """Lock creation time."""
64
+ return datetime.datetime.utcfromtimestamp(self._lock_time).replace(tzinfo=datetime.timezone.utc)
65
+
66
+ @property
67
+ def lock_ttl(self) -> int:
68
+ """TTL of the lock in seconds staring from the creation time. A value of 0 means the timeout is infinite."""
69
+ return self._lock_ttl
70
+
71
+
12
72
  @dataclasses.dataclass
13
73
  class FsNodeInfo:
14
74
  """Extra FS object attributes from Nextcloud."""
@@ -116,11 +176,15 @@ class FsNode:
116
176
  info: FsNodeInfo
117
177
  """Additional extra information for the object"""
118
178
 
179
+ lock_info: FsNodeLockInfo
180
+ """Class describing `lock` information if any."""
181
+
119
182
  def __init__(self, full_path: str, **kwargs):
120
183
  self.full_path = full_path
121
184
  self.file_id = kwargs.get("file_id", "")
122
185
  self.etag = kwargs.get("etag", "")
123
186
  self.info = FsNodeInfo(**kwargs)
187
+ self.lock_info = FsNodeLockInfo(**kwargs)
124
188
 
125
189
  @property
126
190
  def is_dir(self) -> bool:
@@ -400,3 +464,58 @@ class Share:
400
464
  f"{self.share_type.name}: `{self.path}` with id={self.share_id}"
401
465
  f" from {self.share_owner} to {self.share_with}"
402
466
  )
467
+
468
+
469
+ class ActionFileInfo(BaseModel):
470
+ """Information Nextcloud sends to the External Application about File Nodes affected in action."""
471
+
472
+ fileId: int
473
+ """FileID without Nextcloud instance ID"""
474
+ name: str
475
+ """Name of the file/directory"""
476
+ directory: str
477
+ """Directory relative to the user's home directory"""
478
+ etag: str
479
+ mime: str
480
+ fileType: str
481
+ """**file** or **dir**"""
482
+ size: int
483
+ """size of file/directory"""
484
+ favorite: str
485
+ """**true** or **false**"""
486
+ permissions: int
487
+ """Combination of :py:class:`~nc_py_api.files.FilePermissions` values"""
488
+ mtime: int
489
+ """Last modified time"""
490
+ userId: str
491
+ """The ID of the user performing the action."""
492
+ shareOwner: str | None = None
493
+ """If the object is shared, this is a display name of the share owner."""
494
+ shareOwnerId: str | None = None
495
+ """If the object is shared, this is the owner ID of the share."""
496
+ instanceId: str | None = None
497
+ """Nextcloud instance ID."""
498
+
499
+ def to_fs_node(self) -> FsNode:
500
+ """Returns usual :py:class:`~nc_py_api.files.FsNode` created from this class."""
501
+ user_path = os.path.join(self.directory, self.name).rstrip("/")
502
+ is_dir = bool(self.fileType.lower() == "dir")
503
+ if is_dir:
504
+ user_path += "/"
505
+ full_path = os.path.join(f"files/{self.userId}", user_path.lstrip("/"))
506
+ file_id = str(self.fileId).rjust(8, "0")
507
+
508
+ permissions = "S" if self.shareOwnerId else ""
509
+ permissions += permissions_to_str(self.permissions, is_dir)
510
+ return FsNode(
511
+ full_path,
512
+ etag=self.etag,
513
+ size=self.size,
514
+ content_length=0 if is_dir else self.size,
515
+ permissions=permissions,
516
+ favorite=bool(self.favorite.lower() == "true"),
517
+ file_id=file_id + self.instanceId if self.instanceId else file_id,
518
+ fileid=self.fileId,
519
+ last_modified=datetime.datetime.utcfromtimestamp(self.mtime).replace(tzinfo=datetime.timezone.utc),
520
+ mimetype=self.mime,
521
+ )
@@ -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:
@@ -243,7 +255,7 @@ def etag_fileid_from_response(response: Response) -> dict:
243
255
  return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]}
244
256
 
245
257
 
246
- def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
258
+ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pylint: disable = too-many-branches
247
259
  fs_node_args = {}
248
260
  for prop_stat in prop_stats:
249
261
  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:
274
286
  fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"]
275
287
  if "nc:trashbin-deletion-time" in prop_keys:
276
288
  fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"]
277
- # xz = prop.get("oc:dDC", "")
289
+ for k, v in {
290
+ "nc:lock": "is_locked",
291
+ "nc:lock-owner-type": "lock_owner_type",
292
+ "nc:lock-owner": "lock_owner",
293
+ "nc:lock-owner-displayname": "lock_owner_displayname",
294
+ "nc:lock-owner-editor": "lock_owner_editor",
295
+ "nc:lock-time": "lock_time",
296
+ "nc:lock-timeout": "lock_ttl",
297
+ }.items():
298
+ if k in prop_keys and prop[k] is not None:
299
+ fs_node_args[v] = prop[k]
278
300
  return FsNode(full_path, **fs_node_args)
279
301
 
280
302
 
@@ -10,7 +10,7 @@ from httpx import Headers
10
10
  from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error
11
11
  from .._misc import random_string, require_capabilities
12
12
  from .._session import AsyncNcSessionBasic, NcSessionBasic
13
- from . import FsNode, SystemTag
13
+ from . import FsNode, LockType, SystemTag
14
14
  from ._files import (
15
15
  PROPFIND_PROPERTIES,
16
16
  PropFindType,
@@ -25,6 +25,7 @@ from ._files import (
25
25
  dav_get_obj_path,
26
26
  element_tree_as_str,
27
27
  etag_fileid_from_response,
28
+ get_propfind_properties,
28
29
  lf_parse_webdav_response,
29
30
  )
30
31
  from .sharing import _AsyncFilesSharingAPI, _FilesSharingAPI
@@ -50,7 +51,7 @@ class FilesAPI:
50
51
  """
51
52
  if exclude_self and not depth:
52
53
  raise ValueError("Wrong input parameters, query will return nothing.")
53
- properties = PROPFIND_PROPERTIES
54
+ properties = get_propfind_properties(self._session.capabilities)
54
55
  path = path.user_path if isinstance(path, FsNode) else path
55
56
  return self._listdir(self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self)
56
57
 
@@ -76,7 +77,7 @@ class FilesAPI:
76
77
  :param path: path where to search from. Default = **""**.
77
78
  """
78
79
  # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
79
- root = build_find_request(req, path, self._session.user)
80
+ root = build_find_request(req, path, self._session.user, self._session.capabilities)
80
81
  webdav_response = self._session.adapter_dav.request(
81
82
  "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
82
83
  )
@@ -248,7 +249,7 @@ class FilesAPI:
248
249
  Supported values: **favorite**
249
250
  :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
250
251
  """
251
- root = build_list_by_criteria_req(properties, tags)
252
+ root = build_list_by_criteria_req(properties, tags, self._session.capabilities)
252
253
  webdav_response = self._session.adapter_dav.request(
253
254
  "REPORT", dav_get_obj_path(self._session.user), content=element_tree_as_str(root)
254
255
  )
@@ -332,7 +333,7 @@ class FilesAPI:
332
333
  headers = Headers({"Destination": dest}, encoding="utf-8")
333
334
  response = self._session.adapter_dav.request(
334
335
  "MOVE",
335
- f"/versions/{self._session.user}/{file_object.user_path}",
336
+ quote(f"/versions/{self._session.user}/{file_object.user_path}"),
336
337
  headers=headers,
337
338
  )
338
339
  check_error(response, f"restore_version: user={self._session.user}, src={file_object.user_path}")
@@ -396,6 +397,34 @@ class FilesAPI:
396
397
  """Removes Tag from a file/directory."""
397
398
  self._file_change_tag_state(file_id, tag_id, False)
398
399
 
400
+ def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None:
401
+ """Locks the file.
402
+
403
+ .. note:: Exception codes: 423 - existing lock present.
404
+ """
405
+ require_capabilities("files.locking", self._session.capabilities)
406
+ full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path)
407
+ response = self._session.adapter_dav.request(
408
+ "LOCK",
409
+ quote(full_path),
410
+ headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)},
411
+ )
412
+ check_error(response, f"lock: user={self._session.user}, path={full_path}")
413
+
414
+ def unlock(self, path: FsNode | str) -> None:
415
+ """Unlocks the file.
416
+
417
+ .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user.
418
+ """
419
+ require_capabilities("files.locking", self._session.capabilities)
420
+ full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path)
421
+ response = self._session.adapter_dav.request(
422
+ "UNLOCK",
423
+ quote(full_path),
424
+ headers={"X-User-Lock": "1"},
425
+ )
426
+ check_error(response, f"unlock: user={self._session.user}, path={full_path}")
427
+
399
428
  def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None:
400
429
  fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
401
430
  tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
@@ -493,7 +522,7 @@ class AsyncFilesAPI:
493
522
  """
494
523
  if exclude_self and not depth:
495
524
  raise ValueError("Wrong input parameters, query will return nothing.")
496
- properties = PROPFIND_PROPERTIES
525
+ properties = get_propfind_properties(await self._session.capabilities)
497
526
  path = path.user_path if isinstance(path, FsNode) else path
498
527
  return await self._listdir(
499
528
  await self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self
@@ -521,7 +550,7 @@ class AsyncFilesAPI:
521
550
  :param path: path where to search from. Default = **""**.
522
551
  """
523
552
  # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
524
- root = build_find_request(req, path, await self._session.user)
553
+ root = build_find_request(req, path, await self._session.user, await self._session.capabilities)
525
554
  webdav_response = await self._session.adapter_dav.request(
526
555
  "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
527
556
  )
@@ -695,7 +724,7 @@ class AsyncFilesAPI:
695
724
  Supported values: **favorite**
696
725
  :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
697
726
  """
698
- root = build_list_by_criteria_req(properties, tags)
727
+ root = build_list_by_criteria_req(properties, tags, await self._session.capabilities)
699
728
  webdav_response = await self._session.adapter_dav.request(
700
729
  "REPORT", dav_get_obj_path(await self._session.user), content=element_tree_as_str(root)
701
730
  )
@@ -784,7 +813,7 @@ class AsyncFilesAPI:
784
813
  headers = Headers({"Destination": dest}, encoding="utf-8")
785
814
  response = await self._session.adapter_dav.request(
786
815
  "MOVE",
787
- f"/versions/{await self._session.user}/{file_object.user_path}",
816
+ quote(f"/versions/{await self._session.user}/{file_object.user_path}"),
788
817
  headers=headers,
789
818
  )
790
819
  check_error(response, f"restore_version: user={await self._session.user}, src={file_object.user_path}")
@@ -848,6 +877,34 @@ class AsyncFilesAPI:
848
877
  """Removes Tag from a file/directory."""
849
878
  await self._file_change_tag_state(file_id, tag_id, False)
850
879
 
880
+ async def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None:
881
+ """Locks the file.
882
+
883
+ .. note:: Exception codes: 423 - existing lock present.
884
+ """
885
+ require_capabilities("files.locking", await self._session.capabilities)
886
+ full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path)
887
+ response = await self._session.adapter_dav.request(
888
+ "LOCK",
889
+ quote(full_path),
890
+ headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)},
891
+ )
892
+ check_error(response, f"lock: user={self._session.user}, path={full_path}")
893
+
894
+ async def unlock(self, path: FsNode | str) -> None:
895
+ """Unlocks the file.
896
+
897
+ .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user.
898
+ """
899
+ require_capabilities("files.locking", await self._session.capabilities)
900
+ full_path = dav_get_obj_path(await self._session.user, path.user_path if isinstance(path, FsNode) else path)
901
+ response = await self._session.adapter_dav.request(
902
+ "UNLOCK",
903
+ quote(full_path),
904
+ headers={"X-User-Lock": "1"},
905
+ )
906
+ check_error(response, f"unlock: user={self._session.user}, path={full_path}")
907
+
851
908
  async def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None:
852
909
  fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
853
910
  tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
@@ -21,7 +21,7 @@ authors = [
21
21
  ]
22
22
  requires-python = ">=3.10"
23
23
  classifiers = [
24
- "Development Status :: 3 - Alpha",
24
+ "Development Status :: 4 - Beta",
25
25
  "Intended Audience :: Developers",
26
26
  "License :: OSI Approved :: BSD License",
27
27
  "Operating System :: MacOS :: MacOS X",
@@ -118,7 +118,7 @@ lint.extend-ignore = ["D107", "D105", "D203", "D213", "D401", "I001", "RUF100"]
118
118
  "nc_py_api/ex_app/__init__.py" = ["F401"]
119
119
 
120
120
  [tool.ruff.lint.extend-per-file-ignores]
121
- "benchmarks/**/*.py" = ["D", "SIM"]
121
+ "benchmarks/**/*.py" = ["D", "SIM", "S311"]
122
122
  "docs/**/*.py" = ["D"]
123
123
  "examples/**/*.py" = ["D", "S106", "S311"]
124
124
  "tests/**/*.py" = ["D", "E402", "S", "UP"]
@@ -1,15 +0,0 @@
1
- """All possible ExApp stuff for NextcloudApp that can be used."""
2
-
3
- from .defs import ApiScope, LogLvl
4
- from .integration_fastapi import (
5
- AppAPIAuthMiddleware,
6
- anc_app,
7
- atalk_bot_msg,
8
- nc_app,
9
- set_handlers,
10
- talk_bot_msg,
11
- )
12
- from .misc import get_model_path, persistent_storage, verify_version
13
- from .ui.files_actions import UiActionFileInfo
14
- from .ui.settings import SettingsField, SettingsFieldType, SettingsForm
15
- from .uvicorn_fastapi import run_app
@@ -1,49 +0,0 @@
1
- """Additional definitions for NextcloudApp."""
2
-
3
- import enum
4
-
5
-
6
- class LogLvl(enum.IntEnum):
7
- """Log levels."""
8
-
9
- DEBUG = 0
10
- """Debug log level"""
11
- INFO = 1
12
- """Informational log level"""
13
- WARNING = 2
14
- """Warning log level. ``Default``"""
15
- ERROR = 3
16
- """Error log level"""
17
- FATAL = 4
18
- """Fatal log level"""
19
-
20
-
21
- class ApiScope(enum.IntEnum):
22
- """Defined API scopes."""
23
-
24
- SYSTEM = 2
25
- """Allows access to the System APIs."""
26
- FILES = 10
27
- """Allows access to the Nextcloud file base."""
28
- FILES_SHARING = 11
29
- """Allows access to APIs that provide File Sharing."""
30
- USER_INFO = 30
31
- """Allows access to APIs that work with users."""
32
- USER_STATUS = 31
33
- """Allows access to APIs that work with users statuses."""
34
- NOTIFICATIONS = 32
35
- """Allows access to APIs that provide Notifications."""
36
- WEATHER_STATUS = 33
37
- """Allows access to APIs that provide Weather status."""
38
- TALK = 50
39
- """Allows access to Talk API endpoints."""
40
- TALK_BOT = 60
41
- """Allows to register Talk Bots."""
42
- AI_PROVIDERS = 61
43
- """Allows to register AI providers."""
44
- ACTIVITIES = 110
45
- """Activity App endpoints."""
46
- NOTES = 120
47
- """Notes App endpoints."""
48
- ALL = 9999
49
- """All endpoints allowed."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes