diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml
index 4346eaae..fcdf64a8 100644
--- a/.github/workflows/analysis-coverage.yml
+++ b/.github/workflows/analysis-coverage.yml
@@ -598,15 +598,32 @@ jobs:
- name: Enable Talk
run: php occ app:enable spreed
- - name: Generate coverage report
+ - name: Generate coverage report (1)
working-directory: nc_py_api
run: |
- coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py &
+ coverage run --data-file=.coverage.talk_bot tests/_talk_bot_async.py &
echo $! > /tmp/_talk_bot.pid
coverage run --data-file=.coverage.ci -m pytest
kill -15 $(cat /tmp/_talk_bot.pid)
timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null
coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py
+
+ - name: Uninstall NcPyApi
+ run: |
+ php occ app_api:app:unregister "$APP_ID" --silent
+ php occ app_api:daemon:unregister manual_install
+
+ - name: Generate coverage report (2)
+ working-directory: nc_py_api
+ run: |
+ coverage run --data-file=.coverage.ci_install_models tests/_install_init_handler_models.py &
+ echo $! > /tmp/_install_models.pid
+ python3 tests/_install_wait.py http://127.0.0.1:$APP_PORT/heartbeat "\"status\":\"ok\"" 15 0.5
+ cd ..
+ sh nc_py_api/scripts/ci_register.sh "$APP_ID" "$APP_VERSION" "$APP_SECRET" "localhost" "$APP_PORT"
+ kill -15 $(cat /tmp/_install_models.pid)
+ timeout 3m tail --pid=$(cat /tmp/_install_models.pid) -f /dev/null
+ cd nc_py_api
coverage combine && coverage xml && coverage html
- name: HTML coverage to artifacts
@@ -757,21 +774,38 @@ jobs:
- name: Enable Talk
run: php occ app:enable spreed
- - name: Generate coverage report
+ - name: Generate coverage report (1)
working-directory: nc_py_api
run: |
- coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py &
+ coverage run --data-file=.coverage.talk_bot tests/_talk_bot_async.py &
echo $! > /tmp/_talk_bot.pid
coverage run --data-file=.coverage.ci -m pytest
kill -15 $(cat /tmp/_talk_bot.pid)
timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null
coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py
- coverage combine && coverage xml && coverage html
env:
NPA_TIMEOUT: None
NPA_TIMEOUT_DAV: None
NPA_NC_CERT: False
+ - name: Uninstall NcPyApi
+ run: |
+ php occ app_api:app:unregister "$APP_ID" --silent
+ php occ app_api:daemon:unregister manual_install
+
+ - name: Generate coverage report (2)
+ working-directory: nc_py_api
+ run: |
+ coverage run --data-file=.coverage.ci_install_models tests/_install_init_handler_models.py &
+ echo $! > /tmp/_install_models.pid
+ python3 tests/_install_wait.py http://127.0.0.1:$APP_PORT/heartbeat "\"status\":\"ok\"" 15 0.5
+ cd ..
+ sh nc_py_api/scripts/ci_register.sh "$APP_ID" "$APP_VERSION" "$APP_SECRET" "localhost" "$APP_PORT"
+ kill -15 $(cat /tmp/_install_models.pid)
+ timeout 3m tail --pid=$(cat /tmp/_install_models.pid) -f /dev/null
+ cd nc_py_api
+ coverage combine && coverage xml && coverage html
+
- name: HTML coverage to artifacts
uses: actions/upload-artifact@v3
with:
@@ -865,7 +899,7 @@ jobs:
- name: Install NcPyApi
working-directory: nc_py_api
- run: python3 -m pip -v install . pytest coverage pillow
+ run: python3 -m pip -v install . pytest pytest-asyncio coverage pillow
- name: Talk Branch Main
if: ${{ startsWith(matrix.nextcloud, 'master') }}
diff --git a/.run/aregister_nc_py_api (27).run.xml b/.run/aregister_nc_py_api (27).run.xml
new file mode 100644
index 00000000..5cf9f12a
--- /dev/null
+++ b/.run/aregister_nc_py_api (27).run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/aregister_nc_py_api (28).run.xml b/.run/aregister_nc_py_api (28).run.xml
new file mode 100644
index 00000000..72c3f834
--- /dev/null
+++ b/.run/aregister_nc_py_api (28).run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/aregister_nc_py_api (last).run.xml b/.run/aregister_nc_py_api (last).run.xml
new file mode 100644
index 00000000..6ec5faff
--- /dev/null
+++ b/.run/aregister_nc_py_api (last).run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39479aa7..36b4b857 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,11 @@ All notable changes to this project will be documented in this file.
### Added
-- set_handlers: `enabled_handler`, `heartbeat_handler` now can be async(Coroutines). #175
+- implemented `AsyncNextcloud` and `AsyncNextcloudApp` classes. #181
### Changed
+- set_handlers: `enabled_handler`, `heartbeat_handler`, `init_handler` now can be async(Coroutines). #175 #181
- drop Python 3.9 support. #180
- internal code refactoring and clean-up #177
diff --git a/README.md b/README.md
index f0abc8f5..e6dfa1b0 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Python library that provides a robust and well-documented API that allows develo
* **Reliable**: Minimum number of incompatible changes.
* **Robust**: All code is covered with tests as much as possible.
* **Easy**: Designed to be easy to use with excellent documentation.
+ * **Sync+Async**: Provides both sync and async APIs.
### Capabilities
| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 |
diff --git a/nc_py_api/__init__.py b/nc_py_api/__init__.py
index e8358690..51ea41ce 100644
--- a/nc_py_api/__init__.py
+++ b/nc_py_api/__init__.py
@@ -9,4 +9,4 @@
from ._version import __version__
from .files import FilePermissions, FsNode
from .files.sharing import ShareType
-from .nextcloud import Nextcloud, NextcloudApp
+from .nextcloud import AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp
diff --git a/nc_py_api/_preferences.py b/nc_py_api/_preferences.py
index 2c2ff0d7..653606f7 100644
--- a/nc_py_api/_preferences.py
+++ b/nc_py_api/_preferences.py
@@ -1,7 +1,7 @@
"""Nextcloud API for working with classics app's storage with user's context (table oc_preferences)."""
from ._misc import check_capabilities, require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
class PreferencesAPI:
@@ -26,3 +26,27 @@ def delete(self, app_name: str, key: str) -> None:
"""Removes a key and its value for a specific application."""
require_capabilities("provisioning_api", self._session.capabilities)
self._session.ocs("DELETE", f"{self._ep_base}/{app_name}/{key}")
+
+
+class AsyncPreferencesAPI:
+ """Async API for setting/removing configuration values of applications that support it."""
+
+ _ep_base: str = "/ocs/v1.php/apps/provisioning_api/api/v1/config/users"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("provisioning_api", await self._session.capabilities)
+
+ async def set_value(self, app_name: str, key: str, value: str) -> None:
+ """Sets the value for the key for the specific application."""
+ require_capabilities("provisioning_api", await self._session.capabilities)
+ await self._session.ocs("POST", f"{self._ep_base}/{app_name}/{key}", params={"configValue": value})
+
+ async def delete(self, app_name: str, key: str) -> None:
+ """Removes a key and its value for a specific application."""
+ require_capabilities("provisioning_api", await self._session.capabilities)
+ await self._session.ocs("DELETE", f"{self._ep_base}/{app_name}/{key}")
diff --git a/nc_py_api/_preferences_ex.py b/nc_py_api/_preferences_ex.py
index f9682369..6f2d8440 100644
--- a/nc_py_api/_preferences_ex.py
+++ b/nc_py_api/_preferences_ex.py
@@ -4,7 +4,7 @@
from ._exceptions import NextcloudExceptionNotFound
from ._misc import require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -62,6 +62,49 @@ def delete(self, keys: str | list[str], not_fail=True) -> None:
raise e from None
+class _AsyncBasicAppCfgPref:
+ _url_suffix: str
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ async def get_value(self, key: str, default=None) -> str | None:
+ """Returns the value of the key, if found, or the specified default value."""
+ if not key:
+ raise ValueError("`key` parameter can not be empty")
+ require_capabilities("app_api", await self._session.capabilities)
+ r = await self.get_values([key])
+ if r:
+ return r[0].value
+ return default
+
+ async def get_values(self, keys: list[str]) -> list[CfgRecord]:
+ """Returns the :py:class:`CfgRecord` for each founded key."""
+ if not keys:
+ return []
+ if not all(keys):
+ raise ValueError("`key` parameter can not be empty")
+ require_capabilities("app_api", await self._session.capabilities)
+ data = {"configKeys": keys}
+ results = await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}/get-values", json=data)
+ return [CfgRecord(i) for i in results]
+
+ async def delete(self, keys: str | list[str], not_fail=True) -> None:
+ """Deletes config/preference entries by the provided keys."""
+ if isinstance(keys, str):
+ keys = [keys]
+ if not keys:
+ return
+ if not all(keys):
+ raise ValueError("`key` parameter can not be empty")
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._url_suffix}", json={"configKeys": keys})
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+
class PreferencesExAPI(_BasicAppCfgPref):
"""User specific preferences API."""
@@ -76,6 +119,20 @@ def set_value(self, key: str, value: str) -> None:
self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
+class AsyncPreferencesExAPI(_AsyncBasicAppCfgPref):
+ """User specific preferences API."""
+
+ _url_suffix = "ex-app/preference"
+
+ async def set_value(self, key: str, value: str) -> None:
+ """Sets a value for a key."""
+ if not key:
+ raise ValueError("`key` parameter can not be empty")
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {"configKey": key, "configValue": value}
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
+
+
class AppConfigExAPI(_BasicAppCfgPref):
"""Non-user(App) specific preferences API."""
@@ -95,3 +152,24 @@ def set_value(self, key: str, value: str, sensitive: bool | None = None) -> None
if sensitive is not None:
params["sensitive"] = sensitive
self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
+
+
+class AsyncAppConfigExAPI(_AsyncBasicAppCfgPref):
+ """Non-user(App) specific preferences API."""
+
+ _url_suffix = "ex-app/config"
+
+ async def set_value(self, key: str, value: str, sensitive: bool | None = None) -> None:
+ """Sets a value and if specified the sensitive flag for a key.
+
+ .. note:: A sensitive flag ensures key values are truncated in Nextcloud logs.
+ Default for new records is ``False`` when sensitive is *unspecified*, if changes existing record and
+ sensitive is *unspecified* it will not change the existing `sensitive` flag.
+ """
+ if not key:
+ raise ValueError("`key` parameter can not be empty")
+ require_capabilities("app_api", await self._session.capabilities)
+ params: dict = {"configKey": key, "configValue": value}
+ if sensitive is not None:
+ params["sensitive"] = sensitive
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
diff --git a/nc_py_api/_session.py b/nc_py_api/_session.py
index 3ef78490..6cd3e3f9 100644
--- a/nc_py_api/_session.py
+++ b/nc_py_api/_session.py
@@ -10,7 +10,7 @@
from os import environ
from fastapi import Request as FastAPIRequest
-from httpx import Client, Headers, Limits, ReadTimeout, Request, Response
+from httpx import AsyncClient, Client, Headers, Limits, ReadTimeout, Request, Response
from . import options
from ._exceptions import (
@@ -127,9 +127,9 @@ def __init__(self, **kwargs):
self.app_secret = self._get_config_value("app_secret", **kwargs)
-class NcSessionBasic(ABC):
- adapter: Client
- adapter_dav: Client
+class NcSessionBase(ABC):
+ adapter: AsyncClient | Client
+ adapter_dav: AsyncClient | Client
cfg: BasicConfig
custom_headers: dict
response_headers: Headers
@@ -145,13 +145,39 @@ def __init__(self, **kwargs):
self.init_adapter()
self.init_adapter_dav()
self.response_headers = Headers()
- self.__ocs_regexp = re.compile(r"/ocs/v[12]\.php/")
+ self._ocs_regexp = re.compile(r"/ocs/v[12]\.php/")
+
+ def init_adapter(self, restart=False) -> None:
+ if getattr(self, "adapter", None) is None or restart:
+ self.adapter = self._create_adapter()
+ self.adapter.headers.update({"OCS-APIRequest": "true"})
+ if self.custom_headers:
+ self.adapter.headers.update(self.custom_headers)
+ if options.XDEBUG_SESSION:
+ self.adapter.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION)
+ self._capabilities = {}
+
+ def init_adapter_dav(self, restart=False) -> None:
+ if getattr(self, "adapter_dav", None) is None or restart:
+ self.adapter_dav = self._create_adapter(dav=True)
+ if self.custom_headers:
+ self.adapter_dav.headers.update(self.custom_headers)
+ if options.XDEBUG_SESSION:
+ self.adapter_dav.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION)
+
+ @abstractmethod
+ def _create_adapter(self, dav: bool = False) -> AsyncClient | Client:
+ pass # pragma: no cover
+
+ @property
+ def ae_url(self) -> str:
+ """Return base url for the App Ecosystem endpoints."""
+ return "/ocs/v1.php/apps/app_api/api/v1"
+
- def __del__(self):
- if hasattr(self, "adapter") and self.adapter:
- self.adapter.close()
- if hasattr(self, "adapter_dav") and self.adapter_dav:
- self.adapter_dav.close()
+class NcSessionBasic(NcSessionBase, ABC):
+ adapter: Client
+ adapter_dav: Client
def ocs(
self,
@@ -190,35 +216,28 @@ def ocs(
raise NextcloudException(status_code=ocs_meta["statuscode"], reason=ocs_meta["message"], info=info)
return response_data["ocs"]["data"]
- def init_adapter(self, restart=False) -> None:
- if getattr(self, "adapter", None) is None or restart:
- if restart and hasattr(self, "adapter"):
- self.adapter.close()
- self.adapter = self._create_adapter()
- self.adapter.headers.update({"OCS-APIRequest": "true"})
- if self.custom_headers:
- self.adapter.headers.update(self.custom_headers)
- if options.XDEBUG_SESSION:
- self.adapter.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION)
- self._capabilities = {}
-
- def init_adapter_dav(self, restart=False) -> None:
- if getattr(self, "adapter_dav", None) is None or restart:
- if restart and hasattr(self, "adapter"):
- self.adapter.close()
- self.adapter_dav = self._create_adapter(dav=True)
- if self.custom_headers:
- self.adapter_dav.headers.update(self.custom_headers)
- if options.XDEBUG_SESSION:
- self.adapter_dav.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION)
-
- @abstractmethod
- def _create_adapter(self, dav: bool = False) -> Client:
- pass # pragma: no cover
-
def update_server_info(self) -> None:
self._capabilities = self.ocs("GET", "/ocs/v1.php/cloud/capabilities")
+ @property
+ def capabilities(self) -> dict:
+ if not self._capabilities:
+ self.update_server_info()
+ return self._capabilities["capabilities"]
+
+ @property
+ def nc_version(self) -> ServerVersion:
+ if not self._capabilities:
+ self.update_server_info()
+ v = self._capabilities["version"]
+ return ServerVersion(
+ major=v["major"],
+ minor=v["minor"],
+ micro=v["micro"],
+ string=v["string"],
+ extended_support=v["extendedSupport"],
+ )
+
@property
def user(self) -> str:
"""Current user ID. Can be different from the login name."""
@@ -226,20 +245,92 @@ def user(self) -> str:
self._user = self.ocs("GET", "/ocs/v1.php/cloud/user")["id"]
return self._user
- @user.setter
- def user(self, value: str):
- self._user = value
+ def set_user(self, user_id: str) -> None:
+ self._user = user_id
+
+ def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]:
+ if dav:
+ return {
+ "base_url": self.cfg.dav_endpoint,
+ "timeout": self.cfg.options.timeout_dav,
+ "event_hooks": {"request": [], "response": [self._response_event]},
+ }
+ return {
+ "base_url": self.cfg.endpoint,
+ "timeout": self.cfg.options.timeout,
+ "event_hooks": {"request": [self._request_event_ocs], "response": [self._response_event]},
+ }
+
+ def _request_event_ocs(self, request: Request) -> None:
+ str_url = str(request.url)
+ if re.search(self._ocs_regexp, str_url) is not None: # this is OCS call
+ request.url = request.url.copy_merge_params({"format": "json"})
+
+ def _response_event(self, response: Response) -> None:
+ str_url = str(response.request.url)
+ # we do not want ResponseHeaders for those two endpoints, as call to them can occur during DAV calls.
+ for i in ("/ocs/v1.php/cloud/capabilities?format=json", "/ocs/v1.php/cloud/user?format=json"):
+ if str_url.endswith(i):
+ return
+ self.response_headers = response.headers
+
+
+class AsyncNcSessionBasic(NcSessionBase, ABC):
+ adapter: AsyncClient
+ adapter_dav: AsyncClient
+
+ async def ocs(
+ self,
+ method: str,
+ path: str,
+ *,
+ content: bytes | str | typing.Iterable[bytes] | typing.AsyncIterable[bytes] | None = None,
+ json: dict | list | None = None,
+ params: dict | None = None,
+ **kwargs,
+ ):
+ self.init_adapter()
+ info = f"request: {method} {path}"
+ nested_req = kwargs.pop("nested_req", False)
+ try:
+ response = await self.adapter.request(method, path, content=content, json=json, params=params, **kwargs)
+ except ReadTimeout:
+ raise NextcloudException(408, info=info) from None
+
+ check_error(response, info)
+ response_data = loads(response.text)
+ ocs_meta = response_data["ocs"]["meta"]
+ if ocs_meta["status"] != "ok":
+ if (
+ not nested_req
+ and ocs_meta["statuscode"] == 403
+ and str(ocs_meta["message"]).lower().find("password confirmation is required") != -1
+ ):
+ await self.adapter.aclose()
+ self.init_adapter(restart=True)
+ return await self.ocs(
+ method, path, **kwargs, content=content, json=json, params=params, nested_req=True
+ )
+ if ocs_meta["statuscode"] in (404, OCSRespond.RESPOND_NOT_FOUND):
+ raise NextcloudExceptionNotFound(reason=ocs_meta["message"], info=info)
+ if ocs_meta["statuscode"] == 304:
+ raise NextcloudExceptionNotModified(reason=ocs_meta["message"], info=info)
+ raise NextcloudException(status_code=ocs_meta["statuscode"], reason=ocs_meta["message"], info=info)
+ return response_data["ocs"]["data"]
+
+ async def update_server_info(self) -> None:
+ self._capabilities = await self.ocs("GET", "/ocs/v1.php/cloud/capabilities")
@property
- def capabilities(self) -> dict:
+ async def capabilities(self) -> dict:
if not self._capabilities:
- self.update_server_info()
+ await self.update_server_info()
return self._capabilities["capabilities"]
@property
- def nc_version(self) -> ServerVersion:
+ async def nc_version(self) -> ServerVersion:
if not self._capabilities:
- self.update_server_info()
+ await self.update_server_info()
v = self._capabilities["version"]
return ServerVersion(
major=v["major"],
@@ -250,9 +341,14 @@ def nc_version(self) -> ServerVersion:
)
@property
- def ae_url(self) -> str:
- """Return base url for the App Ecosystem endpoints."""
- return "/ocs/v1.php/apps/app_api/api/v1"
+ async def user(self) -> str:
+ """Current user ID. Can be different from the login name."""
+ if isinstance(self, AsyncNcSession) and not self._user: # do not trigger for NextcloudApp
+ self._user = (await self.ocs("GET", "/ocs/v1.php/cloud/user"))["id"]
+ return self._user
+
+ def set_user(self, user: str) -> None:
+ self._user = user
def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]:
if dav:
@@ -267,12 +363,12 @@ def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]:
"event_hooks": {"request": [self._request_event_ocs], "response": [self._response_event]},
}
- def _request_event_ocs(self, request: Request) -> None:
+ async def _request_event_ocs(self, request: Request) -> None:
str_url = str(request.url)
- if re.search(self.__ocs_regexp, str_url) is not None: # this is OCS call
+ if re.search(self._ocs_regexp, str_url) is not None: # this is OCS call
request.url = request.url.copy_merge_params({"format": "json"})
- def _response_event(self, response: Response) -> None:
+ async def _response_event(self, response: Response) -> None:
str_url = str(response.request.url)
# we do not want ResponseHeaders for those two endpoints, as call to them can occur during DAV calls.
for i in ("/ocs/v1.php/cloud/capabilities?format=json", "/ocs/v1.php/cloud/user?format=json"):
@@ -288,7 +384,7 @@ def __init__(self, **kwargs):
self.cfg = Config(**kwargs)
super().__init__()
- def _create_adapter(self, dav: bool = False) -> Client:
+ def _create_adapter(self, dav: bool = False) -> AsyncClient | Client:
return Client(
follow_redirects=True,
limits=self.limits,
@@ -298,32 +394,32 @@ def _create_adapter(self, dav: bool = False) -> Client:
)
-class NcSessionApp(NcSessionBasic):
- cfg: AppConfig
+class AsyncNcSession(AsyncNcSessionBasic):
+ cfg: Config
def __init__(self, **kwargs):
- self.cfg = AppConfig(**kwargs)
- super().__init__(**kwargs)
+ self.cfg = Config(**kwargs)
+ super().__init__()
- def _create_adapter(self, dav: bool = False) -> Client:
- r = self._get_adapter_kwargs(dav)
- r["event_hooks"]["request"].append(self._add_auth)
- return Client(
+ def _create_adapter(self, dav: bool = False) -> AsyncClient | Client:
+ return AsyncClient(
follow_redirects=True,
limits=self.limits,
verify=self.cfg.options.nc_cert,
- **r,
- headers={
- "AA-VERSION": self.cfg.aa_version,
- "EX-APP-ID": self.cfg.app_name,
- "EX-APP-VERSION": self.cfg.app_version,
- },
+ **self._get_adapter_kwargs(dav),
+ auth=self.cfg.auth,
)
- def _add_auth(self, request: Request):
- request.headers.update({
- "AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))
- })
+
+class NcSessionAppBasic(ABC):
+ cfg: AppConfig
+ _user: str
+ adapter: AsyncClient | Client
+ adapter_dav: AsyncClient | Client
+
+ def __init__(self, **kwargs):
+ self.cfg = AppConfig(**kwargs)
+ super().__init__(**kwargs)
def sign_check(self, request: FastAPIRequest) -> None:
headers = {
@@ -347,3 +443,51 @@ def sign_check(self, request: FastAPIRequest) -> None:
app_secret = get_username_secret_from_headers(headers)[1]
if app_secret != self.cfg.app_secret:
raise ValueError(f"Invalid App secret:{app_secret} != {self.cfg.app_secret}")
+
+
+class NcSessionApp(NcSessionAppBasic, NcSessionBasic):
+ cfg: AppConfig
+
+ def _create_adapter(self, dav: bool = False) -> AsyncClient | Client:
+ r = self._get_adapter_kwargs(dav)
+ r["event_hooks"]["request"].append(self._add_auth)
+ return Client(
+ follow_redirects=True,
+ limits=self.limits,
+ verify=self.cfg.options.nc_cert,
+ **r,
+ headers={
+ "AA-VERSION": self.cfg.aa_version,
+ "EX-APP-ID": self.cfg.app_name,
+ "EX-APP-VERSION": self.cfg.app_version,
+ },
+ )
+
+ def _add_auth(self, request: Request):
+ request.headers.update({
+ "AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))
+ })
+
+
+class AsyncNcSessionApp(NcSessionAppBasic, AsyncNcSessionBasic):
+ cfg: AppConfig
+
+ def _create_adapter(self, dav: bool = False) -> AsyncClient | Client:
+ r = self._get_adapter_kwargs(dav)
+ r["event_hooks"]["request"].append(self._add_auth)
+ return AsyncClient(
+ follow_redirects=True,
+ limits=self.limits,
+ verify=self.cfg.options.nc_cert,
+ **r,
+ headers={
+ "AA-VERSION": self.cfg.aa_version,
+ "EX-APP-ID": self.cfg.app_name,
+ "EX-APP-VERSION": self.cfg.app_version,
+ },
+ )
+
+ async def _add_auth(self, request: Request):
+ request.headers.update({
+ "AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))
+ })
diff --git a/nc_py_api/_talk_api.py b/nc_py_api/_talk_api.py
index 3311cc2a..5987074c 100644
--- a/nc_py_api/_talk_api.py
+++ b/nc_py_api/_talk_api.py
@@ -9,7 +9,7 @@
random_string,
require_capabilities,
)
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
from .files import FsNode, Share, ShareType
from .talk import (
BotInfo,
@@ -26,7 +26,7 @@
class _TalkAPI:
- """Class that implements work with Nextcloud Talk."""
+ """Class provides API to work with Nextcloud Talk."""
_ep_base: str = "/ocs/v2.php/apps/spreed"
config_sha: str
@@ -125,31 +125,19 @@ def create_conversation(
return Conversation(self._session.ocs("POST", self._ep_base + "/api/v4/room", json=params))
def rename_conversation(self, conversation: Conversation | str, new_name: str) -> None:
- """Renames a conversation.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param new_name: new name for the conversation.
- """
+ """Renames a conversation."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}", params={"roomName": new_name})
def set_conversation_description(self, conversation: Conversation | str, description: str) -> None:
- """Sets conversation description.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param description: description for the conversation.
- """
+ """Sets conversation description."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs(
"PUT", self._ep_base + f"/api/v4/room/{token}/description", params={"description": description}
)
def set_conversation_fav(self, conversation: Conversation | str, favorite: bool) -> None:
- """Changes conversation **favorite** state.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param favorite: value to set for the ``favourite`` state.
- """
+ """Changes conversation **favorite** state."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("POST" if favorite else "DELETE", self._ep_base + f"/api/v4/room/{token}/favorite")
@@ -167,29 +155,17 @@ def set_conversation_password(self, conversation: Conversation | str, password:
self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/password", params={"password": password})
def set_conversation_readonly(self, conversation: Conversation | str, read_only: bool) -> None:
- """Changes conversation **read_only** state.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param read_only: value to set for the ``read_only`` state.
- """
+ """Changes conversation **read_only** state."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/read-only", params={"state": int(read_only)})
def set_conversation_public(self, conversation: Conversation | str, public: bool) -> None:
- """Changes conversation **public** state.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param public: the value to be set as the new ``public`` state.
- """
+ """Changes conversation **public** state."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("POST" if public else "DELETE", self._ep_base + f"/api/v4/room/{token}/public")
def set_conversation_notify_lvl(self, conversation: Conversation | str, new_lvl: NotificationLevel) -> None:
- """Sets new notification level for user in the conversation.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
- :param new_lvl: new value for notification level for the current user.
- """
+ """Sets new notification level for user in the conversation."""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("POST", self._ep_base + f"/api/v4/room/{token}/notify", json={"level": int(new_lvl)})
@@ -206,8 +182,6 @@ def delete_conversation(self, conversation: Conversation | str) -> None:
.. note:: Deleting a conversation that is the parent of breakout rooms, will also delete them.
``ONE_TO_ONE`` conversations cannot be deleted for them
:py:class:`~nc_py_api._talk_api._TalkAPI.leave_conversation` should be used.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
"""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}")
@@ -217,8 +191,6 @@ def leave_conversation(self, conversation: Conversation | str) -> None:
.. note:: When the participant is a moderator or owner and there are no other moderators or owners left,
participant cannot leave conversation.
-
- :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
"""
token = conversation.token if isinstance(conversation, Conversation) else conversation
self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}/participants/self")
@@ -231,7 +203,7 @@ def send_message(
silent: bool = False,
actor_display_name: str = "",
) -> TalkMessage:
- """Send a message and returns a "reference string" to identify the message again in a "get messages" request.
+ """Send a message to the conversation.
:param message: The message the user wants to say.
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
@@ -242,31 +214,17 @@ def send_message(
The message you are replying to should be from the same conversation.
:param silent: Flag controlling if the message should create a chat notifications for the users.
:param actor_display_name: Guest display name (**ignored for the logged-in users**).
- :returns: :py:class:`~nc_py_api.talk.TalkMessage` that describes the sent message.
:raises ValueError: in case of an invalid usage.
"""
- token = self._get_token(message, conversation)
- reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest()
- params = {
- "message": message,
- "actorDisplayName": actor_display_name,
- "replyTo": reply_to_message.message_id if isinstance(reply_to_message, TalkMessage) else reply_to_message,
- "referenceId": reference_id,
- "silent": silent,
- }
+ params = _send_message(message, actor_display_name, silent, reply_to_message)
+ token = _get_token(message, conversation)
r = self._session.ocs("POST", self._ep_base + f"/api/v1/chat/{token}", json=params)
return TalkMessage(r)
def send_file(self, path: str | FsNode, conversation: Conversation | str = "") -> tuple[Share, str]:
+ """Sends a file to the conversation."""
+ reference_id, params = _send_file(path, conversation)
require_capabilities("files_sharing.api_enabled", self._session.capabilities)
- token = conversation.token if isinstance(conversation, Conversation) else conversation
- reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest()
- params = {
- "shareType": ShareType.TYPE_ROOM,
- "shareWith": token,
- "path": path.user_path if isinstance(path, FsNode) else path,
- "referenceId": reference_id,
- }
r = self._session.ocs("POST", "/ocs/v1.php/apps/files_sharing/api/v1/shares", json=params)
return Share(r), reference_id
@@ -293,8 +251,8 @@ def receive_messages(
"timeout": timeout,
"noStatusUpdate": int(no_status_update),
}
- result = self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params)
- return [TalkFileMessage(i, self._session.user) if i["message"] == "{file}" else TalkMessage(i) for i in result]
+ r = self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params)
+ return [TalkFileMessage(i, self._session.user) if i["message"] == "{file}" else TalkMessage(i) for i in r]
def delete_message(self, message: TalkMessage | str, conversation: Conversation | str = "") -> TalkMessage:
"""Delete a chat message.
@@ -304,7 +262,7 @@ def delete_message(self, message: TalkMessage | str, conversation: Conversation
.. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
"""
- token = self._get_token(message, conversation)
+ token = _get_token(message, conversation)
message_id = message.message_id if isinstance(message, TalkMessage) else message
result = self._session.ocs("DELETE", self._ep_base + f"/api/v1/chat/{token}/{message_id}")
return TalkMessage(result)
@@ -319,10 +277,8 @@ def react_to_message(
:param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
.. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
-
- :returns: list of reactions to the message.
"""
- token = self._get_token(message, conversation)
+ token = _get_token(message, conversation)
message_id = message.message_id if isinstance(message, TalkMessage) else message
params = {
"reaction": reaction,
@@ -341,7 +297,7 @@ def delete_reaction(
.. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
"""
- token = self._get_token(message, conversation)
+ token = _get_token(message, conversation)
message_id = message.message_id if isinstance(message, TalkMessage) else message
params = {
"reaction": reaction,
@@ -360,7 +316,7 @@ def get_message_reactions(
.. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
"""
- token = self._get_token(message, conversation)
+ token = _get_token(message, conversation)
message_id = message.message_id if isinstance(message, TalkMessage) else message
params = {"reaction": reaction_filter} if reaction_filter else {}
r = self._session.ocs("GET", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params)
@@ -521,19 +477,504 @@ def get_conversation_avatar(self, conversation: Conversation | str, dark=False)
check_error(response)
return response.content
- @staticmethod
- def _get_token(message: TalkMessage | str, conversation: Conversation | str) -> str:
- if not conversation and not isinstance(message, TalkMessage):
- raise ValueError("Either specify 'conversation' or provide 'TalkMessage'.")
+ def _update_config_sha(self):
+ config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"]
+ if self.config_sha != config_sha:
+ self._session.update_server_info()
+ self.config_sha = config_sha
+
+
+class _AsyncTalkAPI:
+ """Class provides API to work with Nextcloud Talk."""
- return (
- message.token
- if isinstance(message, TalkMessage)
- else conversation.token if isinstance(conversation, Conversation) else conversation
+ _ep_base: str = "/ocs/v2.php/apps/spreed"
+ config_sha: str
+ """Sha1 value over Talk config. After receiving a different value on subsequent requests, settings got refreshed."""
+ modified_since: int
+ """Used by ``get_user_conversations``, when **modified_since** param is ``True``."""
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+ self.config_sha = ""
+ self.modified_since = 0
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("spreed", await self._session.capabilities)
+
+ @property
+ async def bots_available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+
+ async def get_user_conversations(
+ self, no_status_update: bool = True, include_status: bool = False, modified_since: int | bool = 0
+ ) -> list[Conversation]:
+ """Returns the list of the user's conversations.
+
+ :param no_status_update: When the user status should not be automatically set to the online.
+ :param include_status: Whether the user status information of all one-to-one conversations should be loaded.
+ :param modified_since: When provided only conversations with a newer **lastActivity**
+ (and one-to-one conversations when includeStatus is provided) are returned.
+ Can be set to ``True`` to automatically use last ``modified_since`` from previous calls. Default = **0**.
+
+ .. note:: In rare cases, when a request arrives between seconds, it is possible that return data
+ will contain part of the conversations from the last call that was not modified(
+ their `last_activity` will be the same as ``talk.modified_since``).
+ """
+ params: dict = {}
+ if no_status_update:
+ params["noStatusUpdate"] = 1
+ if include_status:
+ params["includeStatus"] = 1
+ if modified_since:
+ params["modifiedSince"] = self.modified_since if modified_since is True else modified_since
+
+ result = await self._session.ocs("GET", self._ep_base + "/api/v4/room", params=params)
+ self.modified_since = int(self._session.response_headers["X-Nextcloud-Talk-Modified-Before"])
+ await self._update_config_sha()
+ return [Conversation(i) for i in result]
+
+ async def list_participants(
+ self, conversation: Conversation | str, include_status: bool = False
+ ) -> list[Participant]:
+ """Returns a list of conversation participants.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param include_status: Whether the user status information of all one-to-one conversations should be loaded.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ result = await self._session.ocs(
+ "GET", self._ep_base + f"/api/v4/room/{token}/participants", params={"includeStatus": int(include_status)}
)
+ return [Participant(i) for i in result]
- def _update_config_sha(self):
+ async def create_conversation(
+ self,
+ conversation_type: ConversationType,
+ invite: str = "",
+ source: str = "",
+ room_name: str = "",
+ object_type: str = "",
+ object_id: str = "",
+ ) -> Conversation:
+ """Creates a new conversation.
+
+ .. note:: Creating a conversation as a child breakout room will automatically set the lobby when breakout
+ rooms are not started and will always overwrite the room type with the parent room type.
+ Also, moderators of the parent conversation will be automatically added as moderators.
+
+ :param conversation_type: type of the conversation to create.
+ :param invite: User ID(roomType=ONE_TO_ONE), Group ID(roomType=GROUP - optional),
+ Circle ID(roomType=GROUP, source='circles', only available with the ``circles-support`` capability).
+ :param source: The source for the invite, only supported on roomType = GROUP for groups and circles.
+ :param room_name: Conversation name up to 255 characters(``not available for roomType=ONE_TO_ONE``).
+ :param object_type: Type of object this room references, currently only allowed
+ value is **"room"** to indicate the parent of a breakout room.
+ :param object_id: ID of an object this room references, room token is used for the parent of a breakout room.
+ """
+ params: dict = {
+ "roomType": int(conversation_type),
+ "invite": invite,
+ "source": source,
+ "roomName": room_name,
+ "objectType": object_type,
+ "objectId": object_id,
+ }
+ clear_from_params_empty(["invite", "source", "roomName", "objectType", "objectId"], params)
+ return Conversation(await self._session.ocs("POST", self._ep_base + "/api/v4/room", json=params))
+
+ async def rename_conversation(self, conversation: Conversation | str, new_name: str) -> None:
+ """Renames a conversation."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}", params={"roomName": new_name})
+
+ async def set_conversation_description(self, conversation: Conversation | str, description: str) -> None:
+ """Sets conversation description."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs(
+ "PUT", self._ep_base + f"/api/v4/room/{token}/description", params={"description": description}
+ )
+
+ async def set_conversation_fav(self, conversation: Conversation | str, favorite: bool) -> None:
+ """Changes conversation **favorite** state."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("POST" if favorite else "DELETE", self._ep_base + f"/api/v4/room/{token}/favorite")
+
+ async def set_conversation_password(self, conversation: Conversation | str, password: str) -> None:
+ """Sets password for a conversation.
+
+ Currently, it is only allowed to have a password for ``public`` conversations.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param password: new password for the conversation.
+
+ .. note:: Password should match the password policy.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/password", params={"password": password})
+
+ async def set_conversation_readonly(self, conversation: Conversation | str, read_only: bool) -> None:
+ """Changes conversation **read_only** state."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs(
+ "PUT", self._ep_base + f"/api/v4/room/{token}/read-only", params={"state": int(read_only)}
+ )
+
+ async def set_conversation_public(self, conversation: Conversation | str, public: bool) -> None:
+ """Changes conversation **public** state."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("POST" if public else "DELETE", self._ep_base + f"/api/v4/room/{token}/public")
+
+ async def set_conversation_notify_lvl(self, conversation: Conversation | str, new_lvl: NotificationLevel) -> None:
+ """Sets new notification level for user in the conversation."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("POST", self._ep_base + f"/api/v4/room/{token}/notify", json={"level": int(new_lvl)})
+
+ async def get_conversation_by_token(self, conversation: Conversation | str) -> Conversation:
+ """Gets conversation by token."""
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ result = await self._session.ocs("GET", self._ep_base + f"/api/v4/room/{token}")
+ await self._update_config_sha()
+ return Conversation(result)
+
+ async def delete_conversation(self, conversation: Conversation | str) -> None:
+ """Deletes a conversation.
+
+ .. note:: Deleting a conversation that is the parent of breakout rooms, will also delete them.
+ ``ONE_TO_ONE`` conversations cannot be deleted for them
+ :py:class:`~nc_py_api._talk_api._TalkAPI.leave_conversation` should be used.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}")
+
+ async def leave_conversation(self, conversation: Conversation | str) -> None:
+ """Removes yourself from the conversation.
+
+ .. note:: When the participant is a moderator or owner and there are no other moderators or owners left,
+ participant cannot leave conversation.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ await self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}/participants/self")
+
+ async def send_message(
+ self,
+ message: str,
+ conversation: Conversation | str = "",
+ reply_to_message: int | TalkMessage = 0,
+ silent: bool = False,
+ actor_display_name: str = "",
+ ) -> TalkMessage:
+ """Send a message to the conversation.
+
+ :param message: The message the user wants to say.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ Need only if **reply_to_message** is not :py:class:`~nc_py_api.talk.TalkMessage`
+ :param reply_to_message: The message ID this message is a reply to.
+
+ .. note:: Only allowed when the message type is not ``system`` or ``command``.
+ The message you are replying to should be from the same conversation.
+ :param silent: Flag controlling if the message should create a chat notifications for the users.
+ :param actor_display_name: Guest display name (**ignored for the logged-in users**).
+ :raises ValueError: in case of an invalid usage.
+ """
+ params = _send_message(message, actor_display_name, silent, reply_to_message)
+ token = _get_token(message, conversation)
+ r = await self._session.ocs("POST", self._ep_base + f"/api/v1/chat/{token}", json=params)
+ return TalkMessage(r)
+
+ async def send_file(self, path: str | FsNode, conversation: Conversation | str = "") -> tuple[Share, str]:
+ """Sends a file to the conversation."""
+ reference_id, params = _send_file(path, conversation)
+ require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ r = await self._session.ocs("POST", "/ocs/v1.php/apps/files_sharing/api/v1/shares", json=params)
+ return Share(r), reference_id
+
+ async def receive_messages(
+ self,
+ conversation: Conversation | str,
+ look_in_future: bool = False,
+ limit: int = 100,
+ timeout: int = 30,
+ no_status_update: bool = True,
+ ) -> list[TalkMessage]:
+ """Receive chat messages of a conversation.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param look_in_future: ``True`` to poll and wait for the new message or ``False`` to get history.
+ :param limit: Number of chat messages to receive (``100`` by default, ``200`` at most).
+ :param timeout: ``look_in_future=1`` only: seconds to wait for the new messages (60 secs at most).
+ :param no_status_update: When the user status should not be automatically set to the online.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ params = {
+ "lookIntoFuture": int(look_in_future),
+ "limit": limit,
+ "timeout": timeout,
+ "noStatusUpdate": int(no_status_update),
+ }
+ r = await self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params)
+ return [TalkFileMessage(i, await self._session.user) if i["message"] == "{file}" else TalkMessage(i) for i in r]
+
+ async def delete_message(self, message: TalkMessage | str, conversation: Conversation | str = "") -> TalkMessage:
+ """Delete a chat message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to delete.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+
+ .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
+ """
+ token = _get_token(message, conversation)
+ message_id = message.message_id if isinstance(message, TalkMessage) else message
+ result = await self._session.ocs("DELETE", self._ep_base + f"/api/v1/chat/{token}/{message_id}")
+ return TalkMessage(result)
+
+ async def react_to_message(
+ self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = ""
+ ) -> dict[str, list[MessageReactions]]:
+ """React to a chat message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to react to.
+ :param reaction: A single emoji.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+
+ .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
+ """
+ token = _get_token(message, conversation)
+ message_id = message.message_id if isinstance(message, TalkMessage) else message
+ params = {
+ "reaction": reaction,
+ }
+ r = await self._session.ocs("POST", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params)
+ return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+
+ async def delete_reaction(
+ self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = ""
+ ) -> dict[str, list[MessageReactions]]:
+ """Remove reaction from a chat message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to remove reaction from.
+ :param reaction: A single emoji.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+
+ .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
+ """
+ token = _get_token(message, conversation)
+ message_id = message.message_id if isinstance(message, TalkMessage) else message
+ params = {
+ "reaction": reaction,
+ }
+ r = await self._session.ocs("DELETE", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params)
+ return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+
+ async def get_message_reactions(
+ self, message: TalkMessage | str, reaction_filter: str = "", conversation: Conversation | str = ""
+ ) -> dict[str, list[MessageReactions]]:
+ """Get reactions information for a chat message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to get reactions from.
+ :param reaction_filter: A single emoji to get reaction information only for it.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+
+ .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage`
+ """
+ token = _get_token(message, conversation)
+ message_id = message.message_id if isinstance(message, TalkMessage) else message
+ params = {"reaction": reaction_filter} if reaction_filter else {}
+ r = await self._session.ocs("GET", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params)
+ return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+
+ async def list_bots(self) -> list[BotInfo]:
+ """Lists the bots that are installed on the server."""
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ return [BotInfo(i) for i in await self._session.ocs("GET", self._ep_base + "/api/v1/bot/admin")]
+
+ async def conversation_list_bots(self, conversation: Conversation | str) -> list[BotInfoBasic]:
+ """Lists the bots that are enabled and can be enabled for the conversation.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ """
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ return [BotInfoBasic(i) for i in await self._session.ocs("GET", self._ep_base + f"/api/v1/bot/{token}")]
+
+ async def enable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None:
+ """Enable a bot for a conversation as a moderator.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`.
+ """
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot
+ await self._session.ocs("POST", self._ep_base + f"/api/v1/bot/{token}/{bot_id}")
+
+ async def disable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None:
+ """Disable a bot for a conversation as a moderator.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`.
+ """
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot
+ await self._session.ocs("DELETE", self._ep_base + f"/api/v1/bot/{token}/{bot_id}")
+
+ async def create_poll(
+ self,
+ conversation: Conversation | str,
+ question: str,
+ options: list[str],
+ hidden_results: bool = True,
+ max_votes: int = 1,
+ ) -> Poll:
+ """Creates a poll in a conversation.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param question: The question of the poll.
+ :param options: Array of strings with the voting options.
+ :param hidden_results: Should results be hidden until the poll is closed and then only the summary is published.
+ :param max_votes: The maximum amount of options a participant can vote for.
+ """
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ params = {
+ "question": question,
+ "options": options,
+ "resultMode": int(hidden_results),
+ "maxVotes": max_votes,
+ }
+ return Poll(await self._session.ocs("POST", self._ep_base + f"/api/v1/poll/{token}", json=params), token)
+
+ async def get_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll:
+ """Get state or result of a poll.
+
+ :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ """
+ if isinstance(poll, Poll):
+ poll_id = poll.poll_id
+ token = poll.conversation_token
+ else:
+ poll_id = poll
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ return Poll(await self._session.ocs("GET", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
+
+ async def vote_poll(self, options_ids: list[int], poll: Poll | int, conversation: Conversation | str = "") -> Poll:
+ """Vote on a poll.
+
+ :param options_ids: The option IDs the participant wants to vote for.
+ :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ """
+ if isinstance(poll, Poll):
+ poll_id = poll.poll_id
+ token = poll.conversation_token
+ else:
+ poll_id = poll
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ r = await self._session.ocs(
+ "POST", self._ep_base + f"/api/v1/poll/{token}/{poll_id}", json={"optionIds": options_ids}
+ )
+ return Poll(r, token)
+
+ async def close_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll:
+ """Close a poll.
+
+ :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`.
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ """
+ if isinstance(poll, Poll):
+ poll_id = poll.poll_id
+ token = poll.conversation_token
+ else:
+ poll_id = poll
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ return Poll(await self._session.ocs("DELETE", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
+
+ async def set_conversation_avatar(
+ self, conversation: Conversation | str, avatar: bytes | tuple[str, str | None]
+ ) -> Conversation:
+ """Set image or emoji as avatar for the conversation.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param avatar: Squared image with mimetype equal to PNG or JPEG or a tuple with emoji and optional
+ HEX color code(6 times ``0-9A-F``) without the leading ``#`` character.
+
+ .. note:: When color omitted, fallback will be to the default bright/dark mode icon background color.
+ """
+ require_capabilities("spreed.features.avatar", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ if isinstance(avatar, bytes):
+ r = await self._session.ocs("POST", self._ep_base + f"/api/v1/room/{token}/avatar", files={"file": avatar})
+ else:
+ r = await self._session.ocs(
+ "POST",
+ self._ep_base + f"/api/v1/room/{token}/avatar/emoji",
+ json={
+ "emoji": avatar[0],
+ "color": avatar[1],
+ },
+ )
+ return Conversation(r)
+
+ async def delete_conversation_avatar(self, conversation: Conversation | str) -> Conversation:
+ """Delete conversation avatar.
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ """
+ require_capabilities("spreed.features.avatar", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ return Conversation(await self._session.ocs("DELETE", self._ep_base + f"/api/v1/room/{token}/avatar"))
+
+ async def get_conversation_avatar(self, conversation: Conversation | str, dark=False) -> bytes:
+ """Get conversation avatar (binary).
+
+ :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`.
+ :param dark: boolean indicating should be or not avatar fetched for dark theme.
+ """
+ require_capabilities("spreed.features.avatar", await self._session.capabilities)
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ ep_suffix = "/dark" if dark else ""
+ response = await self._session.adapter.get(self._ep_base + f"/api/v1/room/{token}/avatar" + ep_suffix)
+ check_error(response)
+ return response.content
+
+ async def _update_config_sha(self):
config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"]
if self.config_sha != config_sha:
- self._session.update_server_info()
+ await self._session.update_server_info()
self.config_sha = config_sha
+
+
+def _send_message(message: str, actor_display_name: str, silent: bool, reply_to_message: int | TalkMessage):
+ return {
+ "message": message,
+ "actorDisplayName": actor_display_name,
+ "replyTo": reply_to_message.message_id if isinstance(reply_to_message, TalkMessage) else reply_to_message,
+ "referenceId": hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest(),
+ "silent": silent,
+ }
+
+
+def _send_file(path: str | FsNode, conversation: Conversation | str):
+ token = conversation.token if isinstance(conversation, Conversation) else conversation
+ reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest()
+ params = {
+ "shareType": ShareType.TYPE_ROOM,
+ "shareWith": token,
+ "path": path.user_path if isinstance(path, FsNode) else path,
+ "referenceId": reference_id,
+ }
+ return reference_id, params
+
+
+def _get_token(message: TalkMessage | str, conversation: Conversation | str) -> str:
+ if not conversation and not isinstance(message, TalkMessage):
+ raise ValueError("Either specify 'conversation' or provide 'TalkMessage'.")
+
+ return (
+ message.token
+ if isinstance(message, TalkMessage)
+ else conversation.token if isinstance(conversation, Conversation) else conversation
+ )
diff --git a/nc_py_api/activity.py b/nc_py_api/activity.py
index f4dc6798..c445f5cb 100644
--- a/nc_py_api/activity.py
+++ b/nc_py_api/activity.py
@@ -2,10 +2,11 @@
import dataclasses
import datetime
+import typing
from ._exceptions import NextcloudExceptionNotModified
from ._misc import check_capabilities, nc_iso_time_to_datetime
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -172,23 +173,9 @@ def get_activities(
.. note:: ``object_type`` and ``object_id`` should only appear together with ``filter_id`` unset.
"""
- if bool(object_id) != bool(object_type):
- raise ValueError("Either specify both `object_type` and `object_id`, or don't specify any at all.")
if since is True:
since = self.last_given
- filter_id = filter_id.filter_id if isinstance(filter_id, ActivityFilter) else filter_id
- params = {
- "since": since,
- "limit": limit,
- "object_type": object_type,
- "object_id": object_id,
- "sort": sort,
- }
- url = (
- f"/api/v2/activity/{filter_id}"
- if filter_id
- else "/api/v2/activity/filter" if object_id else "/api/v2/activity"
- )
+ url, params = _get_activities(filter_id, since, limit, object_type, object_id, sort)
try:
result = self._session.ocs("GET", self._ep_base + url, params=params)
except NextcloudExceptionNotModified:
@@ -199,3 +186,74 @@ def get_activities(
def get_filters(self) -> list[ActivityFilter]:
"""Returns avalaible activity filters."""
return [ActivityFilter(i) for i in self._session.ocs("GET", self._ep_base + "/api/v2/activity/filters")]
+
+
+class _AsyncActivityAPI:
+ """The class provides the async Activity Application API."""
+
+ _ep_base: str = "/ocs/v1.php/apps/activity"
+ last_given: int
+ """Used by ``get_activities``, when **since** param is ``True``."""
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+ self.last_given = 0
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("activity.apiv2", await self._session.capabilities)
+
+ async def get_activities(
+ self,
+ filter_id: ActivityFilter | str = "",
+ since: int | bool = 0,
+ limit: int = 50,
+ object_type: str = "",
+ object_id: int = 0,
+ sort: str = "desc",
+ ) -> list[Activity]:
+ """Returns activities for the current user.
+
+ :param filter_id: Filter to apply, if needed.
+ :param since: Last activity ID you have seen. When specified, only activities after provided are returned.
+ Can be set to ``True`` to automatically use last ``last_given`` from previous calls. Default = **0**.
+ :param limit: Max number of activities to be returned.
+ :param object_type: Filter the activities to a given object.
+ :param object_id: Filter the activities to a given object.
+ :param sort: Sort activities ascending or descending. Default is ``desc``.
+
+ .. note:: ``object_type`` and ``object_id`` should only appear together with ``filter_id`` unset.
+ """
+ if since is True:
+ since = self.last_given
+ url, params = _get_activities(filter_id, since, limit, object_type, object_id, sort)
+ try:
+ result = await self._session.ocs("GET", self._ep_base + url, params=params)
+ except NextcloudExceptionNotModified:
+ return []
+ self.last_given = int(self._session.response_headers["X-Activity-Last-Given"])
+ return [Activity(i) for i in result]
+
+ async def get_filters(self) -> list[ActivityFilter]:
+ """Returns avalaible activity filters."""
+ return [ActivityFilter(i) for i in await self._session.ocs("GET", self._ep_base + "/api/v2/activity/filters")]
+
+
+def _get_activities(
+ filter_id: ActivityFilter | str, since: int | bool, limit: int, object_type: str, object_id: int, sort: str
+) -> tuple[str, dict[str, typing.Any]]:
+ if bool(object_id) != bool(object_type):
+ raise ValueError("Either specify both `object_type` and `object_id`, or don't specify any at all.")
+ filter_id = filter_id.filter_id if isinstance(filter_id, ActivityFilter) else filter_id
+ params = {
+ "since": since,
+ "limit": limit,
+ "object_type": object_type,
+ "object_id": object_id,
+ "sort": sort,
+ }
+ url = (
+ f"/api/v2/activity/{filter_id}" if filter_id else "/api/v2/activity/filter" if object_id else "/api/v2/activity"
+ )
+ return url, params
diff --git a/nc_py_api/apps.py b/nc_py_api/apps.py
index 7124f29f..db17d446 100644
--- a/nc_py_api/apps.py
+++ b/nc_py_api/apps.py
@@ -4,7 +4,7 @@
import datetime
from ._misc import require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -127,7 +127,6 @@ def ex_app_get_list(self, enabled: bool = False) -> list[ExAppInfo]:
"""Gets information of the enabled external applications installed on the server.
:param enabled: Flag indicating whether to return only enabled applications or all applications.
- Default = **False**.
"""
require_capabilities("app_api", self._session.capabilities)
url_param = "enabled" if enabled else "all"
@@ -145,3 +144,99 @@ def ex_app_is_disabled(self, app_id: str) -> bool:
if not app_id:
raise ValueError("`app_id` parameter can not be empty")
return app_id in [i.app_id for i in self.ex_app_get_list() if not i.enabled]
+
+
+class _AsyncAppsAPI:
+ """The class provides the async application management API on the Nextcloud server."""
+
+ _ep_base: str = "/ocs/v1.php/cloud/apps"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ async def disable(self, app_id: str) -> None:
+ """Disables the application.
+
+ .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode.
+ """
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ await self._session.ocs("DELETE", f"{self._ep_base}/{app_id}")
+
+ async def enable(self, app_id: str) -> None:
+ """Enables the application.
+
+ .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode.
+ """
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ await self._session.ocs("POST", f"{self._ep_base}/{app_id}")
+
+ async def get_list(self, enabled: bool | None = None) -> list[str]:
+ """Get the list of installed applications.
+
+ :param enabled: filter to list all/only enabled/only disabled applications.
+ """
+ params = None
+ if enabled is not None:
+ params = {"filter": "enabled" if enabled else "disabled"}
+ result = await self._session.ocs("GET", self._ep_base, params=params)
+ return list(result["apps"].values()) if isinstance(result["apps"], dict) else result["apps"]
+
+ async def is_installed(self, app_id: str) -> bool:
+ """Returns ``True`` if specified application is installed."""
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ return app_id in await self.get_list()
+
+ async def is_enabled(self, app_id: str) -> bool:
+ """Returns ``True`` if specified application is enabled."""
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ return app_id in await self.get_list(enabled=True)
+
+ async def is_disabled(self, app_id: str) -> bool:
+ """Returns ``True`` if specified application is disabled."""
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ return app_id in await self.get_list(enabled=False)
+
+ async def ex_app_disable(self, app_id: str) -> None:
+ """Disables the external application.
+
+ .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode.
+ """
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ await self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 0})
+
+ async def ex_app_enable(self, app_id: str) -> None:
+ """Enables the external application.
+
+ .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode.
+ """
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ await self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 1})
+
+ async def ex_app_get_list(self, enabled: bool = False) -> list[ExAppInfo]:
+ """Gets information of the enabled external applications installed on the server.
+
+ :param enabled: Flag indicating whether to return only enabled applications or all applications.
+ """
+ require_capabilities("app_api", await self._session.capabilities)
+ url_param = "enabled" if enabled else "all"
+ r = await self._session.ocs("GET", f"{self._session.ae_url}/ex-app/{url_param}")
+ return [ExAppInfo(i) for i in r]
+
+ async def ex_app_is_enabled(self, app_id: str) -> bool:
+ """Returns ``True`` if specified external application is enabled."""
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ return app_id in [i.app_id for i in await self.ex_app_get_list(True)]
+
+ async def ex_app_is_disabled(self, app_id: str) -> bool:
+ """Returns ``True`` if specified external application is disabled."""
+ if not app_id:
+ raise ValueError("`app_id` parameter can not be empty")
+ return app_id in [i.app_id for i in await self.ex_app_get_list() if not i.enabled]
diff --git a/nc_py_api/ex_app/__init__.py b/nc_py_api/ex_app/__init__.py
index 2dbec4ec..8472a84a 100644
--- a/nc_py_api/ex_app/__init__.py
+++ b/nc_py_api/ex_app/__init__.py
@@ -1,7 +1,13 @@
"""All possible ExApp stuff for NextcloudApp that can be used."""
from .defs import ApiScope, LogLvl
-from .integration_fastapi import nc_app, set_handlers, talk_bot_app
+from .integration_fastapi import (
+ anc_app,
+ atalk_bot_app,
+ nc_app,
+ set_handlers,
+ talk_bot_app,
+)
from .misc import persistent_storage, verify_version
from .ui.files_actions import UiActionFileInfo
from .uvicorn_fastapi import run_app
diff --git a/nc_py_api/ex_app/integration_fastapi.py b/nc_py_api/ex_app/integration_fastapi.py
index 298f2a40..553cece4 100644
--- a/nc_py_api/ex_app/integration_fastapi.py
+++ b/nc_py_api/ex_app/integration_fastapi.py
@@ -19,8 +19,8 @@
)
from .._misc import get_username_secret_from_headers
-from ..nextcloud import NextcloudApp
-from ..talk_bot import TalkBotMessage, get_bot_secret
+from ..nextcloud import AsyncNextcloudApp, NextcloudApp
+from ..talk_bot import TalkBotMessage, aget_bot_secret, get_bot_secret
from .misc import persistent_storage
@@ -30,17 +30,25 @@ def nc_app(request: Request) -> NextcloudApp:
"AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", "")
})[0]
request_id = request.headers.get("AA-REQUEST-ID", None)
- headers = {"AA-REQUEST-ID": request_id} if request_id else {}
- nextcloud_app = NextcloudApp(user=user, headers=headers)
+ nextcloud_app = NextcloudApp(user=user, headers={"AA-REQUEST-ID": request_id} if request_id else {})
if not nextcloud_app.request_sign_check(request):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return nextcloud_app
-def talk_bot_app(request: Request) -> TalkBotMessage:
- """Authentication handler for bot requests from Nextcloud Talk to the application."""
- body = asyncio.run(request.body())
- secret = get_bot_secret(request.url.components.path)
+def anc_app(request: Request) -> AsyncNextcloudApp:
+ """Async Authentication handler for requests from Nextcloud to the application."""
+ user = get_username_secret_from_headers({
+ "AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", "")
+ })[0]
+ request_id = request.headers.get("AA-REQUEST-ID", None)
+ nextcloud_app = AsyncNextcloudApp(user=user, headers={"AA-REQUEST-ID": request_id} if request_id else {})
+ if not nextcloud_app.request_sign_check(request):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
+ return nextcloud_app
+
+
+def __talk_bot_app(secret: bytes | None, request: Request, body: bytes) -> TalkBotMessage:
if not secret:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
hmac_sign = hmac.new(
@@ -52,11 +60,21 @@ def talk_bot_app(request: Request) -> TalkBotMessage:
return TalkBotMessage(json.loads(body))
+def talk_bot_app(request: Request) -> TalkBotMessage:
+ """Authentication handler for bot requests from Nextcloud Talk to the application."""
+ return __talk_bot_app(get_bot_secret(request.url.components.path), request, asyncio.run(request.body()))
+
+
+async def atalk_bot_app(request: Request) -> TalkBotMessage:
+ """Async Authentication handler for bot requests from Nextcloud Talk to the application."""
+ return __talk_bot_app(await aget_bot_secret(request.url.components.path), request, await request.body())
+
+
def set_handlers(
fast_api_app: FastAPI,
- enabled_handler: typing.Callable[[bool, NextcloudApp], str | typing.Awaitable[str]],
- heartbeat_handler: typing.Callable[[], str | typing.Awaitable[str]] | None = None,
- init_handler: typing.Callable[[NextcloudApp], None] | None = None,
+ enabled_handler: typing.Callable[[bool, AsyncNextcloudApp | NextcloudApp], typing.Awaitable[str] | str],
+ heartbeat_handler: typing.Callable[[], typing.Awaitable[str] | str] | None = None,
+ init_handler: typing.Callable[[AsyncNextcloudApp | NextcloudApp], typing.Awaitable[None] | None] | None = None,
models_to_fetch: list[str] | None = None,
models_download_params: dict | None = None,
map_app_static: bool = True,
@@ -68,8 +86,7 @@ def set_handlers(
:param heartbeat_handler: Optional, callback that will be called for the `heartbeat` deploy event.
:param init_handler: Optional, callback that will be called for the `init` event.
- .. note:: If ``init_handler`` is specified, it is up to a developer to set the application init progress status.
- AppAPI will only call `enabled_handler` after it receives ``100`` as initialization status progress.
+ .. note:: This parameter is **mutually exclusive** with ``models_to_fetch``.
:param models_to_fetch: Dictionary describing which models should be downloaded during `init`.
@@ -80,42 +97,73 @@ def set_handlers(
.. note:: First, presence of these directories in the current working dir is checked, then one directory higher.
"""
+ if models_to_fetch is not None and init_handler is not None:
+ raise ValueError("Only `init_handler` OR `models_to_fetch` can be defined.")
+
+ if asyncio.iscoroutinefunction(enabled_handler):
- @fast_api_app.put("/enabled")
- async def enabled_callback(
- enabled: bool,
- nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
- ):
- if asyncio.iscoroutinefunction(heartbeat_handler):
- r = await enabled_handler(enabled, nc) # type: ignore
- else:
- r = enabled_handler(enabled, nc)
- return responses.JSONResponse(content={"error": r}, status_code=200)
-
- @fast_api_app.get("/heartbeat")
- async def heartbeat_callback():
- if heartbeat_handler is not None:
- if asyncio.iscoroutinefunction(heartbeat_handler):
- return_status = await heartbeat_handler()
- else:
- return_status = heartbeat_handler()
- else:
- return_status = "ok"
- return responses.JSONResponse(content={"status": return_status}, status_code=200)
-
- @fast_api_app.post("/init")
- async def init_callback(
- background_tasks: BackgroundTasks,
- nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
- ):
- background_tasks.add_task(
- __fetch_models_task,
- nc,
- init_handler,
- models_to_fetch if models_to_fetch else [],
- models_download_params if models_download_params else {},
- )
- return responses.JSONResponse(content={}, status_code=200)
+ @fast_api_app.put("/enabled")
+ async def enabled_callback(enabled: bool, nc: typing.Annotated[AsyncNextcloudApp, Depends(anc_app)]):
+ return responses.JSONResponse(content={"error": await enabled_handler(enabled, nc)}, status_code=200)
+
+ else:
+
+ @fast_api_app.put("/enabled")
+ def enabled_callback(enabled: bool, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]):
+ return responses.JSONResponse(content={"error": enabled_handler(enabled, nc)}, status_code=200)
+
+ if heartbeat_handler is None:
+
+ @fast_api_app.get("/heartbeat")
+ async def heartbeat_callback():
+ return responses.JSONResponse(content={"status": "ok"}, status_code=200)
+
+ elif asyncio.iscoroutinefunction(heartbeat_handler):
+
+ @fast_api_app.get("/heartbeat")
+ async def heartbeat_callback():
+ return responses.JSONResponse(content={"status": await heartbeat_handler()}, status_code=200)
+
+ else:
+
+ @fast_api_app.get("/heartbeat")
+ def heartbeat_callback():
+ return responses.JSONResponse(content={"status": heartbeat_handler()}, status_code=200)
+
+ if init_handler is None:
+
+ @fast_api_app.post("/init")
+ async def init_callback(
+ background_tasks: BackgroundTasks,
+ nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
+ ):
+ background_tasks.add_task(
+ __fetch_models_task,
+ nc,
+ models_to_fetch if models_to_fetch else [],
+ models_download_params if models_download_params else {},
+ )
+ return responses.JSONResponse(content={}, status_code=200)
+
+ elif asyncio.iscoroutinefunction(init_handler):
+
+ @fast_api_app.post("/init")
+ async def init_callback(
+ background_tasks: BackgroundTasks,
+ nc: typing.Annotated[AsyncNextcloudApp, Depends(anc_app)],
+ ):
+ background_tasks.add_task(init_handler, nc)
+ return responses.JSONResponse(content={}, status_code=200)
+
+ else:
+
+ @fast_api_app.post("/init")
+ def init_callback(
+ background_tasks: BackgroundTasks,
+ nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
+ ):
+ background_tasks.add_task(init_handler, nc)
+ return responses.JSONResponse(content={}, status_code=200)
if map_app_static:
__map_app_static_folders(fast_api_app)
@@ -133,7 +181,6 @@ def __map_app_static_folders(fast_api_app: FastAPI):
def __fetch_models_task(
nc: NextcloudApp,
- init_handler: typing.Callable[[NextcloudApp], None] | None,
models: list[str],
params: dict[str, typing.Any],
) -> None:
@@ -143,8 +190,7 @@ def __fetch_models_task(
class TqdmProgress(tqdm):
def display(self, msg=None, pos=None):
- if init_handler is None:
- nc.set_init_status(min(int((self.n * 100 / self.total) / len(models)), 100))
+ nc.set_init_status(min(int((self.n * 100 / self.total) / len(models)), 100))
return super().display(msg, pos)
if "max_workers" not in params:
@@ -153,7 +199,4 @@ def display(self, msg=None, pos=None):
params["cache_dir"] = persistent_storage()
for model in models:
snapshot_download(model, tqdm_class=TqdmProgress, **params) # noqa
- if init_handler is None:
- nc.set_init_status(100)
- else:
- init_handler(nc)
+ nc.set_init_status(100)
diff --git a/nc_py_api/ex_app/ui/files_actions.py b/nc_py_api/ex_app/ui/files_actions.py
index fa9f4fb5..4b8b8c9d 100644
--- a/nc_py_api/ex_app/ui/files_actions.py
+++ b/nc_py_api/ex_app/ui/files_actions.py
@@ -8,7 +8,7 @@
from ..._exceptions import NextcloudExceptionNotFound
from ..._misc import require_capabilities
-from ..._session import NcSessionApp
+from ..._session import AsyncNcSessionApp, NcSessionApp
from ...files import FsNode, permissions_to_str
@@ -158,3 +158,45 @@ def get_entry(self, name: str) -> UiFileActionEntry | None:
)
except NextcloudExceptionNotFound:
return None
+
+
+class _AsyncUiFilesActionsAPI:
+ """Async API for the drop-down menu in Nextcloud **Files app**."""
+
+ _ep_suffix: str = "ui/files-actions-menu"
+
+ def __init__(self, session: AsyncNcSessionApp):
+ self._session = session
+
+ async def register(self, name: str, display_name: str, callback_url: str, **kwargs) -> None:
+ """Registers the files a dropdown menu element."""
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {
+ "name": name,
+ "displayName": display_name,
+ "actionHandler": callback_url,
+ "icon": kwargs.get("icon", ""),
+ "mime": kwargs.get("mime", "file"),
+ "permissions": kwargs.get("permissions", 31),
+ "order": kwargs.get("order", 0),
+ }
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params)
+
+ async def unregister(self, name: str, not_fail=True) -> None:
+ """Removes files dropdown menu element."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", json={"name": name})
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+ async def get_entry(self, name: str) -> UiFileActionEntry | None:
+ """Get information of the file action meny entry for current app."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ return UiFileActionEntry(
+ await self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name})
+ )
+ except NextcloudExceptionNotFound:
+ return None
diff --git a/nc_py_api/ex_app/ui/resources.py b/nc_py_api/ex_app/ui/resources.py
index 82c3bc42..53f7e27e 100644
--- a/nc_py_api/ex_app/ui/resources.py
+++ b/nc_py_api/ex_app/ui/resources.py
@@ -4,7 +4,7 @@
from ..._exceptions import NextcloudExceptionNotFound
from ..._misc import require_capabilities
-from ..._session import NcSessionApp
+from ..._session import AsyncNcSessionApp, NcSessionApp
@dataclasses.dataclass
@@ -198,3 +198,127 @@ def get_style(self, ui_type: str, name: str, path: str) -> UiStyle | None:
)
except NextcloudExceptionNotFound:
return None
+
+
+class _AsyncUiResources:
+ """Async API for adding scripts, styles, initial-states to the TopMenu pages."""
+
+ _ep_suffix_init_state: str = "ui/initial-state"
+ _ep_suffix_js: str = "ui/script"
+ _ep_suffix_css: str = "ui/style"
+
+ def __init__(self, session: AsyncNcSessionApp):
+ self._session = session
+
+ async def set_initial_state(self, ui_type: str, name: str, key: str, value: dict | list) -> None:
+ """Add or update initial state for the page(template)."""
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {
+ "type": ui_type,
+ "name": name,
+ "key": key,
+ "value": value,
+ }
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_init_state}", json=params)
+
+ async def delete_initial_state(self, ui_type: str, name: str, key: str, not_fail=True) -> None:
+ """Removes initial state for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs(
+ "DELETE",
+ f"{self._session.ae_url}/{self._ep_suffix_init_state}",
+ params={"type": ui_type, "name": name, "key": key},
+ )
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+ async def get_initial_state(self, ui_type: str, name: str, key: str) -> UiInitState | None:
+ """Get information about initial state for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ return UiInitState(
+ await self._session.ocs(
+ "GET",
+ f"{self._session.ae_url}/{self._ep_suffix_init_state}",
+ params={"type": ui_type, "name": name, "key": key},
+ )
+ )
+ except NextcloudExceptionNotFound:
+ return None
+
+ async def set_script(self, ui_type: str, name: str, path: str, after_app_id: str = "") -> None:
+ """Add or update script for the page(template)."""
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {
+ "type": ui_type,
+ "name": name,
+ "path": path,
+ "afterAppId": after_app_id,
+ }
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_js}", json=params)
+
+ async def delete_script(self, ui_type: str, name: str, path: str, not_fail=True) -> None:
+ """Removes script for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs(
+ "DELETE",
+ f"{self._session.ae_url}/{self._ep_suffix_js}",
+ params={"type": ui_type, "name": name, "path": path},
+ )
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+ async def get_script(self, ui_type: str, name: str, path: str) -> UiScript | None:
+ """Get information about script for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ return UiScript(
+ await self._session.ocs(
+ "GET",
+ f"{self._session.ae_url}/{self._ep_suffix_js}",
+ params={"type": ui_type, "name": name, "path": path},
+ )
+ )
+ except NextcloudExceptionNotFound:
+ return None
+
+ async def set_style(self, ui_type: str, name: str, path: str) -> None:
+ """Add or update style(css) for the page(template)."""
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {
+ "type": ui_type,
+ "name": name,
+ "path": path,
+ }
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_css}", json=params)
+
+ async def delete_style(self, ui_type: str, name: str, path: str, not_fail=True) -> None:
+ """Removes style(css) for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs(
+ "DELETE",
+ f"{self._session.ae_url}/{self._ep_suffix_css}",
+ params={"type": ui_type, "name": name, "path": path},
+ )
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+ async def get_style(self, ui_type: str, name: str, path: str) -> UiStyle | None:
+ """Get information about style(css) for the page(template) by object name."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ return UiStyle(
+ await self._session.ocs(
+ "GET",
+ f"{self._session.ae_url}/{self._ep_suffix_css}",
+ params={"type": ui_type, "name": name, "path": path},
+ )
+ )
+ except NextcloudExceptionNotFound:
+ return None
diff --git a/nc_py_api/ex_app/ui/top_menu.py b/nc_py_api/ex_app/ui/top_menu.py
index 13d410eb..7fd54e07 100644
--- a/nc_py_api/ex_app/ui/top_menu.py
+++ b/nc_py_api/ex_app/ui/top_menu.py
@@ -4,7 +4,7 @@
from ..._exceptions import NextcloudExceptionNotFound
from ..._misc import require_capabilities
-from ..._session import NcSessionApp
+from ..._session import AsyncNcSessionApp, NcSessionApp
@dataclasses.dataclass
@@ -86,3 +86,48 @@ def get_entry(self, name: str) -> UiTopMenuEntry | None:
)
except NextcloudExceptionNotFound:
return None
+
+
+class _AsyncUiTopMenuAPI:
+ """Async API for the top menu app nav bar in Nextcloud."""
+
+ _ep_suffix: str = "ui/top-menu"
+
+ def __init__(self, session: AsyncNcSessionApp):
+ self._session = session
+
+ async def register(self, name: str, display_name: str, icon: str = "", admin_required=False) -> None:
+ """Registers or edit the App entry in Top Meny.
+
+ :param name: Unique name for the menu entry.
+ :param display_name: Display name of the menu entry.
+ :param icon: Optional, url relative to the ExApp, like: "img/icon.svg"
+ :param admin_required: Boolean value indicating should be Entry visible to all or only to admins.
+ """
+ require_capabilities("app_api", await self._session.capabilities)
+ params = {
+ "name": name,
+ "displayName": display_name,
+ "icon": icon,
+ "adminRequired": int(admin_required),
+ }
+ await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params)
+
+ async def unregister(self, name: str, not_fail=True) -> None:
+ """Removes App entry in Top Menu."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name})
+ except NextcloudExceptionNotFound as e:
+ if not not_fail:
+ raise e from None
+
+ async def get_entry(self, name: str) -> UiTopMenuEntry | None:
+ """Get information of the top meny entry for current app."""
+ require_capabilities("app_api", await self._session.capabilities)
+ try:
+ return UiTopMenuEntry(
+ await self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name})
+ )
+ except NextcloudExceptionNotFound:
+ return None
diff --git a/nc_py_api/ex_app/ui/ui.py b/nc_py_api/ex_app/ui/ui.py
index 84e305c2..ae0099b6 100644
--- a/nc_py_api/ex_app/ui/ui.py
+++ b/nc_py_api/ex_app/ui/ui.py
@@ -1,14 +1,11 @@
"""Nextcloud API for User Interface."""
-from dataclasses import dataclass
+from ..._session import AsyncNcSessionApp, NcSessionApp
+from .files_actions import _AsyncUiFilesActionsAPI, _UiFilesActionsAPI
+from .resources import _AsyncUiResources, _UiResources
+from .top_menu import _AsyncUiTopMenuAPI, _UiTopMenuAPI
-from ..._session import NcSessionApp
-from .files_actions import _UiFilesActionsAPI
-from .resources import _UiResources
-from .top_menu import _UiTopMenuAPI
-
-@dataclass
class UiApi:
"""Class that encapsulates all UI functionality."""
@@ -23,3 +20,19 @@ def __init__(self, session: NcSessionApp):
self.files_dropdown_menu = _UiFilesActionsAPI(session)
self.top_menu = _UiTopMenuAPI(session)
self.resources = _UiResources(session)
+
+
+class AsyncUiApi:
+ """Class that encapsulates all UI functionality(async)."""
+
+ files_dropdown_menu: _AsyncUiFilesActionsAPI
+ """File dropdown menu API."""
+ top_menu: _AsyncUiTopMenuAPI
+ """Top App menu API."""
+ resources: _AsyncUiResources
+ """Page(Template) resources API."""
+
+ def __init__(self, session: AsyncNcSessionApp):
+ self.files_dropdown_menu = _AsyncUiFilesActionsAPI(session)
+ self.top_menu = _AsyncUiTopMenuAPI(session)
+ self.resources = _AsyncUiResources(session)
diff --git a/nc_py_api/files/_files.py b/nc_py_api/files/_files.py
new file mode 100644
index 00000000..8863c1fe
--- /dev/null
+++ b/nc_py_api/files/_files.py
@@ -0,0 +1,319 @@
+"""Helper functions for **FilesAPI** and **AsyncFilesAPI** classes."""
+
+import enum
+from io import BytesIO
+from json import dumps, loads
+from urllib.parse import unquote
+from xml.etree import ElementTree
+
+import xmltodict
+from httpx import Response
+
+from .._exceptions import NextcloudException, check_error
+from .._misc import clear_from_params_empty
+from . import FsNode, SystemTag
+
+PROPFIND_PROPERTIES = [
+ "d:resourcetype",
+ "d:getlastmodified",
+ "d:getcontentlength",
+ "d:getcontenttype",
+ "d:getetag",
+ "oc:size",
+ "oc:id",
+ "oc:fileid",
+ "oc:downloadURL",
+ "oc:dDC",
+ "oc:permissions",
+ "oc:checksums",
+ "oc:share-types",
+ "oc:favorite",
+ "nc:is-encrypted",
+ "nc:lock",
+ "nc:lock-owner-displayname",
+ "nc:lock-owner",
+ "nc:lock-owner-type",
+ "nc:lock-owner-editor",
+ "nc:lock-time",
+ "nc:lock-timeout",
+]
+
+SEARCH_PROPERTIES_MAP = {
+ "name": "d:displayname", # like, eq
+ "mime": "d:getcontenttype", # like, eq
+ "last_modified": "d:getlastmodified", # gt, eq, lt
+ "size": "oc:size", # gt, gte, eq, lt
+ "favorite": "oc:favorite", # eq
+ "fileid": "oc:fileid", # eq
+}
+
+
+class PropFindType(enum.IntEnum):
+ """Internal enum types for ``_listdir`` and ``_lf_parse_webdav_records`` methods."""
+
+ DEFAULT = 0
+ TRASHBIN = 1
+ VERSIONS_FILEID = 2
+ VERSIONS_FILE_ID = 3
+
+
+def build_find_request(req: list, path: str | FsNode, user: str) -> ElementTree.Element:
+ path = path.user_path if isinstance(path, FsNode) else path
+ root = ElementTree.Element(
+ "d:searchrequest",
+ attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
+ )
+ xml_search = ElementTree.SubElement(root, "d:basicsearch")
+ xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop")
+ for i in PROPFIND_PROPERTIES:
+ ElementTree.SubElement(xml_select_prop, i)
+ xml_from_scope = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:from"), "d:scope")
+ href = f"/files/{user}/{path.removeprefix('/')}"
+ ElementTree.SubElement(xml_from_scope, "d:href").text = href
+ ElementTree.SubElement(xml_from_scope, "d:depth").text = "infinity"
+ xml_where = ElementTree.SubElement(xml_search, "d:where")
+ build_search_req(xml_where, req)
+ return root
+
+
+def build_list_by_criteria_req(properties: list[str] | None, tags: list[int | SystemTag] | None) -> ElementTree.Element:
+ if not properties and not tags:
+ raise ValueError("Either specify 'properties' or 'tags' to filter results.")
+ root = ElementTree.Element(
+ "oc:filter-files",
+ attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
+ )
+ prop = ElementTree.SubElement(root, "d:prop")
+ for i in PROPFIND_PROPERTIES:
+ ElementTree.SubElement(prop, i)
+ xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
+ if properties and "favorite" in properties:
+ ElementTree.SubElement(xml_filter_rules, "oc:favorite").text = "1"
+ if tags:
+ for v in tags:
+ tag_id = v.tag_id if isinstance(v, SystemTag) else v
+ ElementTree.SubElement(xml_filter_rules, "oc:systemtag").text = str(tag_id)
+ return root
+
+
+def build_search_req(xml_element_where, req: list) -> None:
+ def _process_or_and(xml_element, or_and: str):
+ _where_part_root = ElementTree.SubElement(xml_element, f"d:{or_and}")
+ _add_value(_where_part_root)
+ _add_value(_where_part_root)
+
+ def _add_value(xml_element, val=None) -> None:
+ first_val = req.pop(0) if val is None else val
+ if first_val in ("or", "and"):
+ _process_or_and(xml_element, first_val)
+ return
+ _root = ElementTree.SubElement(xml_element, f"d:{first_val}")
+ _ = ElementTree.SubElement(_root, "d:prop")
+ ElementTree.SubElement(_, SEARCH_PROPERTIES_MAP[req.pop(0)])
+ _ = ElementTree.SubElement(_root, "d:literal")
+ value = req.pop(0)
+ _.text = value if isinstance(value, str) else str(value)
+
+ while len(req):
+ where_part = req.pop(0)
+ if where_part in ("or", "and"):
+ _process_or_and(xml_element_where, where_part)
+ else:
+ _add_value(xml_element_where, where_part)
+
+
+def build_setfav_req(value: int | bool) -> ElementTree.Element:
+ root = ElementTree.Element(
+ "d:propertyupdate",
+ attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
+ )
+ xml_set = ElementTree.SubElement(root, "d:set")
+ xml_set_prop = ElementTree.SubElement(xml_set, "d:prop")
+ ElementTree.SubElement(xml_set_prop, "oc:favorite").text = str(int(bool(value)))
+ return root
+
+
+def build_list_tag_req() -> ElementTree.Element:
+ root = ElementTree.Element(
+ "d:propfind",
+ attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
+ )
+ properties = ["oc:id", "oc:display-name", "oc:user-visible", "oc:user-assignable"]
+ prop_element = ElementTree.SubElement(root, "d:prop")
+ for i in properties:
+ ElementTree.SubElement(prop_element, i)
+ return root
+
+
+def build_list_tags_response(response: Response) -> list[SystemTag]:
+ result = []
+ records = _webdav_response_to_records(response, "list_tags")
+ for record in records:
+ prop_stat = record["d:propstat"]
+ if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
+ continue
+ result.append(SystemTag(prop_stat["d:prop"]))
+ return result
+
+
+def build_update_tag_req(
+ name: str | None, user_visible: bool | None, user_assignable: bool | None
+) -> ElementTree.Element:
+ root = ElementTree.Element(
+ "d:propertyupdate",
+ attrib={
+ "xmlns:d": "DAV:",
+ "xmlns:oc": "http://owncloud.org/ns",
+ },
+ )
+ properties = {
+ "oc:display-name": name,
+ "oc:user-visible": "true" if user_visible is True else "false" if user_visible is False else None,
+ "oc:user-assignable": "true" if user_assignable is True else "false" if user_assignable is False else None,
+ }
+ clear_from_params_empty(list(properties.keys()), properties)
+ if not properties:
+ raise ValueError("No property specified to change.")
+ xml_set = ElementTree.SubElement(root, "d:set")
+ prop_element = ElementTree.SubElement(xml_set, "d:prop")
+ for k, v in properties.items():
+ ElementTree.SubElement(prop_element, k).text = v
+ return root
+
+
+def build_listdir_req(
+ user: str, path: str, properties: list[str], prop_type: PropFindType
+) -> tuple[ElementTree.Element, str]:
+ root = ElementTree.Element(
+ "d:propfind",
+ attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
+ )
+ prop = ElementTree.SubElement(root, "d:prop")
+ for i in properties:
+ ElementTree.SubElement(prop, i)
+ if prop_type in (PropFindType.VERSIONS_FILEID, PropFindType.VERSIONS_FILE_ID):
+ dav_path = dav_get_obj_path(f"versions/{user}/versions", path, root_path="")
+ elif prop_type == PropFindType.TRASHBIN:
+ dav_path = dav_get_obj_path(f"trashbin/{user}/trash", path, root_path="")
+ else:
+ dav_path = dav_get_obj_path(user, path)
+ return root, dav_path
+
+
+def build_listdir_response(
+ dav_url_suffix: str,
+ webdav_response: Response,
+ user: str,
+ path: str,
+ properties: list[str],
+ exclude_self: bool,
+ prop_type: PropFindType,
+) -> list[FsNode]:
+ result = lf_parse_webdav_response(
+ dav_url_suffix,
+ webdav_response,
+ f"list: {user}, {path}, {properties}",
+ prop_type,
+ )
+ if exclude_self:
+ for index, v in enumerate(result):
+ if v.user_path.rstrip("/") == path.strip("/"):
+ del result[index]
+ break
+ return result
+
+
+def element_tree_as_str(element) -> str:
+ with BytesIO() as buffer:
+ ElementTree.ElementTree(element).write(buffer, xml_declaration=True)
+ buffer.seek(0)
+ return buffer.read().decode("utf-8")
+
+
+def dav_get_obj_path(user: str, path: str = "", root_path="/files") -> str:
+ obj_dav_path = root_path
+ if user:
+ obj_dav_path += "/" + user
+ if path:
+ obj_dav_path += "/" + path.lstrip("/")
+ return obj_dav_path
+
+
+def etag_fileid_from_response(response: Response) -> dict:
+ return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]}
+
+
+def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
+ fs_node_args = {}
+ for prop_stat in prop_stats:
+ if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
+ continue
+ prop: dict = prop_stat["d:prop"]
+ prop_keys = prop.keys()
+ if "oc:id" in prop_keys:
+ fs_node_args["file_id"] = prop["oc:id"]
+ if "oc:fileid" in prop_keys:
+ fs_node_args["fileid"] = int(prop["oc:fileid"])
+ if "oc:size" in prop_keys:
+ fs_node_args["size"] = int(prop["oc:size"])
+ if "d:getcontentlength" in prop_keys:
+ fs_node_args["content_length"] = int(prop["d:getcontentlength"])
+ if "d:getetag" in prop_keys:
+ fs_node_args["etag"] = prop["d:getetag"]
+ if "d:getlastmodified" in prop_keys:
+ fs_node_args["last_modified"] = prop["d:getlastmodified"]
+ if "d:getcontenttype" in prop_keys:
+ fs_node_args["mimetype"] = prop["d:getcontenttype"]
+ if "oc:permissions" in prop_keys:
+ fs_node_args["permissions"] = prop["oc:permissions"]
+ if "oc:favorite" in prop_keys:
+ fs_node_args["favorite"] = bool(int(prop["oc:favorite"]))
+ if "nc:trashbin-filename" in prop_keys:
+ fs_node_args["trashbin_filename"] = prop["nc:trashbin-filename"]
+ if "nc:trashbin-original-location" in prop_keys:
+ fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"]
+ if "nc:trashbin-deletion-time" in prop_keys:
+ fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"]
+ # xz = prop.get("oc:dDC", "")
+ return FsNode(full_path, **fs_node_args)
+
+
+def _parse_records(dav_url_suffix: str, fs_records: list[dict], response_type: PropFindType) -> list[FsNode]:
+ result: list[FsNode] = []
+ for record in fs_records:
+ obj_full_path = unquote(record.get("d:href", ""))
+ obj_full_path = obj_full_path.replace(dav_url_suffix, "").lstrip("/")
+ propstat = record["d:propstat"]
+ fs_node = _parse_record(obj_full_path, propstat if isinstance(propstat, list) else [propstat])
+ if fs_node.etag and response_type in (
+ PropFindType.VERSIONS_FILE_ID,
+ PropFindType.VERSIONS_FILEID,
+ ):
+ fs_node.full_path = fs_node.full_path.rstrip("/")
+ fs_node.info.is_version = True
+ if response_type == PropFindType.VERSIONS_FILEID:
+ fs_node.info.fileid = int(fs_node.full_path.rsplit("/", 2)[-2])
+ fs_node.file_id = str(fs_node.info.fileid)
+ else:
+ fs_node.file_id = fs_node.full_path.rsplit("/", 2)[-2]
+ if fs_node.file_id:
+ result.append(fs_node)
+ return result
+
+
+def lf_parse_webdav_response(
+ dav_url_suffix: str, webdav_res: Response, info: str, response_type: PropFindType = PropFindType.DEFAULT
+) -> list[FsNode]:
+ return _parse_records(dav_url_suffix, _webdav_response_to_records(webdav_res, info), response_type)
+
+
+def _webdav_response_to_records(webdav_res: Response, info: str) -> list[dict]:
+ check_error(webdav_res, info=info)
+ if webdav_res.status_code != 207: # multistatus
+ raise NextcloudException(webdav_res.status_code, "Response is not a multistatus.", info=info)
+ response_data = loads(dumps(xmltodict.parse(webdav_res.text)))
+ if "d:error" in response_data:
+ err = response_data["d:error"]
+ raise NextcloudException(reason=f'{err["s:exception"]}: {err["s:message"]}'.replace("\n", ""), info=info)
+ response = response_data["d:multistatus"].get("d:response", [])
+ return [response] if isinstance(response, dict) else response
diff --git a/nc_py_api/files/files.py b/nc_py_api/files/files.py
index 1217b849..2aae929d 100644
--- a/nc_py_api/files/files.py
+++ b/nc_py_api/files/files.py
@@ -1,69 +1,36 @@
"""Nextcloud API for working with the file system."""
import builtins
-import enum
import os
-from io import BytesIO
-from json import dumps, loads
from pathlib import Path
-from urllib.parse import unquote
-from xml.etree import ElementTree
-import xmltodict
-from httpx import Headers, Response
+from httpx import Headers
from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error
-from .._misc import clear_from_params_empty, random_string, require_capabilities
-from .._session import NcSessionBasic
+from .._misc import random_string, require_capabilities
+from .._session import AsyncNcSessionBasic, NcSessionBasic
from . import FsNode, SystemTag
-from .sharing import _FilesSharingAPI
-
-PROPFIND_PROPERTIES = [
- "d:resourcetype",
- "d:getlastmodified",
- "d:getcontentlength",
- "d:getcontenttype",
- "d:getetag",
- "oc:size",
- "oc:id",
- "oc:fileid",
- "oc:downloadURL",
- "oc:dDC",
- "oc:permissions",
- "oc:checksums",
- "oc:share-types",
- "oc:favorite",
- "nc:is-encrypted",
- "nc:lock",
- "nc:lock-owner-displayname",
- "nc:lock-owner",
- "nc:lock-owner-type",
- "nc:lock-owner-editor",
- "nc:lock-time",
- "nc:lock-timeout",
-]
-
-SEARCH_PROPERTIES_MAP = {
- "name": "d:displayname", # like, eq
- "mime": "d:getcontenttype", # like, eq
- "last_modified": "d:getlastmodified", # gt, eq, lt
- "size": "oc:size", # gt, gte, eq, lt
- "favorite": "oc:favorite", # eq
- "fileid": "oc:fileid", # eq
-}
-
-
-class PropFindType(enum.IntEnum):
- """Internal enum types for ``_listdir`` and ``_lf_parse_webdav_records`` methods."""
-
- DEFAULT = 0
- TRASHBIN = 1
- VERSIONS_FILEID = 2
- VERSIONS_FILE_ID = 3
+from ._files import (
+ PROPFIND_PROPERTIES,
+ PropFindType,
+ build_find_request,
+ build_list_by_criteria_req,
+ build_list_tag_req,
+ build_list_tags_response,
+ build_listdir_req,
+ build_listdir_response,
+ build_setfav_req,
+ build_update_tag_req,
+ dav_get_obj_path,
+ element_tree_as_str,
+ etag_fileid_from_response,
+ lf_parse_webdav_response,
+)
+from .sharing import _AsyncFilesSharingAPI, _FilesSharingAPI
class FilesAPI:
- """Class that encapsulates the file system and file sharing functionality."""
+ """Class that encapsulates file system and file sharing API."""
sharing: _FilesSharingAPI
"""API for managing Files Shares"""
@@ -108,36 +75,17 @@ def find(self, req: list, path: str | FsNode = "") -> list[FsNode]:
:param path: path where to search from. Default = **""**.
"""
# `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
- path = path.user_path if isinstance(path, FsNode) else path
- root = ElementTree.Element(
- "d:searchrequest",
- attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
- )
- xml_search = ElementTree.SubElement(root, "d:basicsearch")
- xml_select_prop = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:select"), "d:prop")
- for i in PROPFIND_PROPERTIES:
- ElementTree.SubElement(xml_select_prop, i)
- xml_from_scope = ElementTree.SubElement(ElementTree.SubElement(xml_search, "d:from"), "d:scope")
- href = f"/files/{self._session.user}/{path.removeprefix('/')}"
- ElementTree.SubElement(xml_from_scope, "d:href").text = href
- ElementTree.SubElement(xml_from_scope, "d:depth").text = "infinity"
- xml_where = ElementTree.SubElement(xml_search, "d:where")
- self._build_search_req(xml_where, req)
-
- headers = {"Content-Type": "text/xml"}
+ root = build_find_request(req, path, self._session.user)
webdav_response = self._session.adapter_dav.request(
- "SEARCH", "", content=self._element_tree_as_str(root), headers=headers
+ "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
)
request_info = f"find: {self._session.user}, {req}, {path}"
- return self._lf_parse_webdav_response(webdav_response, request_info)
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
def download(self, path: str | FsNode) -> bytes:
- """Downloads and returns the content of a file.
-
- :param path: path to download file.
- """
+ """Downloads and returns the content of a file."""
path = path.user_path if isinstance(path, FsNode) else path
- response = self._session.adapter_dav.get(self._dav_get_obj_path(self._session.user, path))
+ response = self._session.adapter_dav.get(dav_get_obj_path(self._session.user, path))
check_error(response, f"download: user={self._session.user}, path={path}")
return response.content
@@ -188,10 +136,10 @@ def upload(self, path: str | FsNode, content: bytes | str) -> FsNode:
:param content: content to create the file. If it is a string, it will be encoded into bytes using UTF-8.
"""
path = path.user_path if isinstance(path, FsNode) else path
- full_path = self._dav_get_obj_path(self._session.user, path)
+ full_path = dav_get_obj_path(self._session.user, path)
response = self._session.adapter_dav.put(full_path, content=content)
check_error(response, f"upload: user={self._session.user}, path={path}, size={len(content)}")
- return FsNode(full_path.strip("/"), **self.__get_etag_fileid_from_response(response))
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
def upload_stream(self, path: str | FsNode, fp, **kwargs) -> FsNode:
"""Creates a file with content provided by `fp` object at the specified path.
@@ -217,11 +165,11 @@ def mkdir(self, path: str | FsNode) -> FsNode:
:param path: path of the directory to be created.
"""
path = path.user_path if isinstance(path, FsNode) else path
- full_path = self._dav_get_obj_path(self._session.user, path)
+ full_path = dav_get_obj_path(self._session.user, path)
response = self._session.adapter_dav.request("MKCOL", full_path)
check_error(response)
full_path += "/" if not full_path.endswith("/") else ""
- return FsNode(full_path.lstrip("/"), **self.__get_etag_fileid_from_response(response))
+ return FsNode(full_path.lstrip("/"), **etag_fileid_from_response(response))
def makedirs(self, path: str | FsNode, exist_ok=False) -> FsNode | None:
"""Creates a new directory and subdirectories.
@@ -253,7 +201,7 @@ def delete(self, path: str | FsNode, not_fail=False) -> None:
:param not_fail: if set to ``True`` and the object is not found, it does not raise an exception.
"""
path = path.user_path if isinstance(path, FsNode) else path
- response = self._session.adapter_dav.delete(self._dav_get_obj_path(self._session.user, path))
+ response = self._session.adapter_dav.delete(dav_get_obj_path(self._session.user, path))
if response.status_code == 404 and not_fail:
return
check_error(response)
@@ -267,14 +215,14 @@ def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False)
Default = **False**.
"""
path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
- full_dest_path = self._dav_get_obj_path(
+ full_dest_path = dav_get_obj_path(
self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
)
dest = self._session.cfg.dav_endpoint + full_dest_path
headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
response = self._session.adapter_dav.request(
"MOVE",
- self._dav_get_obj_path(self._session.user, path_src),
+ dav_get_obj_path(self._session.user, path_src),
headers=headers,
)
check_error(response, f"move: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}")
@@ -289,14 +237,14 @@ def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False)
Default = **False**.
"""
path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
- full_dest_path = self._dav_get_obj_path(
+ full_dest_path = dav_get_obj_path(
self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
)
dest = self._session.cfg.dav_endpoint + full_dest_path
headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
response = self._session.adapter_dav.request(
"COPY",
- self._dav_get_obj_path(self._session.user, path_src),
+ dav_get_obj_path(self._session.user, path_src),
headers=headers,
)
check_error(response, f"copy: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}")
@@ -311,28 +259,13 @@ def list_by_criteria(
Supported values: **favorite**
:param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
"""
- if not properties and not tags:
- raise ValueError("Either specify 'properties' or 'tags' to filter results.")
- root = ElementTree.Element(
- "oc:filter-files",
- attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
- )
- prop = ElementTree.SubElement(root, "d:prop")
- for i in PROPFIND_PROPERTIES:
- ElementTree.SubElement(prop, i)
- xml_filter_rules = ElementTree.SubElement(root, "oc:filter-rules")
- if properties and "favorite" in properties:
- ElementTree.SubElement(xml_filter_rules, "oc:favorite").text = "1"
- if tags:
- for v in tags:
- tag_id = v.tag_id if isinstance(v, SystemTag) else v
- ElementTree.SubElement(xml_filter_rules, "oc:systemtag").text = str(tag_id)
+ root = build_list_by_criteria_req(properties, tags)
webdav_response = self._session.adapter_dav.request(
- "REPORT", self._dav_get_obj_path(self._session.user), content=self._element_tree_as_str(root)
+ "REPORT", dav_get_obj_path(self._session.user), content=element_tree_as_str(root)
)
request_info = f"list_files_by_criteria: {self._session.user}"
check_error(webdav_response, request_info)
- return self._lf_parse_webdav_response(webdav_response, request_info)
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
def setfav(self, path: str | FsNode, value: int | bool) -> None:
"""Sets or unsets favourite flag for specific file.
@@ -341,15 +274,9 @@ def setfav(self, path: str | FsNode, value: int | bool) -> None:
:param value: value to set for the ``favourite`` state.
"""
path = path.user_path if isinstance(path, FsNode) else path
- root = ElementTree.Element(
- "d:propertyupdate",
- attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
- )
- xml_set = ElementTree.SubElement(root, "d:set")
- xml_set_prop = ElementTree.SubElement(xml_set, "d:prop")
- ElementTree.SubElement(xml_set_prop, "oc:favorite").text = str(int(bool(value)))
+ root = build_setfav_req(value)
webdav_response = self._session.adapter_dav.request(
- "PROPPATCH", self._dav_get_obj_path(self._session.user, path), content=self._element_tree_as_str(root)
+ "PROPPATCH", dav_get_obj_path(self._session.user, path), content=element_tree_as_str(root)
)
check_error(webdav_response, f"setfav: path={path}, value={value}")
@@ -423,23 +350,9 @@ def restore_version(self, file_object: FsNode) -> None:
def list_tags(self) -> list[SystemTag]:
"""Returns list of the avalaible Tags."""
- root = ElementTree.Element(
- "d:propfind",
- attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns"},
- )
- properties = ["oc:id", "oc:display-name", "oc:user-visible", "oc:user-assignable"]
- prop_element = ElementTree.SubElement(root, "d:prop")
- for i in properties:
- ElementTree.SubElement(prop_element, i)
- response = self._session.adapter_dav.request("PROPFIND", "/systemtags", content=self._element_tree_as_str(root))
- result = []
- records = self._webdav_response_to_records(response, "list_tags")
- for record in records:
- prop_stat = record["d:propstat"]
- if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
- continue
- result.append(SystemTag(prop_stat["d:prop"]))
- return result
+ root = build_list_tag_req()
+ response = self._session.adapter_dav.request("PROPFIND", "/systemtags", content=element_tree_as_str(root))
+ return build_list_tags_response(response)
def create_tag(self, name: str, user_visible: bool = True, user_assignable: bool = True) -> None:
"""Creates a new Tag.
@@ -467,27 +380,9 @@ def update_tag(
) -> None:
"""Updates the Tag information."""
tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
- root = ElementTree.Element(
- "d:propertyupdate",
- attrib={
- "xmlns:d": "DAV:",
- "xmlns:oc": "http://owncloud.org/ns",
- },
- )
- properties = {
- "oc:display-name": name,
- "oc:user-visible": "true" if user_visible is True else "false" if user_visible is False else None,
- "oc:user-assignable": "true" if user_assignable is True else "false" if user_assignable is False else None,
- }
- clear_from_params_empty(list(properties.keys()), properties)
- if not properties:
- raise ValueError("No property specified to change.")
- xml_set = ElementTree.SubElement(root, "d:set")
- prop_element = ElementTree.SubElement(xml_set, "d:prop")
- for k, v in properties.items():
- ElementTree.SubElement(prop_element, k).text = v
+ root = build_update_tag_req(name, user_visible, user_assignable)
response = self._session.adapter_dav.request(
- "PROPPATCH", f"/systemtags/{tag_id}", content=self._element_tree_as_str(root)
+ "PROPPATCH", f"/systemtags/{tag_id}", content=element_tree_as_str(root)
)
check_error(response)
@@ -532,164 +427,27 @@ def _listdir(
exclude_self: bool,
prop_type: PropFindType = PropFindType.DEFAULT,
) -> list[FsNode]:
- root = ElementTree.Element(
- "d:propfind",
- attrib={"xmlns:d": "DAV:", "xmlns:oc": "http://owncloud.org/ns", "xmlns:nc": "http://nextcloud.org/ns"},
- )
- prop = ElementTree.SubElement(root, "d:prop")
- for i in properties:
- ElementTree.SubElement(prop, i)
- if prop_type in (PropFindType.VERSIONS_FILEID, PropFindType.VERSIONS_FILE_ID):
- dav_path = self._dav_get_obj_path(f"versions/{user}/versions", path, root_path="")
- elif prop_type == PropFindType.TRASHBIN:
- dav_path = self._dav_get_obj_path(f"trashbin/{user}/trash", path, root_path="")
- else:
- dav_path = self._dav_get_obj_path(user, path)
+ root, dav_path = build_listdir_req(user, path, properties, prop_type)
webdav_response = self._session.adapter_dav.request(
"PROPFIND",
dav_path,
- content=self._element_tree_as_str(root),
+ content=element_tree_as_str(root),
headers={"Depth": "infinity" if depth == -1 else str(depth)},
)
-
- result = self._lf_parse_webdav_response(
- webdav_response,
- f"list: {user}, {path}, {properties}",
- prop_type,
+ return build_listdir_response(
+ self._session.cfg.dav_url_suffix, webdav_response, user, path, properties, exclude_self, prop_type
)
- if exclude_self:
- for index, v in enumerate(result):
- if v.user_path.rstrip("/") == path.strip("/"):
- del result[index]
- break
- return result
-
- def _parse_records(self, fs_records: list[dict], response_type: PropFindType) -> list[FsNode]:
- result: list[FsNode] = []
- for record in fs_records:
- obj_full_path = unquote(record.get("d:href", ""))
- obj_full_path = obj_full_path.replace(self._session.cfg.dav_url_suffix, "").lstrip("/")
- propstat = record["d:propstat"]
- fs_node = self._parse_record(obj_full_path, propstat if isinstance(propstat, list) else [propstat])
- if fs_node.etag and response_type in (
- PropFindType.VERSIONS_FILE_ID,
- PropFindType.VERSIONS_FILEID,
- ):
- fs_node.full_path = fs_node.full_path.rstrip("/")
- fs_node.info.is_version = True
- if response_type == PropFindType.VERSIONS_FILEID:
- fs_node.info.fileid = int(fs_node.full_path.rsplit("/", 2)[-2])
- fs_node.file_id = str(fs_node.info.fileid)
- else:
- fs_node.file_id = fs_node.full_path.rsplit("/", 2)[-2]
- if fs_node.file_id:
- result.append(fs_node)
- return result
-
- @staticmethod
- def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode:
- fs_node_args = {}
- for prop_stat in prop_stats:
- if str(prop_stat.get("d:status", "")).find("200 OK") == -1:
- continue
- prop: dict = prop_stat["d:prop"]
- prop_keys = prop.keys()
- if "oc:id" in prop_keys:
- fs_node_args["file_id"] = prop["oc:id"]
- if "oc:fileid" in prop_keys:
- fs_node_args["fileid"] = int(prop["oc:fileid"])
- if "oc:size" in prop_keys:
- fs_node_args["size"] = int(prop["oc:size"])
- if "d:getcontentlength" in prop_keys:
- fs_node_args["content_length"] = int(prop["d:getcontentlength"])
- if "d:getetag" in prop_keys:
- fs_node_args["etag"] = prop["d:getetag"]
- if "d:getlastmodified" in prop_keys:
- fs_node_args["last_modified"] = prop["d:getlastmodified"]
- if "d:getcontenttype" in prop_keys:
- fs_node_args["mimetype"] = prop["d:getcontenttype"]
- if "oc:permissions" in prop_keys:
- fs_node_args["permissions"] = prop["oc:permissions"]
- if "oc:favorite" in prop_keys:
- fs_node_args["favorite"] = bool(int(prop["oc:favorite"]))
- if "nc:trashbin-filename" in prop_keys:
- fs_node_args["trashbin_filename"] = prop["nc:trashbin-filename"]
- if "nc:trashbin-original-location" in prop_keys:
- fs_node_args["trashbin_original_location"] = prop["nc:trashbin-original-location"]
- if "nc:trashbin-deletion-time" in prop_keys:
- fs_node_args["trashbin_deletion_time"] = prop["nc:trashbin-deletion-time"]
- # xz = prop.get("oc:dDC", "")
- return FsNode(full_path, **fs_node_args)
-
- def _lf_parse_webdav_response(
- self, webdav_res: Response, info: str, response_type: PropFindType = PropFindType.DEFAULT
- ) -> list[FsNode]:
- return self._parse_records(self._webdav_response_to_records(webdav_res, info), response_type)
-
- @staticmethod
- def _webdav_response_to_records(webdav_res: Response, info: str) -> list[dict]:
- check_error(webdav_res, info=info)
- if webdav_res.status_code != 207: # multistatus
- raise NextcloudException(webdav_res.status_code, "Response is not a multistatus.", info=info)
- response_data = loads(dumps(xmltodict.parse(webdav_res.text)))
- if "d:error" in response_data:
- err = response_data["d:error"]
- raise NextcloudException(reason=f'{err["s:exception"]}: {err["s:message"]}'.replace("\n", ""), info=info)
- response = response_data["d:multistatus"].get("d:response", [])
- return [response] if isinstance(response, dict) else response
-
- @staticmethod
- def _dav_get_obj_path(user: str, path: str = "", root_path="/files") -> str:
- obj_dav_path = root_path
- if user:
- obj_dav_path += "/" + user
- if path:
- obj_dav_path += "/" + path.lstrip("/")
- return obj_dav_path
-
- @staticmethod
- def _element_tree_as_str(element) -> str:
- with BytesIO() as buffer:
- ElementTree.ElementTree(element).write(buffer, xml_declaration=True)
- buffer.seek(0)
- return buffer.read().decode("utf-8")
-
- @staticmethod
- def _build_search_req(xml_element_where, req: list) -> None:
- def _process_or_and(xml_element, or_and: str):
- _where_part_root = ElementTree.SubElement(xml_element, f"d:{or_and}")
- _add_value(_where_part_root)
- _add_value(_where_part_root)
-
- def _add_value(xml_element, val=None) -> None:
- first_val = req.pop(0) if val is None else val
- if first_val in ("or", "and"):
- _process_or_and(xml_element, first_val)
- return
- _root = ElementTree.SubElement(xml_element, f"d:{first_val}")
- _ = ElementTree.SubElement(_root, "d:prop")
- ElementTree.SubElement(_, SEARCH_PROPERTIES_MAP[req.pop(0)])
- _ = ElementTree.SubElement(_root, "d:literal")
- value = req.pop(0)
- _.text = value if isinstance(value, str) else str(value)
-
- while len(req):
- where_part = req.pop(0)
- if where_part in ("or", "and"):
- _process_or_and(xml_element_where, where_part)
- else:
- _add_value(xml_element_where, where_part)
def __download2stream(self, path: str, fp, **kwargs) -> None:
- with self._session.adapter_dav.stream("GET", self._dav_get_obj_path(self._session.user, path)) as response:
+ with self._session.adapter_dav.stream("GET", dav_get_obj_path(self._session.user, path)) as response:
check_error(response, f"download_stream: user={self._session.user}, path={path}")
for data_chunk in response.iter_raw(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)):
fp.write(data_chunk)
def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode:
- _dav_path = self._dav_get_obj_path(self._session.user, "nc-py-api-" + random_string(56), root_path="/uploads")
+ _dav_path = dav_get_obj_path(self._session.user, "nc-py-api-" + random_string(56), root_path="/uploads")
_v2 = bool(self._session.cfg.options.upload_chunk_v2 and chunk_size >= 5 * 1024 * 1024)
- full_path = self._dav_get_obj_path(self._session.user, path)
+ full_path = dav_get_obj_path(self._session.user, path)
headers = Headers({"Destination": self._session.cfg.dav_endpoint + full_path}, encoding="utf-8")
if _v2:
response = self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers)
@@ -726,10 +484,477 @@ def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode:
response,
f"upload_stream(v={_v2}): user={self._session.user}, path={path}, total_size={end_bytes}",
)
- return FsNode(full_path.strip("/"), **self.__get_etag_fileid_from_response(response))
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
finally:
self._session.adapter_dav.delete(_dav_path)
- @staticmethod
- def __get_etag_fileid_from_response(response: Response) -> dict:
- return {"etag": response.headers.get("OC-Etag", ""), "file_id": response.headers["OC-FileId"]}
+
+class AsyncFilesAPI:
+ """Class that encapsulates async file system and file sharing API."""
+
+ sharing: _AsyncFilesSharingAPI
+ """API for managing Files Shares"""
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+ self.sharing = _AsyncFilesSharingAPI(session)
+
+ async def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=True) -> list[FsNode]:
+ """Returns a list of all entries in the specified directory.
+
+ :param path: path to the directory to get the list.
+ :param depth: how many directory levels should be included in output. Default = **1** (only specified directory)
+ :param exclude_self: boolean value indicating whether the `path` itself should be excluded from the list or not.
+ Default = **True**.
+ """
+ if exclude_self and not depth:
+ raise ValueError("Wrong input parameters, query will return nothing.")
+ properties = PROPFIND_PROPERTIES
+ path = path.user_path if isinstance(path, FsNode) else path
+ return await self._listdir(
+ await self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self
+ )
+
+ async def by_id(self, file_id: int | str | FsNode) -> FsNode | None:
+ """Returns :py:class:`~nc_py_api.files.FsNode` by file_id if any.
+
+ :param file_id: can be full file ID with Nextcloud instance ID or only clear file ID.
+ """
+ file_id = file_id.file_id if isinstance(file_id, FsNode) else file_id
+ result = await self.find(req=["eq", "fileid", file_id])
+ return result[0] if result else None
+
+ async def by_path(self, path: str | FsNode) -> FsNode | None:
+ """Returns :py:class:`~nc_py_api.files.FsNode` by exact path if any."""
+ path = path.user_path if isinstance(path, FsNode) else path
+ result = await self.listdir(path, depth=0, exclude_self=False)
+ return result[0] if result else None
+
+ async def find(self, req: list, path: str | FsNode = "") -> list[FsNode]:
+ """Searches a directory for a file or subdirectory with a name.
+
+ :param req: list of conditions to search for. Detailed description here...
+ :param path: path where to search from. Default = **""**.
+ """
+ # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid"
+ root = build_find_request(req, path, await self._session.user)
+ webdav_response = await self._session.adapter_dav.request(
+ "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"}
+ )
+ request_info = f"find: {await self._session.user}, {req}, {path}"
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
+
+ async def download(self, path: str | FsNode) -> bytes:
+ """Downloads and returns the content of a file."""
+ path = path.user_path if isinstance(path, FsNode) else path
+ response = await self._session.adapter_dav.get(dav_get_obj_path(await self._session.user, path))
+ check_error(response, f"download: user={await self._session.user}, path={path}")
+ return response.content
+
+ async def download2stream(self, path: str | FsNode, fp, **kwargs) -> None:
+ """Downloads file to the given `fp` object.
+
+ :param path: path to download file.
+ :param fp: filename (string), pathlib.Path object or a file object.
+ The object must implement the ``file.write`` method and be able to write binary data.
+ :param kwargs: **chunk_size** an int value specifying chunk size to write. Default = **5Mb**
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ if isinstance(fp, str | Path):
+ with builtins.open(fp, "wb") as f:
+ await self.__download2stream(path, f, **kwargs)
+ elif hasattr(fp, "write"):
+ await self.__download2stream(path, fp, **kwargs)
+ else:
+ raise TypeError("`fp` must be a path to file or an object with `write` method.")
+
+ async def download_directory_as_zip(
+ self, path: str | FsNode, local_path: str | Path | None = None, **kwargs
+ ) -> Path:
+ """Downloads a remote directory as zip archive.
+
+ :param path: path to directory to download.
+ :param local_path: relative or absolute file path to save zip file.
+ :returns: Path to the saved zip archive.
+
+ .. note:: This works only for directories, you should not use this to download a file.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ async with self._session.adapter.stream(
+ "GET", "/index.php/apps/files/ajax/download.php", params={"dir": path}
+ ) as response:
+ check_error(response, f"download_directory_as_zip: user={await self._session.user}, path={path}")
+ result_path = local_path if local_path else os.path.basename(path)
+ with open(
+ result_path,
+ "wb",
+ ) as fp:
+ async for data_chunk in response.aiter_raw(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)):
+ fp.write(data_chunk)
+ return Path(result_path)
+
+ async def upload(self, path: str | FsNode, content: bytes | str) -> FsNode:
+ """Creates a file with the specified content at the specified path.
+
+ :param path: file's upload path.
+ :param content: content to create the file. If it is a string, it will be encoded into bytes using UTF-8.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ full_path = dav_get_obj_path(await self._session.user, path)
+ response = await self._session.adapter_dav.put(full_path, content=content)
+ check_error(response, f"upload: user={await self._session.user}, path={path}, size={len(content)}")
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
+
+ async def upload_stream(self, path: str | FsNode, fp, **kwargs) -> FsNode:
+ """Creates a file with content provided by `fp` object at the specified path.
+
+ :param path: file's upload path.
+ :param fp: filename (string), pathlib.Path object or a file object.
+ The object must implement the ``file.read`` method providing data with str or bytes type.
+ :param kwargs: **chunk_size** an int value specifying chunk size to read. Default = **5Mb**
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ chunk_size = kwargs.get("chunk_size", 5 * 1024 * 1024)
+ if isinstance(fp, str | Path):
+ with builtins.open(fp, "rb") as f:
+ return await self.__upload_stream(path, f, chunk_size)
+ elif hasattr(fp, "read"):
+ return await self.__upload_stream(path, fp, chunk_size)
+ else:
+ raise TypeError("`fp` must be a path to file or an object with `read` method.")
+
+ async def mkdir(self, path: str | FsNode) -> FsNode:
+ """Creates a new directory.
+
+ :param path: path of the directory to be created.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ full_path = dav_get_obj_path(await self._session.user, path)
+ response = await self._session.adapter_dav.request("MKCOL", full_path)
+ check_error(response)
+ full_path += "/" if not full_path.endswith("/") else ""
+ return FsNode(full_path.lstrip("/"), **etag_fileid_from_response(response))
+
+ async def makedirs(self, path: str | FsNode, exist_ok=False) -> FsNode | None:
+ """Creates a new directory and subdirectories.
+
+ :param path: path of the directories to be created.
+ :param exist_ok: ignore error if any of pathname components already exists.
+ :returns: `FsNode` if directory was created or ``None`` if it was already created.
+ """
+ _path = ""
+ path = path.user_path if isinstance(path, FsNode) else path
+ path = path.lstrip("/")
+ result = None
+ for i in Path(path).parts:
+ _path = os.path.join(_path, i)
+ if not exist_ok:
+ result = await self.mkdir(_path)
+ else:
+ try:
+ result = await self.mkdir(_path)
+ except NextcloudException as e:
+ if e.status_code != 405:
+ raise e from None
+ return result
+
+ async def delete(self, path: str | FsNode, not_fail=False) -> None:
+ """Deletes a file/directory (moves to trash if trash is enabled).
+
+ :param path: path to delete.
+ :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ response = await self._session.adapter_dav.delete(dav_get_obj_path(await self._session.user, path))
+ if response.status_code == 404 and not_fail:
+ return
+ check_error(response)
+
+ async def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode:
+ """Moves an existing file or a directory.
+
+ :param path_src: path of an existing file/directory.
+ :param path_dest: name of the new one.
+ :param overwrite: if ``True`` and the destination object already exists, it gets overwritten.
+ Default = **False**.
+ """
+ path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
+ full_dest_path = dav_get_obj_path(
+ await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
+ )
+ dest = self._session.cfg.dav_endpoint + full_dest_path
+ headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
+ response = await self._session.adapter_dav.request(
+ "MOVE",
+ dav_get_obj_path(await self._session.user, path_src),
+ headers=headers,
+ )
+ check_error(response, f"move: user={await self._session.user}, src={path_src}, dest={dest}, {overwrite}")
+ return (await self.find(req=["eq", "fileid", response.headers["OC-FileId"]]))[0]
+
+ async def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode:
+ """Copies an existing file/directory.
+
+ :param path_src: path of an existing file/directory.
+ :param path_dest: name of the new one.
+ :param overwrite: if ``True`` and the destination object already exists, it gets overwritten.
+ Default = **False**.
+ """
+ path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src
+ full_dest_path = dav_get_obj_path(
+ await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest
+ )
+ dest = self._session.cfg.dav_endpoint + full_dest_path
+ headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8")
+ response = await self._session.adapter_dav.request(
+ "COPY",
+ dav_get_obj_path(await self._session.user, path_src),
+ headers=headers,
+ )
+ check_error(response, f"copy: user={await self._session.user}, src={path_src}, dest={dest}, {overwrite}")
+ return (await self.find(req=["eq", "fileid", response.headers["OC-FileId"]]))[0]
+
+ async def list_by_criteria(
+ self, properties: list[str] | None = None, tags: list[int | SystemTag] | None = None
+ ) -> list[FsNode]:
+ """Returns a list of all files/directories for the current user filtered by the specified values.
+
+ :param properties: List of ``properties`` that should have been set for the file.
+ Supported values: **favorite**
+ :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file.
+ """
+ root = build_list_by_criteria_req(properties, tags)
+ webdav_response = await self._session.adapter_dav.request(
+ "REPORT", dav_get_obj_path(await self._session.user), content=element_tree_as_str(root)
+ )
+ request_info = f"list_files_by_criteria: {await self._session.user}"
+ check_error(webdav_response, request_info)
+ return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
+
+ async def setfav(self, path: str | FsNode, value: int | bool) -> None:
+ """Sets or unsets favourite flag for specific file.
+
+ :param path: path to the object to set the state.
+ :param value: value to set for the ``favourite`` state.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ root = build_setfav_req(value)
+ webdav_response = await self._session.adapter_dav.request(
+ "PROPPATCH", dav_get_obj_path(await self._session.user, path), content=element_tree_as_str(root)
+ )
+ check_error(webdav_response, f"setfav: path={path}, value={value}")
+
+ async def trashbin_list(self) -> list[FsNode]:
+ """Returns a list of all entries in the TrashBin."""
+ properties = PROPFIND_PROPERTIES
+ properties += ["nc:trashbin-filename", "nc:trashbin-original-location", "nc:trashbin-deletion-time"]
+ return await self._listdir(
+ await self._session.user,
+ "",
+ properties=properties,
+ depth=1,
+ exclude_self=False,
+ prop_type=PropFindType.TRASHBIN,
+ )
+
+ async def trashbin_restore(self, path: str | FsNode) -> None:
+ """Restore a file/directory from the TrashBin.
+
+ :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself.
+ """
+ restore_name = path.name if isinstance(path, FsNode) else path.split("/", maxsplit=1)[-1]
+ path = path.user_path if isinstance(path, FsNode) else path
+
+ dest = self._session.cfg.dav_endpoint + f"/trashbin/{await self._session.user}/restore/{restore_name}"
+ headers = Headers({"Destination": dest}, encoding="utf-8")
+ response = await self._session.adapter_dav.request(
+ "MOVE",
+ f"/trashbin/{await self._session.user}/{path}",
+ headers=headers,
+ )
+ check_error(response, f"trashbin_restore: user={await self._session.user}, src={path}, dest={dest}")
+
+ async def trashbin_delete(self, path: str | FsNode, not_fail=False) -> None:
+ """Deletes a file/directory permanently from the TrashBin.
+
+ :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself.
+ :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception.
+ """
+ path = path.user_path if isinstance(path, FsNode) else path
+ response = await self._session.adapter_dav.delete(f"/trashbin/{await self._session.user}/{path}")
+ if response.status_code == 404 and not_fail:
+ return
+ check_error(response)
+
+ async def trashbin_cleanup(self) -> None:
+ """Empties the TrashBin."""
+ check_error(await self._session.adapter_dav.delete(f"/trashbin/{await self._session.user}/trash"))
+
+ async def get_versions(self, file_object: FsNode) -> list[FsNode]:
+ """Returns a list of all file versions if any."""
+ require_capabilities("files.versioning", await self._session.capabilities)
+ return await self._listdir(
+ await self._session.user,
+ str(file_object.info.fileid) if file_object.info.fileid else file_object.file_id,
+ properties=PROPFIND_PROPERTIES,
+ depth=1,
+ exclude_self=False,
+ prop_type=PropFindType.VERSIONS_FILEID if file_object.info.fileid else PropFindType.VERSIONS_FILE_ID,
+ )
+
+ async def restore_version(self, file_object: FsNode) -> None:
+ """Restore a file with specified version.
+
+ :param file_object: The **FsNode** class from :py:meth:`~nc_py_api.files.files.FilesAPI.get_versions`.
+ """
+ require_capabilities("files.versioning", await self._session.capabilities)
+ dest = self._session.cfg.dav_endpoint + f"/versions/{await self._session.user}/restore/{file_object.name}"
+ headers = Headers({"Destination": dest}, encoding="utf-8")
+ response = await self._session.adapter_dav.request(
+ "MOVE",
+ f"/versions/{await self._session.user}/{file_object.user_path}",
+ headers=headers,
+ )
+ check_error(response, f"restore_version: user={await self._session.user}, src={file_object.user_path}")
+
+ async def list_tags(self) -> list[SystemTag]:
+ """Returns list of the avalaible Tags."""
+ root = build_list_tag_req()
+ response = await self._session.adapter_dav.request("PROPFIND", "/systemtags", content=element_tree_as_str(root))
+ return build_list_tags_response(response)
+
+ async def create_tag(self, name: str, user_visible: bool = True, user_assignable: bool = True) -> None:
+ """Creates a new Tag.
+
+ :param name: Name of the tag.
+ :param user_visible: Should be Tag visible in the UI.
+ :param user_assignable: Can Tag be assigned from the UI.
+ """
+ response = await self._session.adapter_dav.post(
+ "/systemtags",
+ json={
+ "name": name,
+ "userVisible": user_visible,
+ "userAssignable": user_assignable,
+ },
+ )
+ check_error(response, info=f"create_tag({name})")
+
+ async def update_tag(
+ self,
+ tag_id: int | SystemTag,
+ name: str | None = None,
+ user_visible: bool | None = None,
+ user_assignable: bool | None = None,
+ ) -> None:
+ """Updates the Tag information."""
+ tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
+ root = build_update_tag_req(name, user_visible, user_assignable)
+ response = await self._session.adapter_dav.request(
+ "PROPPATCH", f"/systemtags/{tag_id}", content=element_tree_as_str(root)
+ )
+ check_error(response)
+
+ async def delete_tag(self, tag_id: int | SystemTag) -> None:
+ """Deletes the tag."""
+ tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
+ response = await self._session.adapter_dav.delete(f"/systemtags/{tag_id}")
+ check_error(response)
+
+ async def tag_by_name(self, tag_name: str) -> SystemTag:
+ """Returns Tag info by its name if found or ``None`` otherwise."""
+ r = [i for i in await self.list_tags() if i.display_name == tag_name]
+ if not r:
+ raise NextcloudExceptionNotFound(f"Tag with name='{tag_name}' not found.")
+ return r[0]
+
+ async def assign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None:
+ """Assigns Tag to a file/directory."""
+ await self._file_change_tag_state(file_id, tag_id, True)
+
+ async def unassign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None:
+ """Removes Tag from a file/directory."""
+ await self._file_change_tag_state(file_id, tag_id, False)
+
+ async def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None:
+ fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id
+ tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id
+ response = await self._session.adapter_dav.request(
+ "PUT" if tag_state else "DELETE", f"/systemtags-relations/files/{fs_object}/{tag}"
+ )
+ check_error(
+ response,
+ info=f"({'Adding' if tag_state else 'Removing'} `{tag}` {'to' if tag_state else 'from'} {fs_object})",
+ )
+
+ async def _listdir(
+ self,
+ user: str,
+ path: str,
+ properties: list[str],
+ depth: int,
+ exclude_self: bool,
+ prop_type: PropFindType = PropFindType.DEFAULT,
+ ) -> list[FsNode]:
+ root, dav_path = build_listdir_req(user, path, properties, prop_type)
+ webdav_response = await self._session.adapter_dav.request(
+ "PROPFIND",
+ dav_path,
+ content=element_tree_as_str(root),
+ headers={"Depth": "infinity" if depth == -1 else str(depth)},
+ )
+ return build_listdir_response(
+ self._session.cfg.dav_url_suffix, webdav_response, user, path, properties, exclude_self, prop_type
+ )
+
+ async def __download2stream(self, path: str, fp, **kwargs) -> None:
+ async with self._session.adapter_dav.stream(
+ "GET", dav_get_obj_path(await self._session.user, path)
+ ) as response:
+ check_error(response, f"download_stream: user={await self._session.user}, path={path}")
+ async for data_chunk in response.aiter_raw(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)):
+ fp.write(data_chunk)
+
+ async def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode:
+ _dav_path = dav_get_obj_path(await self._session.user, "nc-py-api-" + random_string(56), root_path="/uploads")
+ _v2 = bool(self._session.cfg.options.upload_chunk_v2 and chunk_size >= 5 * 1024 * 1024)
+ full_path = dav_get_obj_path(await self._session.user, path)
+ headers = Headers({"Destination": self._session.cfg.dav_endpoint + full_path}, encoding="utf-8")
+ if _v2:
+ response = await self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers)
+ else:
+ response = await self._session.adapter_dav.request("MKCOL", _dav_path)
+ check_error(response)
+ try:
+ start_bytes = end_bytes = chunk_number = 0
+ while True:
+ piece = fp.read(chunk_size)
+ if not piece:
+ break
+ end_bytes = start_bytes + len(piece)
+ if _v2:
+ response = await self._session.adapter_dav.put(
+ _dav_path + "/" + str(chunk_number), content=piece, headers=headers
+ )
+ else:
+ _filename = str(start_bytes).rjust(15, "0") + "-" + str(end_bytes).rjust(15, "0")
+ response = await self._session.adapter_dav.put(_dav_path + "/" + _filename, content=piece)
+ check_error(
+ response,
+ f"upload_stream(v={_v2}): user={await self._session.user}, path={path}, cur_size={end_bytes}",
+ )
+ start_bytes = end_bytes
+ chunk_number += 1
+
+ response = await self._session.adapter_dav.request(
+ "MOVE",
+ _dav_path + "/.file",
+ headers=headers,
+ )
+ check_error(
+ response,
+ f"upload_stream(v={_v2}): user={await self._session.user}, path={path}, total_size={end_bytes}",
+ )
+ return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
+ finally:
+ await self._session.adapter_dav.delete(_dav_path)
diff --git a/nc_py_api/files/sharing.py b/nc_py_api/files/sharing.py
index ea2ce38b..d7d891c8 100644
--- a/nc_py_api/files/sharing.py
+++ b/nc_py_api/files/sharing.py
@@ -77,27 +77,8 @@ def create(
* ``note`` - string with note, if any. default = ``""``
* ``label`` - string with label, if any. default = ``""``
"""
+ params = _create(path, share_type, permissions, share_with, **kwargs)
_misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities)
- params = {
- "path": path.user_path if isinstance(path, FsNode) else path,
- "shareType": int(share_type),
- }
- if permissions is not None:
- params["permissions"] = int(permissions)
- if share_with:
- params["shareWith"] = share_with
- if kwargs.get("public_upload", False):
- params["publicUpload"] = "true"
- if "password" in kwargs:
- params["password"] = kwargs["password"]
- if kwargs.get("send_password_by_talk", False):
- params["sendPasswordByTalk"] = "true"
- if "expire_date" in kwargs:
- params["expireDate"] = kwargs["expire_date"].isoformat()
- if "note" in kwargs:
- params["note"] = kwargs["note"]
- if "label" in kwargs:
- params["label"] = kwargs["label"]
return Share(self._session.ocs("POST", f"{self._ep_base}/shares", params=params))
def update(self, share_id: int | Share, **kwargs) -> Share:
@@ -107,30 +88,13 @@ def update(self, share_id: int | Share, **kwargs) -> Share:
:param kwargs: Available for update: ``permissions``, ``password``, ``send_password_by_talk``,
``public_upload``, ``expire_date``, ``note``, ``label``.
"""
+ params = _update(**kwargs)
_misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities)
share_id = share_id.share_id if isinstance(share_id, Share) else share_id
- params: dict = {}
- if "permissions" in kwargs:
- params["permissions"] = int(kwargs["permissions"])
- if "password" in kwargs:
- params["password"] = kwargs["password"]
- if kwargs.get("send_password_by_talk", False):
- params["sendPasswordByTalk"] = "true"
- if kwargs.get("public_upload", False):
- params["publicUpload"] = "true"
- if "expire_date" in kwargs:
- params["expireDate"] = kwargs["expire_date"].isoformat()
- if "note" in kwargs:
- params["note"] = kwargs["note"]
- if "label" in kwargs:
- params["label"] = kwargs["label"]
return Share(self._session.ocs("PUT", f"{self._ep_base}/shares/{share_id}", params=params))
def delete(self, share_id: int | Share) -> None:
- """Removes the given share.
-
- :param share_id: The Share object or an ID of the share.
- """
+ """Removes the given share."""
_misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities)
share_id = share_id.share_id if isinstance(share_id, Share) else share_id
self._session.ocs("DELETE", f"{self._ep_base}/shares/{share_id}")
@@ -161,3 +125,173 @@ def undelete(self, share_id: int | Share) -> None:
_misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities)
share_id = share_id.share_id if isinstance(share_id, Share) else share_id
self._session.ocs("POST", f"{self._ep_base}/deletedshares/{share_id}")
+
+
+class _AsyncFilesSharingAPI:
+ """Class provides all Async File Sharing functionality."""
+
+ _ep_base: str = "/ocs/v1.php/apps/files_sharing/api/v1"
+
+ def __init__(self, session: _session.AsyncNcSessionBasic):
+ self._session = session
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not _misc.check_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+
+ async def get_list(
+ self, shared_with_me=False, reshares=False, subfiles=False, path: str | FsNode = ""
+ ) -> list[Share]:
+ """Returns lists of shares.
+
+ :param shared_with_me: Shares should be with the current user.
+ :param reshares: Only get shares by the current user and reshares.
+ :param subfiles: Only get all sub shares in a folder.
+ :param path: Get shares for a specific path.
+ """
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ path = path.user_path if isinstance(path, FsNode) else path
+ params = {
+ "shared_with_me": "true" if shared_with_me else "false",
+ "reshares": "true" if reshares else "false",
+ "subfiles": "true" if subfiles else "false",
+ }
+ if path:
+ params["path"] = path
+ result = await self._session.ocs("GET", f"{self._ep_base}/shares", params=params)
+ return [Share(i) for i in result]
+
+ async def get_by_id(self, share_id: int) -> Share:
+ """Get Share by share ID."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ result = await self._session.ocs("GET", f"{self._ep_base}/shares/{share_id}")
+ return Share(result[0] if isinstance(result, list) else result)
+
+ async def get_inherited(self, path: str) -> list[Share]:
+ """Get all shares relative to a file, e.g., parent folders shares."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ result = await self._session.ocs("GET", f"{self._ep_base}/shares/inherited", params={"path": path})
+ return [Share(i) for i in result]
+
+ async def create(
+ self,
+ path: str | FsNode,
+ share_type: ShareType,
+ permissions: FilePermissions | None = None,
+ share_with: str = "",
+ **kwargs,
+ ) -> Share:
+ """Creates a new share.
+
+ :param path: The path of an existing file/directory.
+ :param share_type: :py:class:`~nc_py_api.files.sharing.ShareType` value.
+ :param permissions: combination of the :py:class:`~nc_py_api.files.FilePermissions` values.
+ :param share_with: the recipient of the shared object.
+ :param kwargs: See below.
+
+ Additionally supported arguments:
+
+ * ``public_upload`` - indicating should share be available for upload for non-registered users.
+ default = ``False``
+ * ``password`` - string with password to protect share. default = ``""``
+ * ``send_password_by_talk`` - boolean indicating should password be automatically delivered using Talk.
+ default = ``False``
+ * ``expire_date`` - :py:class:`~datetime.datetime` time when share should expire.
+ `hours, minutes, seconds` are ignored. default = ``None``
+ * ``note`` - string with note, if any. default = ``""``
+ * ``label`` - string with label, if any. default = ``""``
+ """
+ params = _create(path, share_type, permissions, share_with, **kwargs)
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ return Share(await self._session.ocs("POST", f"{self._ep_base}/shares", params=params))
+
+ async def update(self, share_id: int | Share, **kwargs) -> Share:
+ """Updates the share options.
+
+ :param share_id: ID of the Share to update.
+ :param kwargs: Available for update: ``permissions``, ``password``, ``send_password_by_talk``,
+ ``public_upload``, ``expire_date``, ``note``, ``label``.
+ """
+ params = _update(**kwargs)
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ share_id = share_id.share_id if isinstance(share_id, Share) else share_id
+ return Share(await self._session.ocs("PUT", f"{self._ep_base}/shares/{share_id}", params=params))
+
+ async def delete(self, share_id: int | Share) -> None:
+ """Removes the given share."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ share_id = share_id.share_id if isinstance(share_id, Share) else share_id
+ await self._session.ocs("DELETE", f"{self._ep_base}/shares/{share_id}")
+
+ async def get_pending(self) -> list[Share]:
+ """Returns all pending shares for current user."""
+ return [Share(i) for i in await self._session.ocs("GET", f"{self._ep_base}/shares/pending")]
+
+ async def accept_share(self, share_id: int | Share) -> None:
+ """Accept pending share."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ share_id = share_id.share_id if isinstance(share_id, Share) else share_id
+ await self._session.ocs("POST", f"{self._ep_base}/pending/{share_id}")
+
+ async def decline_share(self, share_id: int | Share) -> None:
+ """Decline pending share."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ share_id = share_id.share_id if isinstance(share_id, Share) else share_id
+ await self._session.ocs("DELETE", f"{self._ep_base}/pending/{share_id}")
+
+ async def get_deleted(self) -> list[Share]:
+ """Get a list of deleted shares."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ return [Share(i) for i in await self._session.ocs("GET", f"{self._ep_base}/deletedshares")]
+
+ async def undelete(self, share_id: int | Share) -> None:
+ """Undelete a deleted share."""
+ _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities)
+ share_id = share_id.share_id if isinstance(share_id, Share) else share_id
+ await self._session.ocs("POST", f"{self._ep_base}/deletedshares/{share_id}")
+
+
+def _create(
+ path: str | FsNode, share_type: ShareType, permissions: FilePermissions | None, share_with: str, **kwargs
+) -> dict:
+ params = {
+ "path": path.user_path if isinstance(path, FsNode) else path,
+ "shareType": int(share_type),
+ }
+ if permissions is not None:
+ params["permissions"] = int(permissions)
+ if share_with:
+ params["shareWith"] = share_with
+ if kwargs.get("public_upload", False):
+ params["publicUpload"] = "true"
+ if "password" in kwargs:
+ params["password"] = kwargs["password"]
+ if kwargs.get("send_password_by_talk", False):
+ params["sendPasswordByTalk"] = "true"
+ if "expire_date" in kwargs:
+ params["expireDate"] = kwargs["expire_date"].isoformat()
+ if "note" in kwargs:
+ params["note"] = kwargs["note"]
+ if "label" in kwargs:
+ params["label"] = kwargs["label"]
+ return params
+
+
+def _update(**kwargs) -> dict:
+ params: dict = {}
+ if "permissions" in kwargs:
+ params["permissions"] = int(kwargs["permissions"])
+ if "password" in kwargs:
+ params["password"] = kwargs["password"]
+ if kwargs.get("send_password_by_talk", False):
+ params["sendPasswordByTalk"] = "true"
+ if kwargs.get("public_upload", False):
+ params["publicUpload"] = "true"
+ if "expire_date" in kwargs:
+ params["expireDate"] = kwargs["expire_date"].isoformat()
+ if "note" in kwargs:
+ params["note"] = kwargs["note"]
+ if "label" in kwargs:
+ params["label"] = kwargs["label"]
+ return params
diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py
index 512db972..abe70c2d 100644
--- a/nc_py_api/nextcloud.py
+++ b/nc_py_api/nextcloud.py
@@ -2,28 +2,42 @@
from abc import ABC
-from fastapi import Request
-from httpx import Headers as HttpxHeaders
+from fastapi import Request as FastAPIRequest
+from httpx import Headers
from ._exceptions import NextcloudExceptionNotFound
from ._misc import check_capabilities, require_capabilities
-from ._preferences import PreferencesAPI
-from ._preferences_ex import AppConfigExAPI, PreferencesExAPI
-from ._session import AppConfig, NcSession, NcSessionApp, NcSessionBasic, ServerVersion
-from ._talk_api import _TalkAPI
+from ._preferences import AsyncPreferencesAPI, PreferencesAPI
+from ._preferences_ex import (
+ AppConfigExAPI,
+ AsyncAppConfigExAPI,
+ AsyncPreferencesExAPI,
+ PreferencesExAPI,
+)
+from ._session import (
+ AppConfig,
+ AsyncNcSession,
+ AsyncNcSessionApp,
+ AsyncNcSessionBasic,
+ NcSession,
+ NcSessionApp,
+ NcSessionBasic,
+ ServerVersion,
+)
+from ._talk_api import _AsyncTalkAPI, _TalkAPI
from ._theming import ThemingInfo, get_parsed_theme
-from .activity import _ActivityAPI
-from .apps import _AppsAPI
+from .activity import _ActivityAPI, _AsyncActivityAPI
+from .apps import _AppsAPI, _AsyncAppsAPI
from .calendar import _CalendarAPI
from .ex_app.defs import ApiScope, LogLvl
-from .ex_app.ui.ui import UiApi
-from .files.files import FilesAPI
-from .notes import _NotesAPI
-from .notifications import _NotificationsAPI
-from .user_status import _UserStatusAPI
-from .users import _UsersAPI
-from .users_groups import _UsersGroupsAPI
-from .weather_status import _WeatherStatusAPI
+from .ex_app.ui.ui import AsyncUiApi, UiApi
+from .files.files import AsyncFilesAPI, FilesAPI
+from .notes import _AsyncNotesAPI, _NotesAPI
+from .notifications import _AsyncNotificationsAPI, _NotificationsAPI
+from .user_status import _AsyncUserStatusAPI, _UserStatusAPI
+from .users import _AsyncUsersAPI, _UsersAPI
+from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI
+from .weather_status import _AsyncWeatherStatusAPI, _WeatherStatusAPI
class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
@@ -42,7 +56,7 @@ class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
notifications: _NotificationsAPI
"""Nextcloud API for managing user notifications"""
talk: _TalkAPI
- """Nextcloud Talk Api"""
+ """Nextcloud Talk API"""
users: _UsersAPI
"""Nextcloud API for managing users."""
users_groups: _UsersGroupsAPI
@@ -53,7 +67,7 @@ class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
"""Nextcloud API for managing user weather statuses"""
_session: NcSessionBasic
- def _init_api(self, session: NcSessionBasic):
+ def __init__(self, session: NcSessionBasic):
self.apps = _AppsAPI(session)
self.activity = _ActivityAPI(session)
self.cal = _CalendarAPI(session)
@@ -78,10 +92,7 @@ def srv_version(self) -> ServerVersion:
return self._session.nc_version
def check_capabilities(self, capabilities: str | list[str]) -> list[str]:
- """Returns the list with missing capabilities if any.
-
- :param capabilities: one or more features to check for.
- """
+ """Returns the list with missing capabilities if any."""
return check_capabilities(capabilities, self.capabilities)
def update_server_info(self) -> None:
@@ -92,7 +103,7 @@ def update_server_info(self) -> None:
self._session.update_server_info()
@property
- def response_headers(self) -> HttpxHeaders:
+ def response_headers(self) -> Headers:
"""Returns the `HTTPX headers `_ from the last response."""
return self._session.response_headers
@@ -102,6 +113,79 @@ def theme(self) -> ThemingInfo | None:
return get_parsed_theme(self.capabilities["theming"]) if "theming" in self.capabilities else None
+class _AsyncNextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
+ apps: _AsyncAppsAPI
+ """Nextcloud API for App management"""
+ activity: _AsyncActivityAPI
+ """Activity Application API"""
+ # cal: _CalendarAPI
+ # """Nextcloud Calendar API"""
+ files: AsyncFilesAPI
+ """Nextcloud API for File System and Files Sharing"""
+ preferences: AsyncPreferencesAPI
+ """Nextcloud User Preferences API"""
+ notes: _AsyncNotesAPI
+ """Nextcloud Notes API"""
+ notifications: _AsyncNotificationsAPI
+ """Nextcloud API for managing user notifications"""
+ talk: _AsyncTalkAPI
+ """Nextcloud Talk API"""
+ users: _AsyncUsersAPI
+ """Nextcloud API for managing users."""
+ users_groups: _AsyncUsersGroupsAPI
+ """Nextcloud API for managing user groups."""
+ user_status: _AsyncUserStatusAPI
+ """Nextcloud API for managing users statuses"""
+ weather_status: _AsyncWeatherStatusAPI
+ """Nextcloud API for managing user weather statuses"""
+ _session: AsyncNcSessionBasic
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self.apps = _AsyncAppsAPI(session)
+ self.activity = _AsyncActivityAPI(session)
+ # self.cal = _CalendarAPI(session)
+ self.files = AsyncFilesAPI(session)
+ self.preferences = AsyncPreferencesAPI(session)
+ self.notes = _AsyncNotesAPI(session)
+ self.notifications = _AsyncNotificationsAPI(session)
+ self.talk = _AsyncTalkAPI(session)
+ self.users = _AsyncUsersAPI(session)
+ self.users_groups = _AsyncUsersGroupsAPI(session)
+ self.user_status = _AsyncUserStatusAPI(session)
+ self.weather_status = _AsyncWeatherStatusAPI(session)
+
+ @property
+ async def capabilities(self) -> dict:
+ """Returns the capabilities of the Nextcloud instance."""
+ return await self._session.capabilities
+
+ @property
+ async def srv_version(self) -> ServerVersion:
+ """Returns dictionary with the server version."""
+ return await self._session.nc_version
+
+ async def check_capabilities(self, capabilities: str | list[str]) -> list[str]:
+ """Returns the list with missing capabilities if any."""
+ return check_capabilities(capabilities, await self.capabilities)
+
+ async def update_server_info(self) -> None:
+ """Updates the capabilities and the Nextcloud version.
+
+ *In normal cases, it is called automatically and there is no need to call it manually.*
+ """
+ await self._session.update_server_info()
+
+ @property
+ def response_headers(self) -> Headers:
+ """Returns the `HTTPX headers `_ from the last response."""
+ return self._session.response_headers
+
+ @property
+ async def theme(self) -> ThemingInfo | None:
+ """Returns Theme information."""
+ return get_parsed_theme((await self.capabilities)["theming"]) if "theming" in await self.capabilities else None
+
+
class Nextcloud(_NextcloudBasic):
"""Nextcloud client class.
@@ -118,7 +202,7 @@ def __init__(self, **kwargs):
:param nc_auth_pass: password or app-password for the username.
"""
self._session = NcSession(**kwargs)
- self._init_api(self._session)
+ super().__init__(self._session)
@property
def user(self) -> str:
@@ -126,8 +210,32 @@ def user(self) -> str:
return self._session.user
+class AsyncNextcloud(_AsyncNextcloudBasic):
+ """Async Nextcloud client class.
+
+ Allows you to connect to Nextcloud and perform operations on files, shares, users, and everything else.
+ """
+
+ _session: AsyncNcSession
+
+ def __init__(self, **kwargs):
+ """If the parameters are not specified, they will be taken from the environment.
+
+ :param nextcloud_url: url of the nextcloud instance.
+ :param nc_auth_user: login username.
+ :param nc_auth_pass: password or app-password for the username.
+ """
+ self._session = AsyncNcSession(**kwargs)
+ super().__init__(self._session)
+
+ @property
+ async def user(self) -> str:
+ """Returns current user ID."""
+ return await self._session.user
+
+
class NextcloudApp(_NextcloudBasic):
- """Class for creating Nextcloud applications.
+ """Class for communication with Nextcloud in Nextcloud applications.
Provides additional API required for applications such as user impersonation,
endpoint registration, new authentication method, etc.
@@ -150,17 +258,13 @@ def __init__(self, **kwargs):
They can be overridden by specifying them in **kwargs**, but this behavior is highly discouraged.
"""
self._session = NcSessionApp(**kwargs)
- self._init_api(self._session)
+ super().__init__(self._session)
self.appconfig_ex = AppConfigExAPI(self._session)
self.preferences_ex = PreferencesExAPI(self._session)
self.ui = UiApi(self._session)
def log(self, log_lvl: LogLvl, content: str) -> None:
- """Writes log to the Nextcloud log file.
-
- :param log_lvl: level of the log, content belongs to.
- :param content: string to write into the log.
- """
+ """Writes log to the Nextcloud log file."""
if self.check_capabilities("app_api"):
return
if int(log_lvl) < self.capabilities["app_api"].get("loglevel", 0):
@@ -169,7 +273,7 @@ def log(self, log_lvl: LogLvl, content: str) -> None:
def users_list(self) -> list[str]:
"""Returns list of users on the Nextcloud instance. **Available** only for ``System`` applications."""
- return self._session.ocs("GET", f"{self._session.ae_url}/users", params={"format": "json"})
+ return self._session.ocs("GET", f"{self._session.ae_url}/users")
def scope_allowed(self, scope: ApiScope) -> bool:
"""Check if API scope is avalaible for application.
@@ -184,14 +288,14 @@ def scope_allowed(self, scope: ApiScope) -> bool:
def user(self) -> str:
"""Property containing the current user ID.
- *System Applications* can set it and impersonate the user. For normal applications, it is set automatically.
+ **System Applications** can change user ID they impersonate with **set_user** method.
"""
return self._session.user
- @user.setter
- def user(self, value: str):
- if self._session.user != value:
- self._session.user = value
+ def set_user(self, user_id: str):
+ """Changes current User ID."""
+ if self._session.user != user_id:
+ self._session.set_user(user_id)
self.talk.config_sha = ""
self.talk.modified_since = 0
self.activity.last_given = 0
@@ -211,7 +315,7 @@ def register_talk_bot(self, callback_url: str, display_name: str, description: s
:param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides.
:param display_name: The name under which the messages will be posted.
:param description: Optional description shown in the admin settings.
- :return: The secret used for signing requests.
+ :return: Tuple with ID and the secret used for signing requests.
"""
require_capabilities("app_api", self._session.capabilities)
require_capabilities("spreed.features.bots-v1", self._session.capabilities)
@@ -224,11 +328,7 @@ def register_talk_bot(self, callback_url: str, display_name: str, description: s
return result["id"], result["secret"]
def unregister_talk_bot(self, callback_url: str) -> bool:
- """Unregisters Talk BOT.
-
- :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides.
- :return: The secret used for signing requests.
- """
+ """Unregisters Talk BOT."""
require_capabilities("app_api", self._session.capabilities)
require_capabilities("spreed.features.bots-v1", self._session.capabilities)
params = {
@@ -240,7 +340,7 @@ def unregister_talk_bot(self, callback_url: str) -> bool:
return False
return True
- def request_sign_check(self, request: Request) -> bool:
+ def request_sign_check(self, request: FastAPIRequest) -> bool:
"""Verifies the signature and validity of an incoming request from the Nextcloud.
:param request: The `Starlette request `_
@@ -269,3 +369,140 @@ def set_init_status(self, progress: int, error: str = "") -> None:
"error": error,
},
)
+
+
+class AsyncNextcloudApp(_AsyncNextcloudBasic):
+ """Class for communication with Nextcloud in Async Nextcloud applications.
+
+ Provides additional API required for applications such as user impersonation,
+ endpoint registration, new authentication method, etc.
+
+ .. note:: Instance of this class should not be created directly in ``normal`` applications,
+ it will be provided for each app endpoint call.
+ """
+
+ _session: AsyncNcSessionApp
+ appconfig_ex: AsyncAppConfigExAPI
+ """Nextcloud App Preferences API for ExApps"""
+ preferences_ex: AsyncPreferencesExAPI
+ """Nextcloud User Preferences API for ExApps"""
+ ui: AsyncUiApi
+ """Nextcloud UI API for ExApps"""
+
+ def __init__(self, **kwargs):
+ """The parameters will be taken from the environment.
+
+ They can be overridden by specifying them in **kwargs**, but this behavior is highly discouraged.
+ """
+ self._session = AsyncNcSessionApp(**kwargs)
+ super().__init__(self._session)
+ self.appconfig_ex = AsyncAppConfigExAPI(self._session)
+ self.preferences_ex = AsyncPreferencesExAPI(self._session)
+ self.ui = AsyncUiApi(self._session)
+
+ async def log(self, log_lvl: LogLvl, content: str) -> None:
+ """Writes log to the Nextcloud log file."""
+ if await self.check_capabilities("app_api"):
+ return
+ if int(log_lvl) < (await self.capabilities)["app_api"].get("loglevel", 0):
+ return
+ await self._session.ocs("POST", f"{self._session.ae_url}/log", json={"level": int(log_lvl), "message": content})
+
+ async def users_list(self) -> list[str]:
+ """Returns list of users on the Nextcloud instance. **Available** only for ``System`` applications."""
+ return await self._session.ocs("GET", f"{self._session.ae_url}/users")
+
+ async def scope_allowed(self, scope: ApiScope) -> bool:
+ """Check if API scope is avalaible for application.
+
+ Useful for applications that declare optional scopes to check if they are allowed.
+ """
+ if await self.check_capabilities("app_api"):
+ return False
+ return scope in (await self.capabilities)["app_api"]["scopes"]
+
+ @property
+ async def user(self) -> str:
+ """Property containing the current user ID.
+
+ **System Applications** can change user ID they impersonate with **set_user** method.
+ """
+ return await self._session.user
+
+ async def set_user(self, user_id: str):
+ """Changes current User ID."""
+ if await self._session.user != user_id:
+ self._session.set_user(user_id)
+ self.talk.config_sha = ""
+ self.talk.modified_since = 0
+ self.activity.last_given = 0
+ self.notes.last_etag = ""
+ await self._session.update_server_info()
+
+ @property
+ def app_cfg(self) -> AppConfig:
+ """Returns deploy config, with AppAPI version, Application version and name."""
+ return self._session.cfg
+
+ async def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]:
+ """Registers Talk BOT.
+
+ .. note:: AppAPI will add a record in a case of successful registration to the ``appconfig_ex`` table.
+
+ :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides.
+ :param display_name: The name under which the messages will be posted.
+ :param description: Optional description shown in the admin settings.
+ :return: Tuple with ID and the secret used for signing requests.
+ """
+ require_capabilities("app_api", await self._session.capabilities)
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ params = {
+ "name": display_name,
+ "route": callback_url,
+ "description": description,
+ }
+ result = await self._session.ocs("POST", f"{self._session.ae_url}/talk_bot", json=params)
+ return result["id"], result["secret"]
+
+ async def unregister_talk_bot(self, callback_url: str) -> bool:
+ """Unregisters Talk BOT."""
+ require_capabilities("app_api", await self._session.capabilities)
+ require_capabilities("spreed.features.bots-v1", await self._session.capabilities)
+ params = {
+ "route": callback_url,
+ }
+ try:
+ await self._session.ocs("DELETE", f"{self._session.ae_url}/talk_bot", json=params)
+ except NextcloudExceptionNotFound:
+ return False
+ return True
+
+ def request_sign_check(self, request: FastAPIRequest) -> bool:
+ """Verifies the signature and validity of an incoming request from the Nextcloud.
+
+ :param request: The `Starlette request `_
+
+ .. note:: In most cases ``nc: Annotated[NextcloudApp, Depends(nc_app)]`` should be used.
+ """
+ try:
+ self._session.sign_check(request)
+ except ValueError as e:
+ print(e)
+ return False
+ return True
+
+ async def set_init_status(self, progress: int, error: str = "") -> None:
+ """Sets state of the app initialization.
+
+ :param progress: a number from ``0`` to ``100`` indicating the percentage of application readiness for work.
+ After sending ``100`` AppAPI will enable the application.
+ :param error: if non-empty, signals to AppAPI that the application cannot be initialized successfully.
+ """
+ await self._session.ocs(
+ "PUT",
+ f"/ocs/v1.php/apps/app_api/apps/status/{self._session.cfg.app_name}",
+ json={
+ "progress": progress,
+ "error": error,
+ },
+ )
diff --git a/nc_py_api/notes.py b/nc_py_api/notes.py
index 71ba57e7..abc14901 100644
--- a/nc_py_api/notes.py
+++ b/nc_py_api/notes.py
@@ -9,7 +9,7 @@
from ._exceptions import check_error
from ._misc import check_capabilities, clear_from_params_empty, require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -140,14 +140,14 @@ def get_list(
}
clear_from_params_empty(list(params.keys()), params)
headers = {"If-None-Match": self.last_etag} if self.last_etag and etag else {}
- r = self.__response_to_json(self._session.adapter.get(self._ep_base + "/notes", params=params, headers=headers))
+ r = _res_to_json(self._session.adapter.get(self._ep_base + "/notes", params=params, headers=headers))
self.last_etag = self._session.response_headers["ETag"]
return [Note(i) for i in r]
def by_id(self, note: Note) -> Note:
"""Get updated information about :py:class:`~nc_py_api.notes.Note`."""
require_capabilities("notes", self._session.capabilities)
- r = self.__response_to_json(
+ r = _res_to_json(
self._session.adapter.get(
self._ep_base + f"/notes/{note.note_id}", headers={"If-None-Match": f'"{note.etag}"'}
)
@@ -172,7 +172,7 @@ def create(
"modified": last_modified,
}
clear_from_params_empty(list(params.keys()), params)
- return Note(self.__response_to_json(self._session.adapter.post(self._ep_base + "/notes", json=params)))
+ return Note(_res_to_json(self._session.adapter.post(self._ep_base + "/notes", json=params)))
def update(
self,
@@ -199,16 +199,13 @@ def update(
if not params:
raise ValueError("Nothing to update.")
return Note(
- self.__response_to_json(
+ _res_to_json(
self._session.adapter.put(self._ep_base + f"/notes/{note.note_id}", json=params, headers=headers)
)
)
def delete(self, note: int | Note) -> None:
- """Deletes a Note.
-
- :param note: note id or :py:class:`~nc_py_api.notes.Note`.
- """
+ """Deletes a Note."""
require_capabilities("notes", self._session.capabilities)
note_id = note.note_id if isinstance(note, Note) else note
check_error(self._session.adapter.delete(self._ep_base + f"/notes/{note_id}"))
@@ -216,7 +213,7 @@ def delete(self, note: int | Note) -> None:
def get_settings(self) -> NotesSettings:
"""Returns Notes App settings."""
require_capabilities("notes", self._session.capabilities)
- r = self.__response_to_json(self._session.adapter.get(self._ep_base + "/settings"))
+ r = _res_to_json(self._session.adapter.get(self._ep_base + "/settings"))
return {"notes_path": r["notesPath"], "file_suffix": r["fileSuffix"]}
def set_settings(self, notes_path: str | None = None, file_suffix: str | None = None) -> None:
@@ -231,7 +228,145 @@ def set_settings(self, notes_path: str | None = None, file_suffix: str | None =
clear_from_params_empty(list(params.keys()), params)
check_error(self._session.adapter.put(self._ep_base + "/settings", json=params))
- @staticmethod
- def __response_to_json(response: httpx.Response) -> dict:
- check_error(response)
- return json.loads(response.text) if response.status_code != 304 else {}
+
+class _AsyncNotesAPI:
+ """Class implements Async Nextcloud Notes API."""
+
+ _ep_base: str = "/index.php/apps/notes/api/v1" # without `index.php` we will get 405 error.
+ last_etag: str
+ """Used by ``get_list``, when **etag** param is ``True``."""
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+ self.last_etag = ""
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("notes", await self._session.capabilities)
+
+ async def get_list(
+ self,
+ category: str | None = None,
+ modified_since: int | None = None,
+ limit: int | None = None,
+ cursor: str | None = None,
+ no_content: bool = False,
+ etag: bool = False,
+ ) -> list[Note]:
+ """Get information of all Notes.
+
+ :param category: Filter the result by category name. Notes with another category are not included in the result.
+ :param modified_since: When provided only results newer than given Unix timestamp are returned.
+ :param limit: Limit response to contain no more than the given number of notes.
+ If there are more notes, then the result is chunked and the HTTP response header
+ **X-Notes-Chunk-Cursor** is sent with a string value.
+
+ .. note:: Use :py:attr:`~nc_py_api.nextcloud.Nextcloud.response_headers` property to achieve that.
+ :param cursor: You should use the string value from the last request's HTTP response header
+ ``X-Notes-Chunk-Cursor`` in order to get the next chunk of notes.
+ :param no_content: Flag indicating should ``content`` field be excluded from response.
+ :param etag: Flag indicating should ``ETag`` from last call be used. Default = **False**.
+ """
+ require_capabilities("notes", await self._session.capabilities)
+ params = {
+ "category": category,
+ "pruneBefore": modified_since,
+ "exclude": "content" if no_content else None,
+ "chunkSize": limit,
+ "chunkCursor": cursor,
+ }
+ clear_from_params_empty(list(params.keys()), params)
+ headers = {"If-None-Match": self.last_etag} if self.last_etag and etag else {}
+ r = _res_to_json(await self._session.adapter.get(self._ep_base + "/notes", params=params, headers=headers))
+ self.last_etag = self._session.response_headers["ETag"]
+ return [Note(i) for i in r]
+
+ async def by_id(self, note: Note) -> Note:
+ """Get updated information about :py:class:`~nc_py_api.notes.Note`."""
+ require_capabilities("notes", await self._session.capabilities)
+ r = _res_to_json(
+ await self._session.adapter.get(
+ self._ep_base + f"/notes/{note.note_id}", headers={"If-None-Match": f'"{note.etag}"'}
+ )
+ )
+ return Note(r) if r else note
+
+ async def create(
+ self,
+ title: str,
+ content: str | None = None,
+ category: str | None = None,
+ favorite: bool | None = None,
+ last_modified: int | str | datetime.datetime | None = None,
+ ) -> Note:
+ """Create new Note."""
+ require_capabilities("notes", await self._session.capabilities)
+ params = {
+ "title": title,
+ "content": content,
+ "category": category,
+ "favorite": favorite,
+ "modified": last_modified,
+ }
+ clear_from_params_empty(list(params.keys()), params)
+ return Note(_res_to_json(await self._session.adapter.post(self._ep_base + "/notes", json=params)))
+
+ async def update(
+ self,
+ note: Note,
+ title: str | None = None,
+ content: str | None = None,
+ category: str | None = None,
+ favorite: bool | None = None,
+ overwrite: bool = False,
+ ) -> Note:
+ """Updates Note.
+
+ ``overwrite`` specifies should be or not the Note updated even if it was changed on server(has different ETag).
+ """
+ require_capabilities("notes", await self._session.capabilities)
+ headers = {"If-Match": f'"{note.etag}"'} if not overwrite else {}
+ params = {
+ "title": title,
+ "content": content,
+ "category": category,
+ "favorite": favorite,
+ }
+ clear_from_params_empty(list(params.keys()), params)
+ if not params:
+ raise ValueError("Nothing to update.")
+ return Note(
+ _res_to_json(
+ await self._session.adapter.put(self._ep_base + f"/notes/{note.note_id}", json=params, headers=headers)
+ )
+ )
+
+ async def delete(self, note: int | Note) -> None:
+ """Deletes a Note."""
+ require_capabilities("notes", await self._session.capabilities)
+ note_id = note.note_id if isinstance(note, Note) else note
+ check_error(await self._session.adapter.delete(self._ep_base + f"/notes/{note_id}"))
+
+ async def get_settings(self) -> NotesSettings:
+ """Returns Notes App settings."""
+ require_capabilities("notes", await self._session.capabilities)
+ r = _res_to_json(await self._session.adapter.get(self._ep_base + "/settings"))
+ return {"notes_path": r["notesPath"], "file_suffix": r["fileSuffix"]}
+
+ async def set_settings(self, notes_path: str | None = None, file_suffix: str | None = None) -> None:
+ """Change specified setting(s)."""
+ if notes_path is None and file_suffix is None:
+ raise ValueError("No setting to change.")
+ require_capabilities("notes", await self._session.capabilities)
+ params = {
+ "notesPath": notes_path,
+ "fileSuffix": file_suffix,
+ }
+ clear_from_params_empty(list(params.keys()), params)
+ check_error(await self._session.adapter.put(self._ep_base + "/settings", json=params))
+
+
+def _res_to_json(response: httpx.Response) -> dict:
+ check_error(response)
+ return json.loads(response.text) if response.status_code != 304 else {}
diff --git a/nc_py_api/notifications.py b/nc_py_api/notifications.py
index ac81f6d4..5bf6f1f8 100644
--- a/nc_py_api/notifications.py
+++ b/nc_py_api/notifications.py
@@ -9,7 +9,12 @@
random_string,
require_capabilities,
)
-from ._session import NcSessionApp, NcSessionBasic
+from ._session import (
+ AsyncNcSessionApp,
+ AsyncNcSessionBasic,
+ NcSessionApp,
+ NcSessionBasic,
+)
@dataclasses.dataclass
@@ -101,30 +106,10 @@ def create(
.. note:: Does not work in Nextcloud client mode, only for NextcloudApp mode.
"""
+ params = _create(subject, message, subject_params, message_params, link)
if not isinstance(self._session, NcSessionApp):
raise NotImplementedError("Sending notifications is only supported for `App` mode.")
- if not subject:
- raise ValueError("`subject` cannot be empty string.")
require_capabilities(["app_api", "notifications"], self._session.capabilities)
- if subject_params is None:
- subject_params = {}
- if message_params is None:
- message_params = {}
- params: dict = {
- "params": {
- "object": "app_api",
- "object_id": random_string(56),
- "subject_type": "app_api_ex_app",
- "subject_params": {
- "rich_subject": subject,
- "rich_subject_params": subject_params,
- "rich_message": message,
- "rich_message_params": message_params,
- },
- }
- }
- if link:
- params["params"]["subject_params"]["link"] = link
return self._session.ocs("POST", f"{self._session.ae_url}/notification", json=params)["object_id"]
def get_all(self) -> list[Notification]:
@@ -161,3 +146,97 @@ def exists(self, notification_ids: list[int]) -> list[int]:
"""Checks the existence of notifications for the current user."""
require_capabilities("notifications", self._session.capabilities)
return self._session.ocs("POST", f"{self._ep_base}/exists", json={"ids": notification_ids})
+
+
+class _AsyncNotificationsAPI:
+ """Class provides async API for managing user notifications on the Nextcloud server."""
+
+ _ep_base: str = "/ocs/v2.php/apps/notifications/api/v2/notifications"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("notifications", await self._session.capabilities)
+
+ async def create(
+ self,
+ subject: str,
+ message: str = "",
+ subject_params: dict | None = None,
+ message_params: dict | None = None,
+ link: str = "",
+ ) -> str:
+ """Create a Notification for the current user and returns it's ObjectID.
+
+ .. note:: Does not work in Nextcloud client mode, only for NextcloudApp mode.
+ """
+ params = _create(subject, message, subject_params, message_params, link)
+ if not isinstance(self._session, AsyncNcSessionApp):
+ raise NotImplementedError("Sending notifications is only supported for `App` mode.")
+ require_capabilities(["app_api", "notifications"], await self._session.capabilities)
+ return (await self._session.ocs("POST", f"{self._session.ae_url}/notification", json=params))["object_id"]
+
+ async def get_all(self) -> list[Notification]:
+ """Gets all notifications for a current user."""
+ require_capabilities("notifications", await self._session.capabilities)
+ return [Notification(i) for i in await self._session.ocs("GET", self._ep_base)]
+
+ async def get_one(self, notification_id: int) -> Notification:
+ """Gets a single notification for a current user."""
+ require_capabilities("notifications", await self._session.capabilities)
+ return Notification(await self._session.ocs("GET", f"{self._ep_base}/{notification_id}"))
+
+ async def by_object_id(self, object_id: str) -> Notification | None:
+ """Returns Notification if any by its object ID.
+
+ .. note:: this method is a temporary workaround until `create` can return `notification_id`.
+ """
+ for i in await self.get_all():
+ if i.object_id == object_id:
+ return i
+ return None
+
+ async def delete(self, notification_id: int) -> None:
+ """Deletes a notification for the current user."""
+ require_capabilities("notifications", await self._session.capabilities)
+ await self._session.ocs("DELETE", f"{self._ep_base}/{notification_id}")
+
+ async def delete_all(self) -> None:
+ """Deletes all notifications for the current user."""
+ require_capabilities("notifications", await self._session.capabilities)
+ await self._session.ocs("DELETE", self._ep_base)
+
+ async def exists(self, notification_ids: list[int]) -> list[int]:
+ """Checks the existence of notifications for the current user."""
+ require_capabilities("notifications", await self._session.capabilities)
+ return await self._session.ocs("POST", f"{self._ep_base}/exists", json={"ids": notification_ids})
+
+
+def _create(
+ subject: str, message: str, subject_params: dict | None, message_params: dict | None, link: str
+) -> dict[str, str | dict]:
+ if not subject:
+ raise ValueError("`subject` cannot be empty string.")
+ if subject_params is None:
+ subject_params = {}
+ if message_params is None:
+ message_params = {}
+ params: dict = {
+ "params": {
+ "object": "app_api",
+ "object_id": random_string(56),
+ "subject_type": "app_api_ex_app",
+ "subject_params": {
+ "rich_subject": subject,
+ "rich_subject_params": subject_params,
+ "rich_message": message,
+ "rich_message_params": message_params,
+ },
+ }
+ }
+ if link:
+ params["params"]["subject_params"]["link"] = link
+ return params
diff --git a/nc_py_api/talk_bot.py b/nc_py_api/talk_bot.py
index 943a1017..1eca6fde 100644
--- a/nc_py_api/talk_bot.py
+++ b/nc_py_api/talk_bot.py
@@ -12,7 +12,7 @@
from . import options
from ._misc import random_string
from ._session import BasicConfig
-from .nextcloud import NextcloudApp
+from .nextcloud import AsyncNextcloudApp, NextcloudApp
class ObjectContent(typing.TypedDict):
@@ -183,29 +183,149 @@ def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_s
talk_bot_random = random_string(32)
hmac_sign = hmac.new(secret, talk_bot_random.encode("UTF-8"), digestmod=hashlib.sha256)
hmac_sign.update(data_to_sign.encode("UTF-8"))
- headers = {
- "X-Nextcloud-Talk-Bot-Random": talk_bot_random,
- "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(),
- "OCS-APIRequest": "true",
- }
nc_app_cfg = BasicConfig()
- return httpx.request(
- method,
- url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix,
- json=data,
- headers=headers,
- cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {},
- timeout=nc_app_cfg.options.timeout,
- verify=nc_app_cfg.options.nc_cert,
- )
+ with httpx.Client(verify=nc_app_cfg.options.nc_cert) as client:
+ return client.request(
+ method,
+ url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix,
+ json=data,
+ headers={
+ "X-Nextcloud-Talk-Bot-Random": talk_bot_random,
+ "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(),
+ "OCS-APIRequest": "true",
+ },
+ cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {},
+ timeout=nc_app_cfg.options.timeout,
+ )
+
+
+class AsyncTalkBot:
+ """A class that implements the async TalkBot functionality."""
+ _ep_base: str = "/ocs/v2.php/apps/spreed/api/v1/bot"
-def get_bot_secret(callback_url: str) -> bytes | None:
- """Returns the bot's secret from an environment variable or from the application's configuration on the server."""
+ def __init__(self, callback_url: str, display_name: str, description: str = ""):
+ """Class implementing Nextcloud Talk Bot functionality.
+
+ :param callback_url: FastAPI endpoint which will be assigned to bot.
+ :param display_name: The display name of the bot that is shown as author when it posts a message or reaction.
+ :param description: Description of the bot helping moderators to decide if they want to enable this bot.
+ """
+ self.callback_url = callback_url
+ self.display_name = display_name
+ self.description = description
+
+ async def enabled_handler(self, enabled: bool, nc: AsyncNextcloudApp) -> None:
+ """Handles the app ``on``/``off`` event in the context of the bot.
+
+ :param enabled: Value that was passed to ``/enabled`` handler.
+ :param nc: **NextcloudApp** class that was passed ``/enabled`` handler.
+ """
+ if enabled:
+ bot_id, bot_secret = await nc.register_talk_bot(self.callback_url, self.display_name, self.description)
+ os.environ[bot_id] = bot_secret
+ else:
+ await nc.unregister_talk_bot(self.callback_url)
+
+ async def send_message(
+ self, message: str, reply_to_message: int | TalkBotMessage, silent: bool = False, token: str = ""
+ ) -> tuple[httpx.Response, str]:
+ """Send a message and returns a "reference string" to identify the message again in a "get messages" request.
+
+ :param message: The message to say.
+ :param reply_to_message: The message ID this message is a reply to.
+
+ .. note:: Only allowed when the message type is not ``system`` or ``command``.
+ :param silent: Flag controlling if the message should create a chat notifications for the users.
+ :param token: Token of the conversation.
+ Can be empty if ``reply_to_message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`.
+ :returns: Tuple, where fist element is :py:class:`httpx.Response` and second is a "reference string".
+ :raises ValueError: in case of an invalid usage.
+ :raises RuntimeError: in case of a broken installation.
+ """
+ if not token and not isinstance(reply_to_message, TalkBotMessage):
+ raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.")
+ token = reply_to_message.conversation_token if isinstance(reply_to_message, TalkBotMessage) else token
+ reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest()
+ params = {
+ "message": message,
+ "replyTo": reply_to_message.object_id if isinstance(reply_to_message, TalkBotMessage) else reply_to_message,
+ "referenceId": reference_id,
+ "silent": silent,
+ }
+ return await self._sign_send_request("POST", f"/{token}/message", params, message), reference_id
+
+ async def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response:
+ """React to a message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to.
+ :param reaction: A single emoji.
+ :param token: Token of the conversation.
+ Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`.
+ :raises ValueError: in case of an invalid usage.
+ :raises RuntimeError: in case of a broken installation.
+ """
+ if not token and not isinstance(message, TalkBotMessage):
+ raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.")
+ message_id = message.object_id if isinstance(message, TalkBotMessage) else message
+ token = message.conversation_token if isinstance(message, TalkBotMessage) else token
+ params = {
+ "reaction": reaction,
+ }
+ return await self._sign_send_request("POST", f"/{token}/reaction/{message_id}", params, reaction)
+
+ async def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response:
+ """Removes reaction from a message.
+
+ :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to remove reaction from.
+ :param reaction: A single emoji.
+ :param token: Token of the conversation.
+ Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`.
+ :raises ValueError: in case of an invalid usage.
+ :raises RuntimeError: in case of a broken installation.
+ """
+ if not token and not isinstance(message, TalkBotMessage):
+ raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.")
+ message_id = message.object_id if isinstance(message, TalkBotMessage) else message
+ token = message.conversation_token if isinstance(message, TalkBotMessage) else token
+ params = {
+ "reaction": reaction,
+ }
+ return await self._sign_send_request("DELETE", f"/{token}/reaction/{message_id}", params, reaction)
+
+ async def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> httpx.Response:
+ secret = await aget_bot_secret(self.callback_url)
+ if secret is None:
+ raise RuntimeError("Can't find the 'secret' of the bot. Has the bot been installed?")
+ talk_bot_random = random_string(32)
+ hmac_sign = hmac.new(secret, talk_bot_random.encode("UTF-8"), digestmod=hashlib.sha256)
+ hmac_sign.update(data_to_sign.encode("UTF-8"))
+ nc_app_cfg = BasicConfig()
+ async with httpx.AsyncClient(verify=nc_app_cfg.options.nc_cert) as aclient:
+ return await aclient.request(
+ method,
+ url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix,
+ json=data,
+ headers={
+ "X-Nextcloud-Talk-Bot-Random": talk_bot_random,
+ "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(),
+ "OCS-APIRequest": "true",
+ },
+ cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {},
+ timeout=nc_app_cfg.options.timeout,
+ )
+
+
+def __get_bot_secret(callback_url: str) -> str:
sha_1 = hashlib.sha1(usedforsecurity=False)
string_to_hash = os.environ["APP_ID"] + "_" + callback_url
sha_1.update(string_to_hash.encode("UTF-8"))
- secret_key = sha_1.hexdigest()
+ return sha_1.hexdigest()
+
+
+def get_bot_secret(callback_url: str) -> bytes | None:
+ """Returns the bot's secret from an environment variable or from the application's configuration on the server."""
+ secret_key = __get_bot_secret(callback_url)
if secret_key in os.environ:
return os.environ[secret_key].encode("UTF-8")
secret_value = NextcloudApp().appconfig_ex.get_value(secret_key)
@@ -213,3 +333,15 @@ def get_bot_secret(callback_url: str) -> bytes | None:
os.environ[secret_key] = secret_value
return secret_value.encode("UTF-8")
return None
+
+
+async def aget_bot_secret(callback_url: str) -> bytes | None:
+ """Returns the bot's secret from an environment variable or from the application's configuration on the server."""
+ secret_key = __get_bot_secret(callback_url)
+ if secret_key in os.environ:
+ return os.environ[secret_key].encode("UTF-8")
+ secret_value = await AsyncNextcloudApp().appconfig_ex.get_value(secret_key)
+ if secret_value is not None:
+ os.environ[secret_key] = secret_value
+ return secret_value.encode("UTF-8")
+ return None
diff --git a/nc_py_api/user_status.py b/nc_py_api/user_status.py
index 4f984799..6da57d5a 100644
--- a/nc_py_api/user_status.py
+++ b/nc_py_api/user_status.py
@@ -5,7 +5,7 @@
from ._exceptions import NextcloudExceptionNotFound
from ._misc import check_capabilities, kwargs_to_params, require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -201,3 +201,99 @@ def restore_backup_status(self, status_id: str) -> CurrentUserStatus | None:
require_capabilities("user_status.restore", self._session.capabilities)
result = self._session.ocs("DELETE", f"{self._ep_base}/user_status/revert/{status_id}")
return result if result else None
+
+
+class _AsyncUserStatusAPI:
+ """Class provides async user status management API on the Nextcloud server."""
+
+ _ep_base: str = "/ocs/v1.php/apps/user_status/api/v1"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("user_status.enabled", await self._session.capabilities)
+
+ async def get_list(self, limit: int | None = None, offset: int | None = None) -> list[UserStatus]:
+ """Returns statuses for all users."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ data = kwargs_to_params(["limit", "offset"], limit=limit, offset=offset)
+ result = await self._session.ocs("GET", f"{self._ep_base}/statuses", params=data)
+ return [UserStatus(i) for i in result]
+
+ async def get_current(self) -> CurrentUserStatus:
+ """Returns the current user status."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ return CurrentUserStatus(await self._session.ocs("GET", f"{self._ep_base}/user_status"))
+
+ async def get(self, user_id: str) -> UserStatus | None:
+ """Returns the user status for the specified user."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ try:
+ return UserStatus(await self._session.ocs("GET", f"{self._ep_base}/statuses/{user_id}"))
+ except NextcloudExceptionNotFound:
+ return None
+
+ async def get_predefined(self) -> list[PredefinedStatus]:
+ """Returns a list of predefined statuses available for installation on this Nextcloud instance."""
+ if (await self._session.nc_version)["major"] < 27:
+ return []
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ result = await self._session.ocs("GET", f"{self._ep_base}/predefined_statuses")
+ return [PredefinedStatus(i) for i in result]
+
+ async def set_predefined(self, status_id: str, clear_at: int = 0) -> None:
+ """Set predefined status for the current user.
+
+ :param status_id: ``predefined`` status ID.
+ :param clear_at: *optional* time in seconds before the status is cleared.
+ """
+ if (await self._session.nc_version)["major"] < 27:
+ return
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ params: dict[str, int | str] = {"messageId": status_id}
+ if clear_at:
+ params["clearAt"] = clear_at
+ await self._session.ocs("PUT", f"{self._ep_base}/user_status/message/predefined", params=params)
+
+ async def set_status_type(self, value: typing.Literal["online", "away", "dnd", "invisible", "offline"]) -> None:
+ """Sets the status type for the current user."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ await self._session.ocs("PUT", f"{self._ep_base}/user_status/status", params={"statusType": value})
+
+ async def set_status(self, message: str | None = None, clear_at: int = 0, status_icon: str = "") -> None:
+ """Sets current user status.
+
+ :param message: Message text to set in the status.
+ :param clear_at: Unix Timestamp, representing the time to clear the status.
+ :param status_icon: The icon picked by the user (must be one emoji)
+ """
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ if message is None:
+ await self._session.ocs("DELETE", f"{self._ep_base}/user_status/message")
+ return
+ if status_icon:
+ require_capabilities("user_status.supports_emoji", await self._session.capabilities)
+ params: dict[str, int | str] = {"message": message}
+ if clear_at:
+ params["clearAt"] = clear_at
+ if status_icon:
+ params["statusIcon"] = status_icon
+ await self._session.ocs("PUT", f"{self._ep_base}/user_status/message/custom", params=params)
+
+ async def get_backup_status(self, user_id: str = "") -> UserStatus | None:
+ """Get the backup status of the user if any."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ user_id = user_id if user_id else await self._session.user
+ if not user_id:
+ raise ValueError("user_id can not be empty.")
+ return await self.get(f"_{user_id}")
+
+ async def restore_backup_status(self, status_id: str) -> CurrentUserStatus | None:
+ """Restores the backup state as current for the current user."""
+ require_capabilities("user_status.enabled", await self._session.capabilities)
+ require_capabilities("user_status.restore", await self._session.capabilities)
+ result = await self._session.ocs("DELETE", f"{self._ep_base}/user_status/revert/{status_id}")
+ return result if result else None
diff --git a/nc_py_api/users.py b/nc_py_api/users.py
index dcca1dc5..d9892c13 100644
--- a/nc_py_api/users.py
+++ b/nc_py_api/users.py
@@ -6,7 +6,7 @@
from ._exceptions import check_error
from ._misc import kwargs_to_params
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -193,17 +193,7 @@ def create(self, user_id: str, display_name: str | None = None, **kwargs) -> Non
* ``quota`` - quota for the user, if needed.
* ``language`` - default language for the user.
"""
- password = kwargs.get("password", None)
- email = kwargs.get("email", None)
- if not password and not email:
- raise ValueError("Either password or email must be set")
- data = {"userid": user_id}
- for k in ("password", "email", "groups", "subadmin", "quota", "language"):
- if k in kwargs:
- data[k] = kwargs[k]
- if display_name is not None:
- data["displayname"] = display_name
- self._session.ocs("POST", self._ep_base, json=data)
+ self._session.ocs("POST", self._ep_base, json=_create(user_id, display_name, **kwargs))
def delete(self, user_id: str) -> None:
"""Deletes user from the Nextcloud server."""
@@ -269,3 +259,124 @@ def get_avatar(
response = self._session.adapter.get(url_path)
check_error(response)
return response.content
+
+
+class _AsyncUsersAPI:
+ """The class provides the async user API on the Nextcloud server.
+
+ .. note:: In NextcloudApp mode, only ``get_list``, ``editable_fields`` and ``get_user`` methods are available.
+ """
+
+ _ep_base: str = "/ocs/v1.php/cloud/users"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ async def get_list(self, mask: str | None = "", limit: int | None = None, offset: int | None = None) -> list[str]:
+ """Returns list of user IDs."""
+ data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset)
+ response_data = await self._session.ocs("GET", self._ep_base, params=data)
+ return response_data["users"] if response_data else {}
+
+ async def get_user(self, user_id: str = "") -> UserInfo:
+ """Returns detailed user information."""
+ return UserInfo(
+ await self._session.ocs("GET", f"{self._ep_base}/{user_id}" if user_id else "/ocs/v1.php/cloud/user")
+ )
+
+ async def create(self, user_id: str, display_name: str | None = None, **kwargs) -> None:
+ """Create a new user on the Nextcloud server.
+
+ :param user_id: id of the user to create.
+ :param display_name: display name for a created user.
+ :param kwargs: See below.
+
+ Additionally supported arguments:
+
+ * ``password`` - password that should be set for user.
+ * ``email`` - email of the new user. If ``password`` is not provided, then this field should be filled.
+ * ``groups`` - list of groups IDs to which user belongs.
+ * ``subadmin`` - boolean indicating is user should be the subadmin.
+ * ``quota`` - quota for the user, if needed.
+ * ``language`` - default language for the user.
+ """
+ await self._session.ocs("POST", self._ep_base, json=_create(user_id, display_name, **kwargs))
+
+ async def delete(self, user_id: str) -> None:
+ """Deletes user from the Nextcloud server."""
+ await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}")
+
+ async def enable(self, user_id: str) -> None:
+ """Enables user on the Nextcloud server."""
+ await self._session.ocs("PUT", f"{self._ep_base}/{user_id}/enable")
+
+ async def disable(self, user_id: str) -> None:
+ """Disables user on the Nextcloud server."""
+ await self._session.ocs("PUT", f"{self._ep_base}/{user_id}/disable")
+
+ async def resend_welcome_email(self, user_id: str) -> None:
+ """Send welcome email for specified user again."""
+ await self._session.ocs("POST", f"{self._ep_base}/{user_id}/welcome")
+
+ async def editable_fields(self) -> list[str]:
+ """Returns user fields that avalaible for edit."""
+ return await self._session.ocs("GET", "/ocs/v1.php/cloud/user/fields")
+
+ async def edit(self, user_id: str, **kwargs) -> None:
+ """Edits user metadata.
+
+ :param user_id: id of the user.
+ :param kwargs: dictionary where keys are values from ``editable_fields`` method, and values to set.
+ """
+ for k, v in kwargs.items():
+ await self._session.ocs("PUT", f"{self._ep_base}/{user_id}", params={"key": k, "value": v})
+
+ async def add_to_group(self, user_id: str, group_id: str) -> None:
+ """Adds user to the group."""
+ await self._session.ocs("POST", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id})
+
+ async def remove_from_group(self, user_id: str, group_id: str) -> None:
+ """Removes user from the group."""
+ await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id})
+
+ async def promote_to_subadmin(self, user_id: str, group_id: str) -> None:
+ """Makes user admin of the group."""
+ await self._session.ocs("POST", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id})
+
+ async def demote_from_subadmin(self, user_id: str, group_id: str) -> None:
+ """Removes user from the admin role of the group."""
+ await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id})
+
+ async def get_avatar(
+ self, user_id: str = "", size: typing.Literal[64, 512] = 512, dark: bool = False, guest: bool = False
+ ) -> bytes:
+ """Returns user avatar binary data.
+
+ :param user_id: The ID of the user whose avatar should be returned.
+ .. note:: To return the current user's avatar, leave the field blank.
+ :param size: Size of the avatar. Currently supported values: ``64`` and ``512``.
+ :param dark: Flag indicating whether a dark theme avatar should be returned or not.
+ :param guest: Flag indicating whether user ID is a guest name or not.
+ """
+ if not user_id and not guest:
+ user_id = await self._session.user
+ url_path = f"/index.php/avatar/{user_id}/{size}" if not guest else f"/index.php/avatar/guest/{user_id}/{size}"
+ if dark:
+ url_path += "/dark"
+ response = await self._session.adapter.get(url_path)
+ check_error(response)
+ return response.content
+
+
+def _create(user_id: str, display_name: str | None, **kwargs) -> dict[str, typing.Any]:
+ password = kwargs.get("password", None)
+ email = kwargs.get("email", None)
+ if not password and not email:
+ raise ValueError("Either password or email must be set")
+ data = {"userid": user_id}
+ for k in ("password", "email", "groups", "subadmin", "quota", "language"):
+ if k in kwargs:
+ data[k] = kwargs[k]
+ if display_name is not None:
+ data["displayname"] = display_name
+ return data
diff --git a/nc_py_api/users_groups.py b/nc_py_api/users_groups.py
index 4db8c400..6339aa5e 100644
--- a/nc_py_api/users_groups.py
+++ b/nc_py_api/users_groups.py
@@ -3,7 +3,7 @@
import dataclasses
from ._misc import kwargs_to_params
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
@dataclasses.dataclass
@@ -96,3 +96,54 @@ def get_members(self, group_id: str) -> list[str]:
def get_subadmins(self, group_id: str) -> list[str]:
"""Returns list of users who is subadmins of the group."""
return self._session.ocs("GET", f"{self._ep_base}/{group_id}/subadmins")
+
+
+class _AsyncUsersGroupsAPI:
+ """Class provides an async API for managing user groups on the Nextcloud server.
+
+ .. note:: In NextcloudApp mode, only ``get_list`` and ``get_details`` methods are available.
+ """
+
+ _ep_base: str = "/ocs/v1.php/cloud/groups"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ async def get_list(self, mask: str | None = None, limit: int | None = None, offset: int | None = None) -> list[str]:
+ """Returns a list of user groups IDs."""
+ data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset)
+ response_data = await self._session.ocs("GET", self._ep_base, params=data)
+ return response_data["groups"] if response_data else []
+
+ async def get_details(
+ self, mask: str | None = None, limit: int | None = None, offset: int | None = None
+ ) -> list[GroupDetails]:
+ """Returns a list of user groups with detailed information."""
+ data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset)
+ response_data = await self._session.ocs("GET", f"{self._ep_base}/details", params=data)
+ return [GroupDetails(i) for i in response_data["groups"]] if response_data else []
+
+ async def create(self, group_id: str, display_name: str | None = None) -> None:
+ """Creates the users group."""
+ params = {"groupid": group_id}
+ if display_name is not None:
+ params["displayname"] = display_name
+ await self._session.ocs("POST", f"{self._ep_base}", params=params)
+
+ async def edit(self, group_id: str, display_name: str) -> None:
+ """Edits users group information."""
+ params = {"key": "displayname", "value": display_name}
+ await self._session.ocs("PUT", f"{self._ep_base}/{group_id}", params=params)
+
+ async def delete(self, group_id: str) -> None:
+ """Removes the users group."""
+ await self._session.ocs("DELETE", f"{self._ep_base}/{group_id}")
+
+ async def get_members(self, group_id: str) -> list[str]:
+ """Returns a list of group users."""
+ response_data = await self._session.ocs("GET", f"{self._ep_base}/{group_id}")
+ return response_data["users"] if response_data else {}
+
+ async def get_subadmins(self, group_id: str) -> list[str]:
+ """Returns list of users who is subadmins of the group."""
+ return await self._session.ocs("GET", f"{self._ep_base}/{group_id}/subadmins")
diff --git a/nc_py_api/weather_status.py b/nc_py_api/weather_status.py
index 987e3f20..833cce68 100644
--- a/nc_py_api/weather_status.py
+++ b/nc_py_api/weather_status.py
@@ -4,7 +4,7 @@
import enum
from ._misc import check_capabilities, require_capabilities
-from ._session import NcSessionBasic
+from ._session import AsyncNcSessionBasic, NcSessionBasic
class WeatherLocationMode(enum.IntEnum):
@@ -104,3 +104,69 @@ def set_mode(self, mode: WeatherLocationMode) -> bool:
require_capabilities("weather_status.enabled", self._session.capabilities)
result = self._session.ocs("PUT", f"{self._ep_base}/mode", params={"mode": int(mode)})
return result.get("success", False)
+
+
+class _AsyncWeatherStatusAPI:
+ """Class provides async weather status management API on the Nextcloud server."""
+
+ _ep_base: str = "/ocs/v1.php/apps/weather_status/api/v1"
+
+ def __init__(self, session: AsyncNcSessionBasic):
+ self._session = session
+
+ @property
+ async def available(self) -> bool:
+ """Returns True if the Nextcloud instance supports this feature, False otherwise."""
+ return not check_capabilities("weather_status.enabled", await self._session.capabilities)
+
+ async def get_location(self) -> WeatherLocation:
+ """Returns the current location set on the Nextcloud server for the user."""
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ return WeatherLocation(await self._session.ocs("GET", f"{self._ep_base}/location"))
+
+ async def set_location(
+ self,
+ latitude: float | None = None,
+ longitude: float | None = None,
+ address: str | None = None,
+ ) -> bool:
+ """Sets the user's location on the Nextcloud server.
+
+ :param latitude: north-south position of a point on the surface of the Earth.
+ :param longitude: east-west position of a point on the surface of the Earth.
+ :param address: city, index(*optional*) and country, e.g. "Paris, 75007, France"
+ """
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ params: dict[str, str | float] = {}
+ if latitude is not None and longitude is not None:
+ params.update({"lat": latitude, "lon": longitude})
+ elif address:
+ params["address"] = address
+ else:
+ raise ValueError("latitude & longitude or address should be present")
+ result = await self._session.ocs("PUT", f"{self._ep_base}/location", params=params)
+ return result.get("success", False)
+
+ async def get_forecast(self) -> list[dict]:
+ """Get forecast for the current location."""
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ return await self._session.ocs("GET", f"{self._ep_base}/forecast")
+
+ async def get_favorites(self) -> list[str]:
+ """Returns favorites addresses list."""
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ return await self._session.ocs("GET", f"{self._ep_base}/favorites")
+
+ async def set_favorites(self, favorites: list[str]) -> bool:
+ """Sets favorites addresses list."""
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ result = await self._session.ocs("PUT", f"{self._ep_base}/favorites", json={"favorites": favorites})
+ return result.get("success", False)
+
+ async def set_mode(self, mode: WeatherLocationMode) -> bool:
+ """Change the weather status mode."""
+ if int(mode) == WeatherLocationMode.UNKNOWN.value:
+ raise ValueError("This mode can not be set")
+ require_capabilities("weather_status.enabled", await self._session.capabilities)
+ result = await self._session.ocs("PUT", f"{self._ep_base}/mode", params={"mode": int(mode)})
+ return result.get("success", False)
diff --git a/pyproject.toml b/pyproject.toml
index 8c9c5c59..252a89df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -74,6 +74,7 @@ dev-min = [
"pre-commit",
"pylint",
"pytest",
+ "pytest-asyncio",
]
docs = [
"autodoc_pydantic>=2.0.1",
@@ -165,6 +166,7 @@ addopts = "-rs --color=yes"
markers = [
"require_nc: marks a test that requires a minimum version of Nextcloud.",
]
+asyncio_mode = "auto"
[tool.coverage.run]
cover_pylib = true
diff --git a/tests/_install_async.py b/tests/_install_async.py
index 5bd603d6..0f98939b 100644
--- a/tests/_install_async.py
+++ b/tests/_install_async.py
@@ -4,7 +4,7 @@
from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse
-from nc_py_api import NextcloudApp, ex_app
+from nc_py_api import AsyncNextcloudApp, ex_app
@asynccontextmanager
@@ -19,23 +19,23 @@ async def lifespan(_app: FastAPI):
@APP.put("/sec_check")
async def sec_check(
value: int,
- _nc: Annotated[NextcloudApp, Depends(ex_app.nc_app)],
+ _nc: Annotated[AsyncNextcloudApp, Depends(ex_app.anc_app)],
):
print(value, flush=True)
return JSONResponse(content={"error": ""}, status_code=200)
-async def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
+async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str:
print(f"enabled_handler: enabled={enabled}", flush=True)
if enabled:
- nc.log(ex_app.LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)")
+ await nc.log(ex_app.LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)")
else:
- nc.log(ex_app.LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(")
+ await nc.log(ex_app.LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(")
return ""
-def init_handler(nc: NextcloudApp):
- nc.set_init_status(100)
+async def init_handler(nc: AsyncNextcloudApp):
+ await nc.set_init_status(100)
async def heartbeat_callback():
diff --git a/tests/_install_only_enabled_handler_async.py b/tests/_install_only_enabled_handler_async.py
new file mode 100644
index 00000000..efae194f
--- /dev/null
+++ b/tests/_install_only_enabled_handler_async.py
@@ -0,0 +1,22 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from nc_py_api import AsyncNextcloudApp, ex_app
+
+
+@asynccontextmanager
+async def lifespan(_app: FastAPI):
+ ex_app.set_handlers(APP, enabled_handler)
+ yield
+
+
+APP = FastAPI(lifespan=lifespan)
+
+
+async def enabled_handler(_enabled: bool, _nc: AsyncNextcloudApp) -> str:
+ return ""
+
+
+if __name__ == "__main__":
+ ex_app.run_app("_install_only_enabled_handler_async:APP", log_level="warning")
diff --git a/tests/_talk_bot.py b/tests/_talk_bot.py
index bf097893..6d1d0173 100644
--- a/tests/_talk_bot.py
+++ b/tests/_talk_bot.py
@@ -1,3 +1,4 @@
+import os
from typing import Annotated
import gfixture_set_env # noqa
@@ -24,6 +25,7 @@ def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request:
assert isinstance(message.object_content, dict)
assert message.object_media_type in ("text/markdown", "text/plain")
assert isinstance(message.conversation_name, str)
+ assert str(message).find("conversation=") != -1
with pytest.raises(ValueError):
COVERAGE_BOT.react_to_message(message.object_id, "🥳")
with pytest.raises(ValueError):
@@ -41,11 +43,10 @@ def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request:
request._url = URL("sample_url")
talk_bot_app(request)
assert e.value.status_code == 500
- assert str(message).find("conversation=") != -1
@APP.post("/talk_bot_coverage")
-async def currency_talk_bot(
+def talk_bot_coverage(
request: Request,
message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_app)],
background_tasks: BackgroundTasks,
@@ -54,5 +55,12 @@ async def currency_talk_bot(
return requests.Response()
+# in real program this is not needed, as bot enabling handler is called in the bots process itself and will reset it.
+@APP.delete("/reset_bot_secret")
+def reset_bot_secret():
+ os.environ.pop(talk_bot.__get_bot_secret("/talk_bot_coverage"))
+ return requests.Response()
+
+
if __name__ == "__main__":
run_app("_talk_bot:APP", log_level="trace")
diff --git a/tests/_talk_bot_async.py b/tests/_talk_bot_async.py
new file mode 100644
index 00000000..6885c8e4
--- /dev/null
+++ b/tests/_talk_bot_async.py
@@ -0,0 +1,54 @@
+import os
+from typing import Annotated
+
+import gfixture_set_env # noqa
+import pytest
+import requests
+from fastapi import BackgroundTasks, Depends, FastAPI, Request
+
+from nc_py_api import talk_bot
+from nc_py_api.ex_app import atalk_bot_app, run_app
+
+APP = FastAPI()
+COVERAGE_BOT = talk_bot.AsyncTalkBot("/talk_bot_coverage", "Coverage bot", "Desc")
+
+
+async def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request: Request):
+ await COVERAGE_BOT.react_to_message(message, "🥳")
+ await COVERAGE_BOT.react_to_message(message, "🫡")
+ await COVERAGE_BOT.delete_reaction(message, "🫡")
+ await COVERAGE_BOT.send_message("Hello from bot!", message)
+ assert isinstance(message.actor_id, str)
+ assert isinstance(message.actor_display_name, str)
+ assert isinstance(message.object_name, str)
+ assert isinstance(message.object_content, dict)
+ assert message.object_media_type in ("text/markdown", "text/plain")
+ assert isinstance(message.conversation_name, str)
+ assert str(message).find("conversation=") != -1
+ with pytest.raises(ValueError):
+ await COVERAGE_BOT.react_to_message(message.object_id, "🥳")
+ with pytest.raises(ValueError):
+ await COVERAGE_BOT.delete_reaction(message.object_id, "🥳")
+ with pytest.raises(ValueError):
+ await COVERAGE_BOT.send_message("🥳", message.object_id)
+
+
+@APP.post("/talk_bot_coverage")
+async def talk_bot_coverage(
+ request: Request,
+ message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_app)],
+ background_tasks: BackgroundTasks,
+):
+ background_tasks.add_task(coverage_talk_bot_process_request, message, request)
+ return requests.Response()
+
+
+# in real program this is not needed, as bot enabling handler is called in the bots process itself and will reset it.
+@APP.delete("/reset_bot_secret")
+async def reset_bot_secret():
+ os.environ.pop(talk_bot.__get_bot_secret("/talk_bot_coverage"))
+ return requests.Response()
+
+
+if __name__ == "__main__":
+ run_app("_talk_bot_async:APP", log_level="trace")
diff --git a/tests/_tests_at_the_end.py b/tests/_tests_at_the_end.py
index 04cec84d..eea6a05e 100644
--- a/tests/_tests_at_the_end.py
+++ b/tests/_tests_at_the_end.py
@@ -2,23 +2,31 @@
import sys
from subprocess import Popen
+import pytest
+
from ._install_wait import check_heartbeat
# These tests will be run separate, and at the end of all other tests.
-def test_ex_app_enable_disable(nc_client, nc_app):
+def _test_ex_app_enable_disable(file_to_test):
child_environment = os.environ.copy()
child_environment["APP_PORT"] = os.environ.get("APP_PORT", "9009")
r = Popen(
- [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "_install_only_enabled_handler.py")],
+ [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), file_to_test)],
env=child_environment,
cwd=os.getcwd(),
)
url = f"http://127.0.0.1:{child_environment['APP_PORT']}/heartbeat"
+ return r, url
+
+
+@pytest.mark.parametrize("file_to_test", ("_install_only_enabled_handler.py", "_install_only_enabled_handler_async.py"))
+def test_ex_app_enable_disable(nc_client, nc_app, file_to_test):
+ r, url = _test_ex_app_enable_disable(file_to_test)
try:
if check_heartbeat(url, '"status":"ok"', 15, 0.3):
- raise RuntimeError("`_install_only_enabled_handler` can not start.")
+ raise RuntimeError(f"`{file_to_test}` can not start.")
if nc_client.apps.ex_app_is_enabled("nc_py_api"):
nc_client.apps.ex_app_disable("nc_py_api")
assert nc_client.apps.ex_app_is_disabled("nc_py_api") is True
@@ -28,3 +36,23 @@ def test_ex_app_enable_disable(nc_client, nc_app):
assert nc_client.apps.ex_app_is_enabled("nc_py_api") is True
finally:
r.terminate()
+ r.wait(timeout=10)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("file_to_test", ("_install_only_enabled_handler.py", "_install_only_enabled_handler_async.py"))
+async def test_ex_app_enable_disable_async(anc_client, anc_app, file_to_test):
+ r, url = _test_ex_app_enable_disable(file_to_test)
+ try:
+ if check_heartbeat(url, '"status":"ok"', 15, 0.3):
+ raise RuntimeError(f"`{file_to_test}` can not start.")
+ if await anc_client.apps.ex_app_is_enabled("nc_py_api"):
+ await anc_client.apps.ex_app_disable("nc_py_api")
+ assert await anc_client.apps.ex_app_is_disabled("nc_py_api") is True
+ assert await anc_client.apps.ex_app_is_enabled("nc_py_api") is False
+ await anc_client.apps.ex_app_enable("nc_py_api")
+ assert await anc_client.apps.ex_app_is_disabled("nc_py_api") is False
+ assert await anc_client.apps.ex_app_is_enabled("nc_py_api") is True
+ finally:
+ r.terminate()
+ r.wait(timeout=10)
diff --git a/tests/actual_tests/activity_test.py b/tests/actual_tests/activity_test.py
index 4b3c5260..8fb0f91b 100644
--- a/tests/actual_tests/activity_test.py
+++ b/tests/actual_tests/activity_test.py
@@ -2,6 +2,8 @@
import pytest
+from nc_py_api.activity import Activity
+
def test_get_filters(nc_any):
if nc_any.activity.available is False:
@@ -16,12 +18,21 @@ def test_get_filters(nc_any):
assert str(i).find("name=") != -1
-def test_get_activities(nc_any):
- if nc_any.activity.available is False:
+@pytest.mark.asyncio(scope="session")
+async def test_get_filters_async(anc_any):
+ if await anc_any.activity.available is False:
pytest.skip("Activity App is not installed")
- with pytest.raises(ValueError):
- nc_any.activity.get_activities(object_id=4)
- r = nc_any.activity.get_activities(since=True)
+ r = await anc_any.activity.get_filters()
+ assert r
+ for i in r:
+ assert i.filter_id
+ assert isinstance(i.icon, str)
+ assert i.name
+ assert isinstance(i.priority, int)
+ assert str(i).find("name=") != -1
+
+
+def _test_get_activities(r: list[Activity]):
assert r
for i in r:
assert i.activity_id
@@ -40,6 +51,15 @@ def test_get_activities(nc_any):
assert isinstance(i.icon, str)
assert i.time > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
assert str(i).find("app=") != -1
+
+
+def test_get_activities(nc_any):
+ if nc_any.activity.available is False:
+ pytest.skip("Activity App is not installed")
+ with pytest.raises(ValueError):
+ nc_any.activity.get_activities(object_id=4)
+ r = nc_any.activity.get_activities(since=True)
+ _test_get_activities(r)
r2 = nc_any.activity.get_activities(since=True)
if r2:
old_activities_id = [i.activity_id for i in r]
@@ -49,3 +69,22 @@ def test_get_activities(nc_any):
while True:
if not nc_any.activity.get_activities(since=True):
break
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_activities_async(anc_any):
+ if await anc_any.activity.available is False:
+ pytest.skip("Activity App is not installed")
+ with pytest.raises(ValueError):
+ await anc_any.activity.get_activities(object_id=4)
+ r = await anc_any.activity.get_activities(since=True)
+ _test_get_activities(r)
+ r2 = await anc_any.activity.get_activities(since=True)
+ if r2:
+ old_activities_id = [i.activity_id for i in r]
+ assert r2[0].activity_id not in old_activities_id
+ assert r2[-1].activity_id not in old_activities_id
+ assert len(await anc_any.activity.get_activities(since=0, limit=1)) == 1
+ while True:
+ if not await anc_any.activity.get_activities(since=True):
+ break
diff --git a/tests/actual_tests/appcfg_prefs_ex_test.py b/tests/actual_tests/appcfg_prefs_ex_test.py
index b73af876..209350ca 100644
--- a/tests/actual_tests/appcfg_prefs_ex_test.py
+++ b/tests/actual_tests/appcfg_prefs_ex_test.py
@@ -2,7 +2,7 @@
from nc_py_api import NextcloudExceptionNotFound
-from ..conftest import NC_APP
+from ..conftest import NC_APP, NC_APP_ASYNC
if NC_APP is None:
pytest.skip("Need App mode", allow_module_level=True)
@@ -14,6 +14,13 @@ def test_cfg_ex_get_value_invalid(class_to_test):
class_to_test.get_value("")
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_value_invalid_async(class_to_test):
+ with pytest.raises(ValueError):
+ await class_to_test.get_value("")
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_get_values_invalid(class_to_test):
assert class_to_test.get_values([]) == []
@@ -23,12 +30,29 @@ def test_cfg_ex_get_values_invalid(class_to_test):
class_to_test.get_values(["", "k"])
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_values_invalid_async(class_to_test):
+ assert await class_to_test.get_values([]) == []
+ with pytest.raises(ValueError):
+ await class_to_test.get_values([""])
+ with pytest.raises(ValueError):
+ await class_to_test.get_values(["", "k"])
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_set_empty_key(class_to_test):
with pytest.raises(ValueError):
class_to_test.set_value("", "some value")
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_set_empty_key_async(class_to_test):
+ with pytest.raises(ValueError):
+ await class_to_test.set_value("", "some value")
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_delete_invalid(class_to_test):
class_to_test.delete([])
@@ -38,11 +62,27 @@ def test_cfg_ex_delete_invalid(class_to_test):
class_to_test.delete(["", "k"])
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_delete_invalid_async(class_to_test):
+ await class_to_test.delete([])
+ with pytest.raises(ValueError):
+ await class_to_test.delete([""])
+ with pytest.raises(ValueError):
+ await class_to_test.delete(["", "k"])
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_get_default(class_to_test):
assert class_to_test.get_value("non_existing_key", default="alice") == "alice"
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_default_async(class_to_test):
+ assert await class_to_test.get_value("non_existing_key", default="alice") == "alice"
+
+
@pytest.mark.parametrize("value", ("0", "1", "12 3", ""))
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_set_delete(value, class_to_test):
@@ -56,6 +96,20 @@ def test_cfg_ex_set_delete(value, class_to_test):
assert class_to_test.get_value("test_key") is None
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("value", ("0", "1", "12 3", ""))
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_set_delete_async(value, class_to_test):
+ await class_to_test.delete("test_key")
+ assert await class_to_test.get_value("test_key") is None
+ await class_to_test.set_value("test_key", value)
+ assert await class_to_test.get_value("test_key") == value
+ await class_to_test.set_value("test_key", "zzz")
+ assert await class_to_test.get_value("test_key") == "zzz"
+ await class_to_test.delete("test_key")
+ assert await class_to_test.get_value("test_key") is None
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_delete(class_to_test):
class_to_test.set_value("test_key", "123")
@@ -70,6 +124,21 @@ def test_cfg_ex_delete(class_to_test):
class_to_test.delete(["test_key"], not_fail=False)
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_delete_async(class_to_test):
+ await class_to_test.set_value("test_key", "123")
+ assert await class_to_test.get_value("test_key")
+ await class_to_test.delete("test_key")
+ assert await class_to_test.get_value("test_key") is None
+ await class_to_test.delete("test_key")
+ await class_to_test.delete(["test_key"])
+ with pytest.raises(NextcloudExceptionNotFound):
+ await class_to_test.delete("test_key", not_fail=False)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await class_to_test.delete(["test_key"], not_fail=False)
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_get(class_to_test):
class_to_test.delete(["test key", "test key2"])
@@ -80,6 +149,17 @@ def test_cfg_ex_get(class_to_test):
assert len(class_to_test.get_values(["test key", "test key2"])) == 2
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_async(class_to_test):
+ await class_to_test.delete(["test key", "test key2"])
+ assert len(await class_to_test.get_values(["test key", "test key2"])) == 0
+ await class_to_test.set_value("test key", "123")
+ assert len(await class_to_test.get_values(["test key", "test key2"])) == 1
+ await class_to_test.set_value("test key2", "123")
+ assert len(await class_to_test.get_values(["test key", "test key2"])) == 2
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_multiply_delete(class_to_test):
class_to_test.set_value("test_key", "123")
@@ -94,6 +174,21 @@ def test_cfg_ex_multiply_delete(class_to_test):
assert len(class_to_test.get_values(["test_key", "test_key2"])) == 0
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_multiply_delete_async(class_to_test):
+ await class_to_test.set_value("test_key", "123")
+ await class_to_test.set_value("test_key2", "123")
+ assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 2
+ await class_to_test.delete(["test_key", "test_key2"])
+ assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 0
+ await class_to_test.delete(["test_key", "test_key2"])
+ await class_to_test.set_value("test_key", "123")
+ assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 1
+ await class_to_test.delete(["test_key", "test_key2"])
+ assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 0
+
+
@pytest.mark.parametrize("key", ("k", "k y", " "))
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_get_non_existing(key, class_to_test):
@@ -103,6 +198,16 @@ def test_cfg_ex_get_non_existing(key, class_to_test):
assert len(class_to_test.get_values([key, "non_existing_key"])) == 0
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("key", ("k", "k y", " "))
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_non_existing_async(key, class_to_test):
+ await class_to_test.delete(key)
+ assert await class_to_test.get_value(key) is None
+ assert await class_to_test.get_values([key]) == []
+ assert len(await class_to_test.get_values([key, "non_existing_key"])) == 0
+
+
@pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex))
def test_cfg_ex_get_typing(class_to_test):
class_to_test.set_value("test key", "123")
@@ -115,6 +220,19 @@ def test_cfg_ex_get_typing(class_to_test):
assert r[1].value == "321"
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex))
+async def test_cfg_ex_get_typing_async(class_to_test):
+ await class_to_test.set_value("test key", "123")
+ await class_to_test.set_value("test key2", "321")
+ r = await class_to_test.get_values(["test key", "test key2"])
+ assert isinstance(r, list)
+ assert r[0].key == "test key"
+ assert r[1].key == "test key2"
+ assert r[0].value == "123"
+ assert r[1].value == "321"
+
+
def test_appcfg_sensitive(nc_app):
appcfg = nc_app.appconfig_ex
appcfg.delete("test_key")
@@ -142,3 +260,33 @@ def test_appcfg_sensitive(nc_app):
assert result["configkey"] == "test_key"
assert result["configvalue"] == "123"
assert bool(result["sensitive"]) is False
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_appcfg_sensitive_async(anc_app):
+ appcfg = anc_app.appconfig_ex
+ await appcfg.delete("test_key")
+ await appcfg.set_value("test_key", "123", sensitive=True)
+ assert await appcfg.get_value("test_key") == "123"
+ assert (await appcfg.get_values(["test_key"]))[0].value == "123"
+ await appcfg.delete("test_key")
+ # next code tests `sensitive` value from the `AppAPI`
+ params = {"configKey": "test_key", "configValue": "123"}
+ result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params)
+ assert not result["sensitive"] # by default if sensitive value is unspecified it is False
+ await appcfg.delete("test_key")
+ params = {"configKey": "test_key", "configValue": "123", "sensitive": True}
+ result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params)
+ assert result["configkey"] == "test_key"
+ assert result["configvalue"] == "123"
+ assert bool(result["sensitive"]) is True
+ params.pop("sensitive") # if we not specify value, AppEcosystem should not change it.
+ result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params)
+ assert result["configkey"] == "test_key"
+ assert result["configvalue"] == "123"
+ assert bool(result["sensitive"]) is True
+ params["sensitive"] = False
+ result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params)
+ assert result["configkey"] == "test_key"
+ assert result["configvalue"] == "123"
+ assert bool(result["sensitive"]) is False
diff --git a/tests/actual_tests/apps_test.py b/tests/actual_tests/apps_test.py
index aa8a283f..d912f6bd 100644
--- a/tests/actual_tests/apps_test.py
+++ b/tests/actual_tests/apps_test.py
@@ -2,6 +2,8 @@
import pytest
+from nc_py_api.apps import ExAppInfo
+
APP_NAME = "files_trashbin"
@@ -11,12 +13,26 @@ def test_list_apps_types(nc):
assert isinstance(nc.apps.get_list(enabled=False), list)
+@pytest.mark.asyncio(scope="session")
+async def test_list_apps_types_async(anc):
+ assert isinstance(await anc.apps.get_list(), list)
+ assert isinstance(await anc.apps.get_list(enabled=True), list)
+ assert isinstance(await anc.apps.get_list(enabled=False), list)
+
+
def test_list_apps(nc):
apps = nc.apps.get_list()
assert apps
assert APP_NAME in apps
+@pytest.mark.asyncio(scope="session")
+async def test_list_apps_async(anc):
+ apps = await anc.apps.get_list()
+ assert apps
+ assert APP_NAME in apps
+
+
def test_app_enable_disable(nc_client):
assert nc_client.apps.is_installed(APP_NAME) is True
if nc_client.apps.is_enabled(APP_NAME):
@@ -29,11 +45,30 @@ def test_app_enable_disable(nc_client):
assert nc_client.apps.is_installed(APP_NAME) is True
+@pytest.mark.asyncio(scope="session")
+async def test_app_enable_disable_async(anc_client):
+ assert await anc_client.apps.is_installed(APP_NAME) is True
+ if await anc_client.apps.is_enabled(APP_NAME):
+ await anc_client.apps.disable(APP_NAME)
+ assert await anc_client.apps.is_disabled(APP_NAME) is True
+ assert await anc_client.apps.is_enabled(APP_NAME) is False
+ assert await anc_client.apps.is_installed(APP_NAME) is True
+ await anc_client.apps.enable(APP_NAME)
+ assert await anc_client.apps.is_enabled(APP_NAME) is True
+ assert await anc_client.apps.is_installed(APP_NAME) is True
+
+
def test_is_installed_enabled(nc):
assert nc.apps.is_enabled(APP_NAME) != nc.apps.is_disabled(APP_NAME)
assert nc.apps.is_installed(APP_NAME)
+@pytest.mark.asyncio(scope="session")
+async def test_is_installed_enabled_async(anc):
+ assert await anc.apps.is_enabled(APP_NAME) != await anc.apps.is_disabled(APP_NAME)
+ assert await anc.apps.is_installed(APP_NAME)
+
+
def test_invalid_param(nc_any):
with pytest.raises(ValueError):
nc_any.apps.is_enabled("")
@@ -55,13 +90,29 @@ def test_invalid_param(nc_any):
nc_any.apps.ex_app_enable("")
-def test_ex_app_get_list(nc, nc_app):
- enabled_ex_apps = nc.apps.ex_app_get_list(enabled=True)
- assert isinstance(enabled_ex_apps, list)
- for i in enabled_ex_apps:
- assert i.enabled is True
- assert "nc_py_api" in [i.app_id for i in enabled_ex_apps]
- ex_apps = nc.apps.ex_app_get_list()
+@pytest.mark.asyncio(scope="session")
+async def test_invalid_param_async(anc_any):
+ with pytest.raises(ValueError):
+ await anc_any.apps.is_enabled("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.is_installed("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.is_disabled("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.enable("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.disable("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.ex_app_is_enabled("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.ex_app_is_disabled("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.ex_app_disable("")
+ with pytest.raises(ValueError):
+ await anc_any.apps.ex_app_enable("")
+
+
+def _test_ex_app_get_list(ex_apps: list[ExAppInfo], enabled_ex_apps: list[ExAppInfo]):
assert isinstance(ex_apps, list)
assert "nc_py_api" in [i.app_id for i in ex_apps]
assert len(ex_apps) >= len(enabled_ex_apps)
@@ -75,3 +126,24 @@ def test_ex_app_get_list(nc, nc_app):
if app.app_id == "nc_py_api":
assert app.system is True
assert str(app).find("id=") != -1 and str(app).find("ver=") != -1
+
+
+def test_ex_app_get_list(nc, nc_app):
+ enabled_ex_apps = nc.apps.ex_app_get_list(enabled=True)
+ assert isinstance(enabled_ex_apps, list)
+ for i in enabled_ex_apps:
+ assert i.enabled is True
+ assert "nc_py_api" in [i.app_id for i in enabled_ex_apps]
+ ex_apps = nc.apps.ex_app_get_list()
+ _test_ex_app_get_list(ex_apps, enabled_ex_apps)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_ex_app_get_list_async(anc, anc_app):
+ enabled_ex_apps = await anc.apps.ex_app_get_list(enabled=True)
+ assert isinstance(enabled_ex_apps, list)
+ for i in enabled_ex_apps:
+ assert i.enabled is True
+ assert "nc_py_api" in [i.app_id for i in enabled_ex_apps]
+ ex_apps = await anc.apps.ex_app_get_list()
+ _test_ex_app_get_list(ex_apps, enabled_ex_apps)
diff --git a/tests/actual_tests/files_sharing_test.py b/tests/actual_tests/files_sharing_test.py
index 411ba5af..a5fef164 100644
--- a/tests/actual_tests/files_sharing_test.py
+++ b/tests/actual_tests/files_sharing_test.py
@@ -5,6 +5,7 @@
from nc_py_api import (
FilePermissions,
+ FsNode,
Nextcloud,
NextcloudException,
NextcloudExceptionNotFound,
@@ -17,6 +18,11 @@ def test_available(nc_any):
assert nc_any.files.sharing.available
+@pytest.mark.asyncio(scope="session")
+async def test_available_async(anc_any):
+ assert await anc_any.files.sharing.available
+
+
def test_create_delete(nc_any):
new_share = nc_any.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK)
nc_any.files.sharing.delete(new_share)
@@ -24,30 +30,53 @@ def test_create_delete(nc_any):
nc_any.files.sharing.delete(new_share)
+@pytest.mark.asyncio(scope="session")
+async def test_create_delete_async(anc_any):
+ new_share = await anc_any.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK)
+ await anc_any.files.sharing.delete(new_share)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_any.files.sharing.delete(new_share)
+
+
+def _test_share_fields(new_share: Share, get_by_id: Share, shared_file: FsNode):
+ assert new_share.share_type == ShareType.TYPE_LINK
+ assert not new_share.label
+ assert not new_share.note
+ assert new_share.mimetype.find("text") != -1
+ assert new_share.permissions & FilePermissions.PERMISSION_READ
+ assert new_share.url
+ assert new_share.path == shared_file.user_path
+ assert get_by_id.share_id == new_share.share_id
+ assert get_by_id.path == new_share.path
+ assert get_by_id.mimetype == new_share.mimetype
+ assert get_by_id.share_type == new_share.share_type
+ assert get_by_id.file_owner == new_share.file_owner
+ assert get_by_id.share_owner == new_share.share_owner
+ assert not get_by_id.share_with
+ assert str(get_by_id) == str(new_share)
+
+
def test_share_fields(nc_any):
shared_file = nc_any.files.by_path("test_12345_text.txt")
new_share = nc_any.files.sharing.create(shared_file, ShareType.TYPE_LINK, FilePermissions.PERMISSION_READ)
try:
get_by_id = nc_any.files.sharing.get_by_id(new_share.share_id)
- assert new_share.share_type == ShareType.TYPE_LINK
- assert not new_share.label
- assert not new_share.note
- assert new_share.mimetype.find("text") != -1
- assert new_share.permissions & FilePermissions.PERMISSION_READ
- assert new_share.url
- assert new_share.path == shared_file.user_path
- assert get_by_id.share_id == new_share.share_id
- assert get_by_id.path == new_share.path
- assert get_by_id.mimetype == new_share.mimetype
- assert get_by_id.share_type == new_share.share_type
- assert get_by_id.file_owner == new_share.file_owner
- assert get_by_id.share_owner == new_share.share_owner
- assert not get_by_id.share_with
- assert str(get_by_id) == str(new_share)
+ _test_share_fields(new_share, get_by_id, shared_file)
finally:
nc_any.files.sharing.delete(new_share)
+@pytest.mark.asyncio(scope="session")
+async def test_share_fields_async(anc_any):
+ shared_file = await anc_any.files.by_path("test_12345_text.txt")
+ new_share = await anc_any.files.sharing.create(shared_file, ShareType.TYPE_LINK, FilePermissions.PERMISSION_READ)
+ try:
+ get_by_id = await anc_any.files.sharing.get_by_id(new_share.share_id)
+ _test_share_fields(new_share, get_by_id, shared_file)
+ finally:
+ await anc_any.files.sharing.delete(new_share)
+
+
def test_create_permissions(nc_any):
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE)
nc_any.files.sharing.delete(new_share)
@@ -69,6 +98,34 @@ def test_create_permissions(nc_any):
)
+@pytest.mark.asyncio(scope="session")
+async def test_create_permissions_async(anc_any):
+ new_share = await anc_any.files.sharing.create(
+ "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE
+ )
+ await anc_any.files.sharing.delete(new_share)
+ assert (
+ new_share.permissions
+ == FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
+ )
+ new_share = await anc_any.files.sharing.create(
+ "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_DELETE
+ )
+ await anc_any.files.sharing.delete(new_share)
+ assert (
+ new_share.permissions
+ == FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_DELETE | FilePermissions.PERMISSION_SHARE
+ )
+ new_share = await anc_any.files.sharing.create(
+ "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_UPDATE
+ )
+ await anc_any.files.sharing.delete(new_share)
+ assert (
+ new_share.permissions
+ == FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_UPDATE | FilePermissions.PERMISSION_SHARE
+ )
+
+
def test_create_public_upload(nc_any):
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, public_upload=True)
nc_any.files.sharing.delete(new_share)
@@ -82,6 +139,20 @@ def test_create_public_upload(nc_any):
)
+@pytest.mark.asyncio(scope="session")
+async def test_create_public_upload_async(anc_any):
+ new_share = await anc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, public_upload=True)
+ await anc_any.files.sharing.delete(new_share)
+ assert (
+ new_share.permissions
+ == FilePermissions.PERMISSION_READ
+ | FilePermissions.PERMISSION_UPDATE
+ | FilePermissions.PERMISSION_SHARE
+ | FilePermissions.PERMISSION_DELETE
+ | FilePermissions.PERMISSION_CREATE
+ )
+
+
def test_create_password(nc):
if nc.check_capabilities("spreed"):
pytest.skip(reason="Talk is not installed.")
@@ -97,6 +168,22 @@ def test_create_password(nc):
assert new_share.send_password_by_talk is True
+@pytest.mark.asyncio(scope="session")
+async def test_create_password_async(anc):
+ if await anc.check_capabilities("spreed"):
+ pytest.skip(reason="Talk is not installed.")
+ new_share = await anc.files.sharing.create("test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1")
+ await anc.files.sharing.delete(new_share)
+ assert new_share.password
+ assert new_share.send_password_by_talk is False
+ new_share = await anc.files.sharing.create(
+ "test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1", send_password_by_talk=True
+ )
+ await anc.files.sharing.delete(new_share)
+ assert new_share.password
+ assert new_share.send_password_by_talk is True
+
+
def test_create_note_label(nc_any):
new_share = nc_any.files.sharing.create(
"test_empty_text.txt", ShareType.TYPE_LINK, note="This is note", label="label"
@@ -106,6 +193,16 @@ def test_create_note_label(nc_any):
assert new_share.label == "label"
+@pytest.mark.asyncio(scope="session")
+async def test_create_note_label_async(anc_any):
+ new_share = await anc_any.files.sharing.create(
+ "test_empty_text.txt", ShareType.TYPE_LINK, note="This is note", label="label"
+ )
+ await anc_any.files.sharing.delete(new_share)
+ assert new_share.note == "This is note"
+ assert new_share.label == "label"
+
+
def test_create_expire_time(nc):
expire_time = datetime.datetime.now() + datetime.timedelta(days=1)
expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -121,6 +218,30 @@ def test_create_expire_time(nc):
assert new_share2.expire_date == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
+@pytest.mark.asyncio(scope="session")
+async def test_create_expire_time_async(anc):
+ expire_time = datetime.datetime.now() + datetime.timedelta(days=1)
+ expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
+ new_share = await anc.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK, expire_date=expire_time)
+ await anc.files.sharing.delete(new_share)
+ assert new_share.expire_date == expire_time
+ with pytest.raises(NextcloudException):
+ await anc.files.sharing.create(
+ "test_12345_text.txt", ShareType.TYPE_LINK, expire_date=datetime.datetime.now() - datetime.timedelta(days=1)
+ )
+ new_share.raw_data["expiration"] = "invalid time"
+ new_share2 = Share(new_share.raw_data)
+ assert new_share2.expire_date == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
+
+
+def _test_get_list(share_by_id: Share, shares_list: list[Share]):
+ assert share_by_id.share_owner == shares_list[-1].share_owner
+ assert share_by_id.mimetype == shares_list[-1].mimetype
+ assert share_by_id.password == shares_list[-1].password
+ assert share_by_id.permissions == shares_list[-1].permissions
+ assert share_by_id.url == shares_list[-1].url
+
+
def test_get_list(nc):
shared_file = nc.files.by_path("test_12345_text.txt")
result = nc.files.sharing.get_list()
@@ -133,11 +254,23 @@ def test_get_list(nc):
share_by_id = nc.files.sharing.get_by_id(shares_list[-1].share_id)
nc.files.sharing.delete(new_share)
assert n_shares == len(nc.files.sharing.get_list())
- assert share_by_id.share_owner == shares_list[-1].share_owner
- assert share_by_id.mimetype == shares_list[-1].mimetype
- assert share_by_id.password == shares_list[-1].password
- assert share_by_id.permissions == shares_list[-1].permissions
- assert share_by_id.url == shares_list[-1].url
+ _test_get_list(share_by_id, shares_list)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_list_async(anc):
+ shared_file = await anc.files.by_path("test_12345_text.txt")
+ result = await anc.files.sharing.get_list()
+ assert isinstance(result, list)
+ n_shares = len(result)
+ new_share = await anc.files.sharing.create(shared_file, ShareType.TYPE_LINK)
+ assert isinstance(new_share, Share)
+ shares_list = await anc.files.sharing.get_list()
+ assert n_shares + 1 == len(shares_list)
+ share_by_id = await anc.files.sharing.get_by_id(shares_list[-1].share_id)
+ await anc.files.sharing.delete(new_share)
+ assert n_shares == len(await anc.files.sharing.get_list())
+ _test_get_list(share_by_id, shares_list)
def test_create_update(nc):
@@ -172,6 +305,39 @@ def test_create_update(nc):
nc.files.sharing.delete(new_share)
+@pytest.mark.asyncio(scope="session")
+async def test_create_update_async(anc):
+ if await anc.check_capabilities("spreed"):
+ pytest.skip(reason="Talk is not installed.")
+ new_share = await anc.files.sharing.create(
+ "test_empty_dir",
+ ShareType.TYPE_LINK,
+ permissions=FilePermissions.PERMISSION_READ
+ + FilePermissions.PERMISSION_SHARE
+ + FilePermissions.PERMISSION_UPDATE,
+ )
+ update_share = await anc.files.sharing.update(new_share, password="s2dDS_z44ad1")
+ assert update_share.password
+ assert update_share.permissions != FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE
+ update_share = await anc.files.sharing.update(
+ new_share, permissions=FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE
+ )
+ assert update_share.password
+ assert update_share.permissions == FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE
+ assert update_share.send_password_by_talk is False
+ update_share = await anc.files.sharing.update(new_share, send_password_by_talk=True, public_upload=True)
+ assert update_share.password
+ assert update_share.send_password_by_talk is True
+ expire_time = datetime.datetime.now() + datetime.timedelta(days=1)
+ expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
+ update_share = await anc.files.sharing.update(new_share, expire_date=expire_time)
+ assert update_share.expire_date == expire_time
+ update_share = await anc.files.sharing.update(new_share, note="note", label="label")
+ assert update_share.note == "note"
+ assert update_share.label == "label"
+ await anc.files.sharing.delete(new_share)
+
+
def test_get_inherited(nc_any):
new_share = nc_any.files.sharing.create("test_dir/subdir", ShareType.TYPE_LINK)
assert not nc_any.files.sharing.get_inherited("test_dir")
@@ -181,6 +347,20 @@ def test_get_inherited(nc_any):
assert new_share.share_owner == new_share2.share_owner
assert new_share.file_owner == new_share2.file_owner
assert new_share.url == new_share2.url
+ nc_any.files.sharing.delete(new_share)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_inherited_async(anc_any):
+ new_share = await anc_any.files.sharing.create("test_dir/subdir", ShareType.TYPE_LINK)
+ assert not await anc_any.files.sharing.get_inherited("test_dir")
+ assert not await anc_any.files.sharing.get_inherited("test_dir/subdir")
+ new_share2 = (await anc_any.files.sharing.get_inherited("test_dir/subdir/test_12345_text.txt"))[0]
+ assert new_share.share_id == new_share2.share_id
+ assert new_share.share_owner == new_share2.share_owner
+ assert new_share.file_owner == new_share2.file_owner
+ assert new_share.url == new_share2.url
+ await anc_any.files.sharing.delete(new_share)
def test_share_with(nc, nc_client):
@@ -204,6 +384,28 @@ def test_share_with(nc, nc_client):
assert not nc_second_user.files.sharing.get_list()
+@pytest.mark.asyncio(scope="session")
+async def test_share_with_async(anc, anc_client):
+ nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"])
+ assert not nc_second_user.files.sharing.get_list()
+ shared_file = await anc.files.by_path("test_empty_text.txt")
+ folder_share = await anc.files.sharing.create(
+ "test_empty_dir_in_dir", ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"]
+ )
+ file_share = await anc.files.sharing.create(shared_file, ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"])
+ shares_list1 = await anc.files.sharing.get_list(path="test_empty_dir_in_dir/")
+ shares_list2 = await anc.files.sharing.get_list(path="test_empty_text.txt")
+ second_user_shares_list = nc_second_user.files.sharing.get_list()
+ second_user_shares_list_with_me = nc_second_user.files.sharing.get_list(shared_with_me=True)
+ await anc.files.sharing.delete(folder_share)
+ await anc.files.sharing.delete(file_share)
+ assert not second_user_shares_list
+ assert len(second_user_shares_list_with_me) == 2
+ assert len(shares_list1) == 1
+ assert len(shares_list2) == 1
+ assert not nc_second_user.files.sharing.get_list()
+
+
def test_pending(nc_any):
assert isinstance(nc_any.files.sharing.get_pending(), list)
with pytest.raises(NextcloudExceptionNotFound):
@@ -212,7 +414,23 @@ def test_pending(nc_any):
nc_any.files.sharing.decline_share(99999999)
+@pytest.mark.asyncio(scope="session")
+async def test_pending_async(anc_any):
+ assert isinstance(await anc_any.files.sharing.get_pending(), list)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_any.files.sharing.accept_share(99999999)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_any.files.sharing.decline_share(99999999)
+
+
def test_deleted(nc_any):
assert isinstance(nc_any.files.sharing.get_deleted(), list)
with pytest.raises(NextcloudExceptionNotFound):
nc_any.files.sharing.undelete(99999999)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_deleted_async(anc_any):
+ assert isinstance(await anc_any.files.sharing.get_deleted(), list)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_any.files.sharing.undelete(99999999)
diff --git a/tests/actual_tests/files_test.py b/tests/actual_tests/files_test.py
index 9ba3bf5c..720dc630 100644
--- a/tests/actual_tests/files_test.py
+++ b/tests/actual_tests/files_test.py
@@ -4,6 +4,7 @@
import zipfile
from datetime import datetime
from io import BytesIO
+from pathlib import Path
from random import choice, randbytes
from string import ascii_lowercase
from tempfile import NamedTemporaryFile
@@ -29,46 +30,81 @@ def write(self, *args, **kwargs):
return super().write(*args, **kwargs)
-def test_list_user_root(nc):
- user_root = nc.files.listdir()
+def _test_list_user_root(user_root: list[FsNode], user: str):
assert user_root
for obj in user_root:
- assert obj.user == nc.user
+ assert obj.user == user
assert obj.has_extra
assert obj.name
assert obj.user_path
assert obj.file_id
assert obj.etag
+
+
+def test_list_user_root(nc):
+ user_root = nc.files.listdir()
+ _test_list_user_root(user_root, nc.user)
root_node = FsNode(full_path=f"files/{nc.user}/")
user_root2 = nc.files.listdir(root_node)
assert user_root == user_root2
-def test_list_user_root_self_exclude(nc):
- user_root = nc.files.listdir()
- user_root_with_self = nc.files.listdir(exclude_self=False)
+@pytest.mark.asyncio(scope="session")
+async def test_list_user_root_async(anc):
+ user_root = await anc.files.listdir()
+ _test_list_user_root(user_root, await anc.user)
+ root_node = FsNode(full_path=f"files/{await anc.user}/")
+ user_root2 = await anc.files.listdir(root_node)
+ assert user_root == user_root2
+
+
+def _test_list_user_root_self_exclude(user_root: list[FsNode], user_root_with_self: list[FsNode], user: str):
assert len(user_root_with_self) == 1 + len(user_root)
self_res = next(i for i in user_root_with_self if not i.user_path)
for i in user_root:
assert self_res != i
assert self_res.has_extra
assert self_res.file_id
- assert self_res.user == nc.user
+ assert self_res.user == user
assert self_res.name
assert self_res.etag
- assert self_res.full_path == f"files/{nc.user}/"
+ assert self_res.full_path == f"files/{user}/"
-def test_list_empty_dir(nc_any):
- assert not len(nc_any.files.listdir("test_empty_dir"))
- result = nc_any.files.listdir("test_empty_dir", exclude_self=False)
+def test_list_user_root_self_exclude(nc):
+ user_root = nc.files.listdir()
+ user_root_with_self = nc.files.listdir(exclude_self=False)
+ _test_list_user_root_self_exclude(user_root, user_root_with_self, nc.user)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_list_user_root_self_exclude_async(anc):
+ user_root = await anc.files.listdir()
+ user_root_with_self = await anc.files.listdir(exclude_self=False)
+ _test_list_user_root_self_exclude(user_root, user_root_with_self, await anc.user)
+
+
+def _test_list_empty_dir(result: list[FsNode], user: str):
assert len(result)
result = result[0]
assert result.file_id
- assert result.user == nc_any.user
+ assert result.user == user
assert result.name == "test_empty_dir"
assert result.etag
- assert result.full_path == f"files/{nc_any.user}/test_empty_dir/"
+ assert result.full_path == f"files/{user}/test_empty_dir/"
+
+
+def test_list_empty_dir(nc_any):
+ assert not len(nc_any.files.listdir("test_empty_dir"))
+ result = nc_any.files.listdir("test_empty_dir", exclude_self=False)
+ _test_list_empty_dir(result, nc_any.user)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_list_empty_dir_async(anc_any):
+ assert not len(await anc_any.files.listdir("test_empty_dir"))
+ result = await anc_any.files.listdir("test_empty_dir", exclude_self=False)
+ _test_list_empty_dir(result, await anc_any.user)
def test_list_dir_wrong_args(nc_any):
@@ -76,16 +112,33 @@ def test_list_dir_wrong_args(nc_any):
nc_any.files.listdir(depth=0, exclude_self=True)
-def test_by_path(nc_any):
- result = nc_any.files.by_path("")
- result2 = nc_any.files.by_path("/")
+@pytest.mark.asyncio(scope="session")
+async def test_list_dir_wrong_args_async(anc_any):
+ with pytest.raises(ValueError):
+ await anc_any.files.listdir(depth=0, exclude_self=True)
+
+
+def _test_by_path(result: FsNode, result2: FsNode, user: str):
assert isinstance(result, FsNode)
assert isinstance(result2, FsNode)
assert result == result2
assert result.is_dir == result2.is_dir
assert result.is_dir
assert result.user == result2.user
- assert result.user == nc_any.user
+ assert result.user == user
+
+
+def test_by_path(nc_any):
+ result = nc_any.files.by_path("")
+ result2 = nc_any.files.by_path("/")
+ _test_by_path(result, result2, nc_any.user)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_by_path_async(anc_any):
+ result = await anc_any.files.by_path("")
+ result2 = await anc_any.files.by_path("/")
+ _test_by_path(result, result2, await anc_any.user)
def test_file_download(nc_any):
@@ -93,23 +146,49 @@ def test_file_download(nc_any):
assert nc_any.files.download("/test_12345_text.txt") == b"12345"
+@pytest.mark.asyncio(scope="session")
+async def test_file_download_async(anc_any):
+ assert await anc_any.files.download("test_empty_text.txt") == b""
+ assert await anc_any.files.download("/test_12345_text.txt") == b"12345"
+
+
@pytest.mark.parametrize("data_type", ("str", "bytes"))
@pytest.mark.parametrize("chunk_size", (15, 32, 64, None))
def test_file_download2stream(nc, data_type, chunk_size):
- srv_admin_manual_buf = MyBytesIO()
+ bytes_io_fp = MyBytesIO()
content = "".join(choice(ascii_lowercase) for _ in range(64)) if data_type == "str" else randbytes(64)
- nc.files.upload("/test_dir_tmp/test_file_download2stream", content=content)
+ nc.files.upload("/test_dir_tmp/download2stream", content=content)
old_headers = nc.response_headers
if chunk_size is not None:
- nc.files.download2stream("/test_dir_tmp/test_file_download2stream", srv_admin_manual_buf, chunk_size=chunk_size)
+ nc.files.download2stream("/test_dir_tmp/download2stream", bytes_io_fp, chunk_size=chunk_size)
else:
- nc.files.download2stream("/test_dir_tmp/test_file_download2stream", srv_admin_manual_buf)
+ nc.files.download2stream("/test_dir_tmp/download2stream", bytes_io_fp)
assert nc.response_headers != old_headers
- assert nc.files.download("/test_dir_tmp/test_file_download2stream") == srv_admin_manual_buf.getbuffer()
+ assert nc.files.download("/test_dir_tmp/download2stream") == bytes_io_fp.getbuffer()
+ if chunk_size is None:
+ assert bytes_io_fp.n_write_calls == 1
+ else:
+ assert bytes_io_fp.n_write_calls == math.ceil(64 / chunk_size)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("data_type", ("str", "bytes"))
+@pytest.mark.parametrize("chunk_size", (15, 32, 64, None))
+async def test_file_download2stream_async(anc, data_type, chunk_size):
+ bytes_io_fp = MyBytesIO()
+ content = "".join(choice(ascii_lowercase) for _ in range(64)) if data_type == "str" else randbytes(64)
+ await anc.files.upload("/test_dir_tmp/download2stream_async", content=content)
+ old_headers = anc.response_headers
+ if chunk_size is not None:
+ await anc.files.download2stream("/test_dir_tmp/download2stream_async", bytes_io_fp, chunk_size=chunk_size)
+ else:
+ await anc.files.download2stream("/test_dir_tmp/download2stream_async", bytes_io_fp)
+ assert anc.response_headers != old_headers
+ assert await anc.files.download("/test_dir_tmp/download2stream_async") == bytes_io_fp.getbuffer()
if chunk_size is None:
- assert srv_admin_manual_buf.n_write_calls == 1
+ assert bytes_io_fp.n_write_calls == 1
else:
- assert srv_admin_manual_buf.n_write_calls == math.ceil(64 / chunk_size)
+ assert bytes_io_fp.n_write_calls == math.ceil(64 / chunk_size)
def test_file_download2file(nc_any, rand_bytes):
@@ -118,6 +197,13 @@ def test_file_download2file(nc_any, rand_bytes):
assert tmp_file.read() == rand_bytes
+@pytest.mark.asyncio(scope="session")
+async def test_file_download2file_async(anc_any, rand_bytes):
+ with NamedTemporaryFile() as tmp_file:
+ await anc_any.files.download2stream("test_64_bytes.bin", tmp_file.name)
+ assert tmp_file.read() == rand_bytes
+
+
def test_file_download2stream_invalid_type(nc_any):
for test_type in (
b"13",
@@ -127,6 +213,16 @@ def test_file_download2stream_invalid_type(nc_any):
nc_any.files.download2stream("xxx", test_type)
+@pytest.mark.asyncio(scope="session")
+async def test_file_download2stream_invalid_type_async(anc_any):
+ for test_type in (
+ b"13",
+ int(55),
+ ):
+ with pytest.raises(TypeError):
+ await anc_any.files.download2stream("xxx", test_type)
+
+
def test_file_upload_stream_invalid_type(nc_any):
for test_type in (
b"13",
@@ -136,6 +232,16 @@ def test_file_upload_stream_invalid_type(nc_any):
nc_any.files.upload_stream("xxx", test_type)
+@pytest.mark.asyncio(scope="session")
+async def test_file_upload_stream_invalid_type_async(anc_any):
+ for test_type in (
+ b"13",
+ int(55),
+ ):
+ with pytest.raises(TypeError):
+ await anc_any.files.upload_stream("xxx", test_type)
+
+
def test_file_download_not_found(nc_any):
with pytest.raises(NextcloudException):
nc_any.files.download("file that does not exist on the server")
@@ -143,6 +249,14 @@ def test_file_download_not_found(nc_any):
nc_any.files.listdir("non existing path")
+@pytest.mark.asyncio(scope="session")
+async def test_file_download_not_found_async(anc_any):
+ with pytest.raises(NextcloudException):
+ await anc_any.files.download("file that does not exist on the server")
+ with pytest.raises(NextcloudException):
+ await anc_any.files.listdir("non existing path")
+
+
def test_file_download2stream_not_found(nc_any):
buf = BytesIO()
with pytest.raises(NextcloudException):
@@ -151,6 +265,15 @@ def test_file_download2stream_not_found(nc_any):
nc_any.files.download2stream("non existing path", buf)
+@pytest.mark.asyncio(scope="session")
+async def test_file_download2stream_not_found_async(anc_any):
+ buf = BytesIO()
+ with pytest.raises(NextcloudException):
+ await anc_any.files.download2stream("file that does not exist on the server", buf)
+ with pytest.raises(NextcloudException):
+ await anc_any.files.download2stream("non existing path", buf)
+
+
def test_file_upload(nc_any):
file_name = "test_dir_tmp/12345.txt"
result = nc_any.files.upload(file_name, content=b"\x31\x32")
@@ -167,6 +290,23 @@ def test_file_upload(nc_any):
assert nc_any.files.download(file_name).decode("utf-8") == "life is good"
+@pytest.mark.asyncio(scope="session")
+async def test_file_upload_async(anc_any):
+ file_name = "test_dir_tmp/12345_async.txt"
+ result = await anc_any.files.upload(file_name, content=b"\x31\x32")
+ assert (await anc_any.files.by_id(result)).info.size == 2
+ assert await anc_any.files.download(file_name) == b"\x31\x32"
+ result = await anc_any.files.upload(f"/{file_name}", content=b"\x31\x32\x33")
+ assert not result.has_extra
+ result = await anc_any.files.by_path(result)
+ assert result.info.size == 3
+ assert result.is_updatable
+ assert not result.is_creatable
+ assert await anc_any.files.download(file_name) == b"\x31\x32\x33"
+ await anc_any.files.upload(file_name, content="life is good")
+ assert (await anc_any.files.download(file_name)).decode("utf-8") == "life is good"
+
+
@pytest.mark.parametrize("chunk_size", (63, 64, 65, None))
def test_file_upload_chunked(nc, chunk_size):
file_name = "/test_dir_tmp/chunked.bin"
@@ -192,6 +332,32 @@ def test_file_upload_chunked(nc, chunk_size):
assert upload_crc == download_crc
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("chunk_size", (63, 64, 65, None))
+async def test_file_upload_chunked_async(anc, chunk_size):
+ file_name = "/test_dir_tmp/chunked_async.bin"
+ buf_upload = MyBytesIO()
+ random_bytes = randbytes(64)
+ buf_upload.write(random_bytes)
+ buf_upload.seek(0)
+ if chunk_size is None:
+ result = await anc.files.upload_stream(file_name, fp=buf_upload)
+ else:
+ result = await anc.files.upload_stream(file_name, fp=buf_upload, chunk_size=chunk_size)
+ if chunk_size is None:
+ assert buf_upload.n_read_calls == 2
+ else:
+ assert buf_upload.n_read_calls == 1 + math.ceil(64 / chunk_size)
+ assert (await anc.files.by_id(result.file_id)).info.size == 64
+ buf_download = BytesIO()
+ await anc.files.download2stream(file_name, fp=buf_download)
+ buf_upload.seek(0)
+ buf_download.seek(0)
+ upload_crc = adler32(buf_upload.read())
+ download_crc = adler32(buf_download.read())
+ assert upload_crc == download_crc
+
+
def test_file_upload_file(nc_any):
content = randbytes(113)
with NamedTemporaryFile() as tmp_file:
@@ -201,7 +367,17 @@ def test_file_upload_file(nc_any):
assert nc_any.files.download("test_dir_tmp/test_file_upload_file") == content
-@pytest.mark.parametrize("dest_path", ("test_dir_tmp/test_file_upl_chunk_v2", "test_dir_tmp/test_file_upl_chunk_v2_ü"))
+@pytest.mark.asyncio(scope="session")
+async def test_file_upload_file_async(anc_any):
+ content = randbytes(113)
+ with NamedTemporaryFile() as tmp_file:
+ tmp_file.write(content)
+ tmp_file.flush()
+ await anc_any.files.upload_stream("test_dir_tmp/test_file_upload_file_async", tmp_file.name)
+ assert await anc_any.files.download("test_dir_tmp/test_file_upload_file_async") == content
+
+
+@pytest.mark.parametrize("dest_path", ("test_dir_tmp/upl_chunk_v2", "test_dir_tmp/upl_chunk_v2_ü"))
def test_file_upload_chunked_v2(nc_any, dest_path):
with NamedTemporaryFile() as tmp_file:
tmp_file.seek(7 * 1024 * 1024)
@@ -211,29 +387,69 @@ def test_file_upload_chunked_v2(nc_any, dest_path):
assert len(nc_any.files.download(dest_path)) == 7 * 1024 * 1024 + 1
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("dest_path", ("test_dir_tmp/upl_chunk_v2_async", "test_dir_tmp/upl_chunk_v2_ü_async"))
+async def test_file_upload_chunked_v2_async(anc_any, dest_path):
+ with NamedTemporaryFile() as tmp_file:
+ tmp_file.seek(7 * 1024 * 1024)
+ tmp_file.write(b"\0")
+ tmp_file.flush()
+ await anc_any.files.upload_stream(dest_path, tmp_file.name)
+ assert len(await anc_any.files.download(dest_path)) == 7 * 1024 * 1024 + 1
+
+
+@pytest.mark.parametrize("file_name", ("test_file_upload_del", "test_file_upload_del/", "test_file_upload_del//"))
+def test_file_upload_zero_size(nc_any, file_name):
+ nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
+ with pytest.raises(NextcloudException):
+ nc_any.files.delete(f"/test_dir_tmp/{file_name}")
+ result = nc_any.files.upload(f"/test_dir_tmp/{file_name}", content="")
+ assert nc_any.files.download(f"/test_dir_tmp/{file_name}") == b""
+ assert result.is_dir is False
+ assert result.name == "test_file_upload_del"
+ assert result.full_path.startswith("files/")
+ nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("file_name", ("test_file_upload_del", "test_file_upload_del/", "test_file_upload_del//"))
+async def test_file_upload_zero_size_async(anc_any, file_name):
+ await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
+ with pytest.raises(NextcloudException):
+ await anc_any.files.delete(f"/test_dir_tmp/{file_name}")
+ result = await anc_any.files.upload(f"/test_dir_tmp/{file_name}", content="")
+ assert await anc_any.files.download(f"/test_dir_tmp/{file_name}") == b""
+ assert result.is_dir is False
+ assert result.name == "test_file_upload_del"
+ assert result.full_path.startswith("files/")
+ await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
+
+
@pytest.mark.parametrize("file_name", ("chunked_zero", "chunked_zero/", "chunked_zero//"))
def test_file_upload_chunked_zero_size(nc_any, file_name):
- nc_any.files.delete("/test_dir_tmp/test_file_upload_del", not_fail=True)
+ nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
buf_upload = MyBytesIO()
result = nc_any.files.upload_stream(f"test_dir_tmp/{file_name}", fp=buf_upload)
- assert nc_any.files.download("test_dir_tmp/chunked_zero") == b""
+ assert nc_any.files.download(f"test_dir_tmp/{file_name}") == b""
assert not nc_any.files.by_path(result.user_path).info.size
assert result.is_dir is False
assert result.full_path.startswith("files/")
assert result.name == "chunked_zero"
+ nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
-@pytest.mark.parametrize("file_name", ("test_file_upload_del", "test_file_upload_del/", "test_file_upload_del//"))
-def test_file_upload_del(nc_any, file_name):
- nc_any.files.delete("/test_dir_tmp/test_file_upload_del", not_fail=True)
- with pytest.raises(NextcloudException):
- nc_any.files.delete("/test_dir_tmp/test_file_upload_del")
- result = nc_any.files.upload(f"/test_dir_tmp/{file_name}", content="")
- assert nc_any.files.download(f"/test_dir_tmp/{file_name}") == b""
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("file_name", ("chunked_zero", "chunked_zero/", "chunked_zero//"))
+async def test_file_upload_chunked_zero_size_async(anc_any, file_name):
+ await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
+ buf_upload = MyBytesIO()
+ result = await anc_any.files.upload_stream(f"test_dir_tmp/{file_name}", fp=buf_upload)
+ assert await anc_any.files.download(f"test_dir_tmp/{file_name}") == b""
+ assert not (await anc_any.files.by_path(result.user_path)).info.size
assert result.is_dir is False
- assert result.name == "test_file_upload_del"
assert result.full_path.startswith("files/")
- nc_any.files.delete("/test_dir_tmp/test_file_upload_del", not_fail=True)
+ assert result.name == "chunked_zero"
+ await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True)
@pytest.mark.parametrize("dir_name", ("1 2", "Яё", "відео та картинки", "复杂 目录 Í", "Björn", "João"))
@@ -249,12 +465,33 @@ def test_mkdir(nc_any, dir_name):
nc_any.files.delete(dir_name)
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("dir_name", ("1 2", "Яё", "відео та картинки", "复杂 目录 Í", "Björn", "João"))
+async def test_mkdir_async(anc_any, dir_name):
+ await anc_any.files.delete(dir_name, not_fail=True)
+ result = await anc_any.files.mkdir(dir_name)
+ assert result.is_dir
+ assert not result.has_extra
+ with pytest.raises(NextcloudException):
+ await anc_any.files.mkdir(dir_name)
+ await anc_any.files.delete(dir_name)
+ with pytest.raises(NextcloudException):
+ await anc_any.files.delete(dir_name)
+
+
def test_mkdir_invalid_args(nc_any):
with pytest.raises(NextcloudException) as exc_info:
nc_any.files.makedirs("test_dir_tmp/ /zzzzzzzz", exist_ok=True)
assert exc_info.value.status_code != 405
+@pytest.mark.asyncio(scope="session")
+async def test_mkdir_invalid_args_async(anc_any):
+ with pytest.raises(NextcloudException) as exc_info:
+ await anc_any.files.makedirs("test_dir_tmp/ /zzzzzzzz", exist_ok=True)
+ assert exc_info.value.status_code != 405
+
+
def test_mkdir_delete_with_end_slash(nc_any):
nc_any.files.delete("dir_with_slash", not_fail=True)
result = nc_any.files.mkdir("dir_with_slash/")
@@ -266,6 +503,18 @@ def test_mkdir_delete_with_end_slash(nc_any):
nc_any.files.delete("dir_with_slash")
+@pytest.mark.asyncio(scope="session")
+async def test_mkdir_delete_with_end_slash_async(anc_any):
+ await anc_any.files.delete("dir_with_slash", not_fail=True)
+ result = await anc_any.files.mkdir("dir_with_slash/")
+ assert result.is_dir
+ assert result.name == "dir_with_slash"
+ assert result.full_path.startswith("files/")
+ await anc_any.files.delete("dir_with_slash/")
+ with pytest.raises(NextcloudException):
+ await anc_any.files.delete("dir_with_slash")
+
+
def test_favorites(nc_any):
favorites = nc_any.files.list_by_criteria(["favorite"])
favorites = [i for i in favorites if i.name != "test_generated_image.png"]
@@ -289,6 +538,30 @@ def test_favorites(nc_any):
assert not favorites
+@pytest.mark.asyncio(scope="session")
+async def test_favorites_async(anc_any):
+ favorites = await anc_any.files.list_by_criteria(["favorite"])
+ favorites = [i for i in favorites if i.name != "test_generated_image.png"]
+ for favorite in favorites:
+ await anc_any.files.setfav(favorite.user_path, False)
+ favorites = await anc_any.files.list_by_criteria(["favorite"])
+ favorites = [i for i in favorites if i.name != "test_generated_image.png"]
+ assert not favorites
+ files = ("test_dir_tmp/fav1.txt", "test_dir_tmp/fav2.txt", "test_dir_tmp/fav3.txt")
+ for n in files:
+ await anc_any.files.upload(n, content=n)
+ await anc_any.files.setfav(n, True)
+ favorites = await anc_any.files.list_by_criteria(["favorite"])
+ favorites = [i for i in favorites if i.name != "test_generated_image.png"]
+ assert len(favorites) == 3
+ for favorite in favorites:
+ assert isinstance(favorite, FsNode)
+ await anc_any.files.setfav(favorite, False)
+ favorites = await anc_any.files.list_by_criteria(["favorite"])
+ favorites = [i for i in favorites if i.name != "test_generated_image.png"]
+ assert not favorites
+
+
@pytest.mark.parametrize("dest_path", ("test_dir_tmp/test_64_bytes.bin", "test_dir_tmp/test_64_bytes_ü.bin"))
def test_copy_file(nc_any, rand_bytes, dest_path):
copied_file = nc_any.files.copy("test_64_bytes.bin", dest_path)
@@ -301,6 +574,23 @@ def test_copy_file(nc_any, rand_bytes, dest_path):
assert copied_file.file_id
assert copied_file.is_dir is False
assert nc_any.files.download(dest_path) == b"12345"
+ nc_any.files.delete(copied_file)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("dest_path", ("test_dir_tmp/test_64_bytes.bin", "test_dir_tmp/test_64_bytes_ü.bin"))
+async def test_copy_file_async(anc_any, rand_bytes, dest_path):
+ copied_file = await anc_any.files.copy("test_64_bytes.bin", dest_path)
+ assert copied_file.file_id
+ assert copied_file.is_dir is False
+ assert await anc_any.files.download(dest_path) == rand_bytes
+ with pytest.raises(NextcloudException):
+ await anc_any.files.copy("test_64_bytes.bin", dest_path)
+ copied_file = await anc_any.files.copy("test_12345_text.txt", dest_path, overwrite=True)
+ assert copied_file.file_id
+ assert copied_file.is_dir is False
+ assert await anc_any.files.download(dest_path) == b"12345"
+ await anc_any.files.delete(copied_file)
@pytest.mark.parametrize("dest_path", ("test_dir_tmp/dest move test file", "test_dir_tmp/dest move test file-ä"))
@@ -327,6 +617,35 @@ def test_move_file(nc_any, dest_path):
with pytest.raises(NextcloudException):
nc_any.files.download(src)
assert nc_any.files.download(dest_path) == content2
+ nc_any.files.delete(dest_path)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("dest_path", ("test_dir_tmp/dest move test file", "test_dir_tmp/dest move test file-ä"))
+async def test_move_file_async(anc_any, dest_path):
+ src = "test_dir_tmp/src move test file"
+ content = b"content of the file"
+ content2 = b"content of the file-second part"
+ await anc_any.files.upload(src, content=content)
+ await anc_any.files.delete(dest_path, not_fail=True)
+ result = await anc_any.files.move(src, dest_path)
+ assert result.etag
+ assert result.file_id
+ assert result.is_dir is False
+ assert await anc_any.files.download(dest_path) == content
+ with pytest.raises(NextcloudException):
+ await anc_any.files.download(src)
+ await anc_any.files.upload(src, content=content2)
+ with pytest.raises(NextcloudException):
+ await anc_any.files.move(src, dest_path)
+ result = await anc_any.files.move(src, dest_path, overwrite=True)
+ assert result.etag
+ assert result.file_id
+ assert result.is_dir is False
+ with pytest.raises(NextcloudException):
+ await anc_any.files.download(src)
+ assert await anc_any.files.download(dest_path) == content2
+ await anc_any.files.delete(dest_path)
def test_move_copy_dir(nc_any):
@@ -336,10 +655,32 @@ def test_move_copy_dir(nc_any):
assert nc_any.files.by_path(result).is_dir
assert len(nc_any.files.listdir("test_dir_tmp/test_copy_dir")) == len(nc_any.files.listdir("test_dir/subdir"))
result = nc_any.files.move("test_dir_tmp/test_copy_dir", "test_dir_tmp/test_move_dir")
+ with pytest.raises(NextcloudException):
+ nc_any.files.listdir("test_dir_tmp/test_copy_dir")
assert result.file_id
assert result.is_dir
assert nc_any.files.by_path(result).is_dir
assert len(nc_any.files.listdir("test_dir_tmp/test_move_dir")) == 4
+ nc_any.files.delete("test_dir_tmp/test_move_dir")
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_move_copy_dir_async(anc_any):
+ result = await anc_any.files.copy("/test_dir/subdir", "test_dir_tmp/test_copy_dir")
+ assert result.file_id
+ assert result.is_dir
+ assert (await anc_any.files.by_path(result)).is_dir
+ assert len(await anc_any.files.listdir("test_dir_tmp/test_copy_dir")) == len(
+ await anc_any.files.listdir("test_dir/subdir")
+ )
+ result = await anc_any.files.move("test_dir_tmp/test_copy_dir", "test_dir_tmp/test_move_dir")
+ with pytest.raises(NextcloudException):
+ await anc_any.files.listdir("test_dir_tmp/test_copy_dir")
+ assert result.file_id
+ assert result.is_dir
+ assert (await anc_any.files.by_path(result)).is_dir
+ assert len(await anc_any.files.listdir("test_dir_tmp/test_move_dir")) == 4
+ await anc_any.files.delete("test_dir_tmp/test_move_dir")
def test_find_files_listdir_depth(nc_any):
@@ -379,6 +720,18 @@ def test_listdir_depth(nc_any):
assert len(result) == 10
+@pytest.mark.asyncio(scope="session")
+async def test_listdir_depth_async(anc_any):
+ result = await anc_any.files.listdir("test_dir/", depth=1)
+ result2 = await anc_any.files.listdir("test_dir")
+ assert result == result2
+ assert len(result) == 6
+ result = await anc_any.files.listdir("test_dir/", depth=2)
+ result2 = await anc_any.files.listdir("test_dir", depth=-1)
+ assert result == result2
+ assert len(result) == 10
+
+
def test_fs_node_fields(nc_any):
results = nc_any.files.listdir("/test_dir")
assert len(results) == 6
@@ -433,6 +786,7 @@ def test_fs_node_fields(nc_any):
def test_makedirs(nc_any):
+ nc_any.files.delete("/test_dir_tmp/abc", not_fail=True)
result = nc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True)
assert result.is_dir
with pytest.raises(NextcloudException) as exc_info:
@@ -442,6 +796,18 @@ def test_makedirs(nc_any):
assert result is None
+@pytest.mark.asyncio(scope="session")
+async def test_makedirs_async(anc_any):
+ await anc_any.files.delete("/test_dir_tmp/abc", not_fail=True)
+ result = await anc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True)
+ assert result.is_dir
+ with pytest.raises(NextcloudException) as exc_info:
+ await anc_any.files.makedirs("/test_dir_tmp/abc/def")
+ assert exc_info.value.status_code == 405
+ result = await anc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True)
+ assert result is None
+
+
def test_fs_node_str(nc_any):
fs_node1 = nc_any.files.by_path("test_empty_dir_in_dir")
str_fs_node1 = str(fs_node1)
@@ -455,11 +821,8 @@ def test_fs_node_str(nc_any):
assert str_fs_node2.find(f"id={fs_node2.file_id}") != -1
-def test_download_as_zip(nc):
- old_headers = nc.response_headers
- result = nc.files.download_directory_as_zip("test_dir")
- assert nc.response_headers != old_headers
- try:
+def _test_download_as_zip(result: Path, n: int):
+ if n == 1:
with zipfile.ZipFile(result, "r") as zip_ref:
assert zip_ref.filelist[0].filename == "test_dir/"
assert not zip_ref.filelist[0].file_size
@@ -470,6 +833,26 @@ def test_download_as_zip(nc):
assert zip_ref.filelist[3].filename == "test_dir/subdir/test_64_bytes.bin"
assert zip_ref.filelist[3].file_size == 64
assert len(zip_ref.filelist) == 11
+ elif n == 2:
+ with zipfile.ZipFile(result, "r") as zip_ref:
+ assert zip_ref.filelist[0].filename == "test_empty_dir_in_dir/"
+ assert not zip_ref.filelist[0].file_size
+ assert zip_ref.filelist[1].filename == "test_empty_dir_in_dir/test_empty_child_dir/"
+ assert not zip_ref.filelist[1].file_size
+ assert len(zip_ref.filelist) == 2
+ else:
+ with zipfile.ZipFile(result, "r") as zip_ref:
+ assert zip_ref.filelist[0].filename == "test_empty_dir/"
+ assert not zip_ref.filelist[0].file_size
+ assert len(zip_ref.filelist) == 1
+
+
+def test_download_as_zip(nc):
+ old_headers = nc.response_headers
+ result = nc.files.download_directory_as_zip("test_dir")
+ assert nc.response_headers != old_headers
+ try:
+ _test_download_as_zip(result, 1)
finally:
os.remove(result)
old_headers = nc.response_headers
@@ -477,21 +860,38 @@ def test_download_as_zip(nc):
assert nc.response_headers != old_headers
try:
assert str(result) == "2.zip"
- with zipfile.ZipFile(result, "r") as zip_ref:
- assert zip_ref.filelist[0].filename == "test_empty_dir_in_dir/"
- assert not zip_ref.filelist[0].file_size
- assert zip_ref.filelist[1].filename == "test_empty_dir_in_dir/test_empty_child_dir/"
- assert not zip_ref.filelist[1].file_size
- assert len(zip_ref.filelist) == 2
+ _test_download_as_zip(result, 2)
finally:
os.remove("2.zip")
result = nc.files.download_directory_as_zip("/test_empty_dir", "empty_folder.zip")
try:
assert str(result) == "empty_folder.zip"
- with zipfile.ZipFile(result, "r") as zip_ref:
- assert zip_ref.filelist[0].filename == "test_empty_dir/"
- assert not zip_ref.filelist[0].file_size
- assert len(zip_ref.filelist) == 1
+ _test_download_as_zip(result, 3)
+ finally:
+ os.remove("empty_folder.zip")
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_download_as_zip_async(anc):
+ old_headers = anc.response_headers
+ result = await anc.files.download_directory_as_zip("test_dir")
+ assert anc.response_headers != old_headers
+ try:
+ _test_download_as_zip(result, 1)
+ finally:
+ os.remove(result)
+ old_headers = anc.response_headers
+ result = await anc.files.download_directory_as_zip("test_empty_dir_in_dir", "2.zip")
+ assert anc.response_headers != old_headers
+ try:
+ assert str(result) == "2.zip"
+ _test_download_as_zip(result, 2)
+ finally:
+ os.remove("2.zip")
+ result = await anc.files.download_directory_as_zip("/test_empty_dir", "empty_folder.zip")
+ try:
+ assert str(result) == "empty_folder.zip"
+ _test_download_as_zip(result, 3)
finally:
os.remove("empty_folder.zip")
@@ -564,6 +964,54 @@ def test_trashbin(nc_any, file_path):
assert not r
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("file_path", ("test_dir_tmp/trashbin_test", "test_dir_tmp/trashbin_test-ä"))
+async def test_trashbin_async(anc_any, file_path):
+ r = await anc_any.files.trashbin_list()
+ assert isinstance(r, list)
+ new_file = await anc_any.files.upload(file_path, content=b"")
+ await anc_any.files.delete(new_file)
+ # minimum one object now in a trashbin
+ r = await anc_any.files.trashbin_list()
+ assert r
+ # clean up trashbin
+ await anc_any.files.trashbin_cleanup()
+ # no objects should be in trashbin
+ r = await anc_any.files.trashbin_list()
+ assert not r
+ new_file = await anc_any.files.upload(file_path, content=b"")
+ await anc_any.files.delete(new_file)
+ # one object now in a trashbin
+ r = await anc_any.files.trashbin_list()
+ assert len(r) == 1
+ # check types of FsNode properties
+ i: FsNode = r[0]
+ assert i.info.in_trash is True
+ assert i.info.trashbin_filename.find("trashbin_test") != -1
+ assert i.info.trashbin_original_location == file_path
+ assert isinstance(i.info.trashbin_deletion_time, int)
+ # restore that object
+ await anc_any.files.trashbin_restore(r[0])
+ # no files in trashbin
+ r = await anc_any.files.trashbin_list()
+ assert not r
+ # move a restored object to trashbin again
+ await anc_any.files.delete(new_file)
+ # one object now in a trashbin
+ r = await anc_any.files.trashbin_list()
+ assert len(r) == 1
+ # remove one object from a trashbin
+ await anc_any.files.trashbin_delete(r[0])
+ # NextcloudException with status_code 404
+ with pytest.raises(NextcloudException) as e:
+ await anc_any.files.trashbin_delete(r[0])
+ assert e.value.status_code == 404
+ await anc_any.files.trashbin_delete(r[0], not_fail=True)
+ # no files in trashbin
+ r = await anc_any.files.trashbin_list()
+ assert not r
+
+
@pytest.mark.parametrize("dest_path", ("/test_dir_tmp/file_versions.txt", "/test_dir_tmp/file_versions-ä.txt"))
def test_file_versions(nc_any, dest_path):
if nc_any.check_capabilities("files.versioning"):
@@ -583,6 +1031,26 @@ def test_file_versions(nc_any, dest_path):
assert nc_any.files.download(new_file) == b"22"
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("dest_path", ("/test_dir_tmp/file_versions.txt", "/test_dir_tmp/file_versions-ä.txt"))
+async def test_file_versions_async(anc_any, dest_path):
+ if await anc_any.check_capabilities("files.versioning"):
+ pytest.skip("Need 'Versions' App to be enabled.")
+ for i in (0, 1):
+ await anc_any.files.delete(dest_path, not_fail=True)
+ await anc_any.files.upload(dest_path, content=b"22")
+ new_file = await anc_any.files.upload(dest_path, content=b"333")
+ if i:
+ new_file = await anc_any.files.by_id(new_file)
+ versions = await anc_any.files.get_versions(new_file)
+ assert versions
+ version_str = str(versions[0])
+ assert version_str.find("File version") != -1
+ assert version_str.find("bytes size") != -1
+ await anc_any.files.restore_version(versions[0])
+ assert await anc_any.files.download(new_file) == b"22"
+
+
def test_create_update_delete_tag(nc_any):
with contextlib.suppress(NextcloudExceptionNotFound):
nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api"))
@@ -608,6 +1076,32 @@ def test_create_update_delete_tag(nc_any):
nc_any.files.update_tag(tag)
+@pytest.mark.asyncio(scope="session")
+async def test_create_update_delete_tag_async(anc_any):
+ with contextlib.suppress(NextcloudExceptionNotFound):
+ await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api"))
+ with contextlib.suppress(NextcloudExceptionNotFound):
+ await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api2"))
+ await anc_any.files.create_tag("test_nc_py_api", True, True)
+ tag = await anc_any.files.tag_by_name("test_nc_py_api")
+ assert isinstance(tag.tag_id, int)
+ assert tag.display_name == "test_nc_py_api"
+ assert tag.user_visible is True
+ assert tag.user_assignable is True
+ await anc_any.files.update_tag(tag, "test_nc_py_api2", False, False)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_any.files.tag_by_name("test_nc_py_api")
+ tag = await anc_any.files.tag_by_name("test_nc_py_api2")
+ assert tag.display_name == "test_nc_py_api2"
+ assert tag.user_visible is False
+ assert tag.user_assignable is False
+ for i in await anc_any.files.list_tags():
+ assert str(i).find("name=") != -1
+ await anc_any.files.delete_tag(tag)
+ with pytest.raises(ValueError):
+ await anc_any.files.update_tag(tag)
+
+
def test_assign_unassign_tag(nc_any):
with contextlib.suppress(NextcloudExceptionNotFound):
nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api"))
@@ -635,3 +1129,33 @@ def test_assign_unassign_tag(nc_any):
nc_any.files.assign_tag(new_file, tag1)
with pytest.raises(ValueError):
nc_any.files.list_by_criteria()
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_assign_unassign_tag_async(anc_any):
+ with contextlib.suppress(NextcloudExceptionNotFound):
+ await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api"))
+ with contextlib.suppress(NextcloudExceptionNotFound):
+ await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api2"))
+ await anc_any.files.create_tag("test_nc_py_api", True, False)
+ await anc_any.files.create_tag("test_nc_py_api2", False, False)
+ tag1 = await anc_any.files.tag_by_name("test_nc_py_api")
+ assert tag1.user_visible is True
+ assert tag1.user_assignable is False
+ tag2 = await anc_any.files.tag_by_name("test_nc_py_api2")
+ assert tag2.user_visible is False
+ assert tag2.user_assignable is False
+ new_file = await anc_any.files.upload("/test_dir_tmp/tag_test.txt", content=b"")
+ new_file = await anc_any.files.by_id(new_file)
+ assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 0
+ await anc_any.files.assign_tag(new_file, tag1)
+ assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 1
+ assert len(await anc_any.files.list_by_criteria(["favorite"], tags=[tag1])) == 0
+ assert len(await anc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 0
+ await anc_any.files.assign_tag(new_file, tag2.tag_id)
+ assert len(await anc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 1
+ await anc_any.files.unassign_tag(new_file, tag1)
+ assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 0
+ await anc_any.files.assign_tag(new_file, tag1)
+ with pytest.raises(ValueError):
+ await anc_any.files.list_by_criteria()
diff --git a/tests/actual_tests/logs_test.py b/tests/actual_tests/logs_test.py
index 96ecc188..4dfc6b88 100644
--- a/tests/actual_tests/logs_test.py
+++ b/tests/actual_tests/logs_test.py
@@ -19,24 +19,51 @@ def test_log_success(nc_app):
nc_app.log(LogLvl.FATAL, "log success")
+@pytest.mark.asyncio(scope="session")
+async def test_log_success_async(anc_app):
+ await anc_app.log(LogLvl.FATAL, "log success")
+
+
def test_loglvl_str(nc_app):
nc_app.log("1", "lolglvl in str: should be written") # noqa
+@pytest.mark.asyncio(scope="session")
+async def test_loglvl_str_async(anc_app):
+ await anc_app.log("1", "lolglvl in str: should be written") # noqa
+
+
def test_invalid_log_level(nc_app):
with pytest.raises(NextcloudException):
nc_app.log(5, "wrong log level") # noqa
+@pytest.mark.asyncio(scope="session")
+async def test_invalid_log_level_async(anc_app):
+ with pytest.raises(NextcloudException):
+ await anc_app.log(5, "wrong log level") # noqa
+
+
def test_empty_log(nc_app):
nc_app.log(LogLvl.FATAL, "")
+@pytest.mark.asyncio(scope="session")
+async def test_empty_log_async(anc_app):
+ await anc_app.log(LogLvl.FATAL, "")
+
+
def test_loglvl_equal(nc_app):
current_log_lvl = nc_app.capabilities["app_api"].get("loglevel", LogLvl.FATAL)
nc_app.log(current_log_lvl, "log should be written")
+@pytest.mark.asyncio(scope="session")
+async def test_loglvl_equal_async(anc_app):
+ current_log_lvl = (await anc_app.capabilities)["app_api"].get("loglevel", LogLvl.FATAL)
+ await anc_app.log(current_log_lvl, "log should be written")
+
+
def test_loglvl_less(nc_app):
current_log_lvl = nc_app.capabilities["app_api"].get("loglevel", LogLvl.FATAL)
if current_log_lvl == LogLvl.DEBUG:
@@ -48,6 +75,18 @@ def test_loglvl_less(nc_app):
assert ocs.call_count > 0
+@pytest.mark.asyncio(scope="session")
+async def test_loglvl_less_async(anc_app):
+ current_log_lvl = (await anc_app.capabilities)["app_api"].get("loglevel", LogLvl.FATAL)
+ if current_log_lvl == LogLvl.DEBUG:
+ pytest.skip("Log lvl to low")
+ with mock.patch("tests.conftest.NC_APP_ASYNC._session.ocs") as ocs:
+ await anc_app.log(int(current_log_lvl) - 1, "will not be sent") # noqa
+ ocs.assert_not_called()
+ await anc_app.log(current_log_lvl, "will be sent")
+ assert ocs.call_count > 0
+
+
def test_log_without_app_api(nc_app):
srv_capabilities = deepcopy(nc_app.capabilities)
srv_version = deepcopy(nc_app.srv_version)
@@ -60,3 +99,18 @@ def test_log_without_app_api(nc_app):
):
nc_app.log(log_lvl, "will not be sent")
ocs.assert_not_called()
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_log_without_app_api_async(anc_app):
+ srv_capabilities = deepcopy(await anc_app.capabilities)
+ srv_version = deepcopy(await anc_app.srv_version)
+ log_lvl = srv_capabilities["app_api"].pop("loglevel")
+ srv_capabilities.pop("app_api")
+ patched_capabilities = {"capabilities": srv_capabilities, "version": srv_version}
+ with (
+ mock.patch.dict("tests.conftest.NC_APP_ASYNC._session._capabilities", patched_capabilities, clear=True),
+ mock.patch("tests.conftest.NC_APP_ASYNC._session.ocs") as ocs,
+ ):
+ await anc_app.log(log_lvl, "will not be sent")
+ ocs.assert_not_called()
diff --git a/tests/actual_tests/misc_test.py b/tests/actual_tests/misc_test.py
index c0dc05ce..47d2b642 100644
--- a/tests/actual_tests/misc_test.py
+++ b/tests/actual_tests/misc_test.py
@@ -4,7 +4,14 @@
import pytest
from httpx import Request, Response
-from nc_py_api import Nextcloud, NextcloudApp, NextcloudException, ex_app
+from nc_py_api import (
+ AsyncNextcloud,
+ AsyncNextcloudApp,
+ Nextcloud,
+ NextcloudApp,
+ NextcloudException,
+ ex_app,
+)
from nc_py_api._deffered_error import DeferredError # noqa
from nc_py_api._exceptions import check_error # noqa
from nc_py_api._misc import nc_iso_time_to_datetime, require_capabilities # noqa
@@ -65,6 +72,13 @@ def test_response_headers(nc):
assert old_headers != nc.response_headers
+@pytest.mark.asyncio(scope="session")
+async def test_response_headers_async(anc):
+ old_headers = anc.response_headers
+ await anc.users.get_user(await anc.user)
+ assert old_headers != anc.response_headers
+
+
def test_nc_iso_time_to_datetime():
parsed_time = nc_iso_time_to_datetime("invalid")
assert parsed_time == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
@@ -105,8 +119,51 @@ def test_init_adapter_dav(nc_any):
assert old_adapter != getattr(new_nc._session, "adapter_dav", None)
+@pytest.mark.asyncio(scope="session")
+async def test_init_adapter_dav_async(anc_any):
+ new_nc = AsyncNextcloud() if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp()
+ new_nc._session.init_adapter_dav()
+ old_adapter = getattr(new_nc._session, "adapter_dav", None)
+ assert old_adapter is not None
+ new_nc._session.init_adapter_dav()
+ assert old_adapter == getattr(new_nc._session, "adapter_dav", None)
+ new_nc._session.init_adapter_dav(restart=True)
+ assert old_adapter != getattr(new_nc._session, "adapter_dav", None)
+
+
def test_no_initial_connection(nc_any):
new_nc = Nextcloud() if isinstance(nc_any, Nextcloud) else NextcloudApp()
assert not new_nc._session._capabilities
_ = new_nc.srv_version
assert new_nc._session._capabilities
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_no_initial_connection_async(anc_any):
+ new_nc = AsyncNextcloud() if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp()
+ assert not new_nc._session._capabilities
+ _ = await new_nc.srv_version
+ assert new_nc._session._capabilities
+
+
+def test_ocs_timeout(nc_any):
+ new_nc = Nextcloud(npa_timeout=0.01) if isinstance(nc_any, Nextcloud) else NextcloudApp(npa_timeout=0.01)
+ with pytest.raises(NextcloudException) as e:
+ if new_nc.weather_status.set_location(latitude=41.896655, longitude=12.488776):
+ new_nc.weather_status.get_forecast()
+ if e.value.status_code in (500, 996):
+ pytest.skip("Some network problem on the host")
+ assert e.value.status_code == 408
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_ocs_timeout_async(anc_any):
+ new_nc = (
+ AsyncNextcloud(npa_timeout=0.01) if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp(npa_timeout=0.01)
+ )
+ with pytest.raises(NextcloudException) as e:
+ if await new_nc.weather_status.set_location(latitude=41.896655, longitude=12.488776):
+ await new_nc.weather_status.get_forecast()
+ if e.value.status_code in (500, 996):
+ pytest.skip("Some network problem on the host")
+ assert e.value.status_code == 408
diff --git a/tests/actual_tests/nc_app_test.py b/tests/actual_tests/nc_app_test.py
index 4aca9430..7c13e589 100644
--- a/tests/actual_tests/nc_app_test.py
+++ b/tests/actual_tests/nc_app_test.py
@@ -1,6 +1,9 @@
from os import environ
+from unittest import mock
-from nc_py_api.ex_app import ApiScope
+import pytest
+
+from nc_py_api.ex_app import ApiScope, set_handlers
def test_get_users_list(nc_app):
@@ -9,6 +12,13 @@ def test_get_users_list(nc_app):
assert nc_app.user in users
+@pytest.mark.asyncio(scope="session")
+async def test_get_users_list_async(anc_app):
+ users = await anc_app.users_list()
+ assert users
+ assert await anc_app.user in users
+
+
def test_scope_allowed(nc_app):
for i in ApiScope:
assert nc_app.scope_allowed(i)
@@ -16,6 +26,14 @@ def test_scope_allowed(nc_app):
assert not nc_app.scope_allowed(999999999) # noqa
+@pytest.mark.asyncio(scope="session")
+async def test_scope_allowed_async(anc_app):
+ for i in ApiScope:
+ assert await anc_app.scope_allowed(i)
+ assert not await anc_app.scope_allowed(0) # noqa
+ assert not await anc_app.scope_allowed(999999999) # noqa
+
+
def test_app_cfg(nc_app):
app_cfg = nc_app.app_cfg
assert app_cfg.app_name == environ["APP_ID"]
@@ -23,6 +41,14 @@ def test_app_cfg(nc_app):
assert app_cfg.app_secret == environ["APP_SECRET"]
+@pytest.mark.asyncio(scope="session")
+async def test_app_cfg_async(anc_app):
+ app_cfg = anc_app.app_cfg
+ assert app_cfg.app_name == environ["APP_ID"]
+ assert app_cfg.app_version == environ["APP_VERSION"]
+ assert app_cfg.app_secret == environ["APP_SECRET"]
+
+
def test_scope_allow_app_ecosystem_disabled(nc_client, nc_app):
assert nc_app.scope_allowed(ApiScope.FILES)
nc_client.apps.disable("app_api")
@@ -35,14 +61,59 @@ def test_scope_allow_app_ecosystem_disabled(nc_client, nc_app):
nc_app.update_server_info()
+@pytest.mark.asyncio(scope="session")
+async def test_scope_allow_app_ecosystem_disabled_async(anc_client, anc_app):
+ assert await anc_app.scope_allowed(ApiScope.FILES)
+ await anc_client.apps.disable("app_api")
+ try:
+ assert await anc_app.scope_allowed(ApiScope.FILES)
+ await anc_app.update_server_info()
+ assert not await anc_app.scope_allowed(ApiScope.FILES)
+ finally:
+ await anc_client.apps.enable("app_api")
+ await anc_app.update_server_info()
+
+
def test_change_user(nc_app):
orig_user = nc_app.user
try:
orig_capabilities = nc_app.capabilities
assert nc_app.user_status.available
- nc_app.user = ""
+ nc_app.set_user("")
assert not nc_app.user_status.available
assert orig_capabilities != nc_app.capabilities
finally:
- nc_app.user = orig_user
+ nc_app.set_user(orig_user)
assert orig_capabilities == nc_app.capabilities
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_change_user_async(anc_app):
+ orig_user = await anc_app.user
+ try:
+ orig_capabilities = await anc_app.capabilities
+ assert await anc_app.user_status.available
+ await anc_app.set_user("")
+ assert not await anc_app.user_status.available
+ assert orig_capabilities != await anc_app.capabilities
+ finally:
+ await anc_app.set_user(orig_user)
+ assert orig_capabilities == await anc_app.capabilities
+
+
+def test_set_user_same_value(nc_app):
+ with (mock.patch("tests.conftest.NC_APP._session.update_server_info") as update_server_info,):
+ nc_app.set_user(nc_app.user)
+ update_server_info.assert_not_called()
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_set_user_same_value_async(anc_app):
+ with (mock.patch("tests.conftest.NC_APP_ASYNC._session.update_server_info") as update_server_info,):
+ await anc_app.set_user(await anc_app.user)
+ update_server_info.assert_not_called()
+
+
+def test_set_handlers_invalid_param(nc_any):
+ with pytest.raises(ValueError):
+ set_handlers(None, None, init_handler=set_handlers, models_to_fetch=["some"]) # noqa
diff --git a/tests/actual_tests/notes_test.py b/tests/actual_tests/notes_test.py
index 3fd8777c..275c5bf9 100644
--- a/tests/actual_tests/notes_test.py
+++ b/tests/actual_tests/notes_test.py
@@ -23,12 +23,45 @@ def test_settings(nc_any):
nc_any.notes.set_settings()
+@pytest.mark.asyncio(scope="session")
+async def test_settings_async(anc_any):
+ if await anc_any.notes.available is False:
+ pytest.skip("Notes is not installed")
+
+ original_settings = await anc_any.notes.get_settings()
+ assert isinstance(original_settings["file_suffix"], str)
+ assert isinstance(original_settings["notes_path"], str)
+ await anc_any.notes.set_settings(file_suffix=".ncpa")
+ modified_settings = await anc_any.notes.get_settings()
+ assert modified_settings["file_suffix"] == ".ncpa"
+ assert modified_settings["notes_path"] == original_settings["notes_path"]
+ await anc_any.notes.set_settings(file_suffix=original_settings["file_suffix"])
+ modified_settings = await anc_any.notes.get_settings()
+ assert modified_settings["file_suffix"] == original_settings["file_suffix"]
+ with pytest.raises(ValueError):
+ await anc_any.notes.set_settings()
+
+
def test_create_delete(nc_any):
if nc_any.notes.available is False:
pytest.skip("Notes is not installed")
unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()
new_note = nc_any.notes.create(str(unix_timestamp))
nc_any.notes.delete(new_note)
+ _test_create_delete(new_note)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_create_delete_async(anc_any):
+ if await anc_any.notes.available is False:
+ pytest.skip("Notes is not installed")
+ unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()
+ new_note = await anc_any.notes.create(str(unix_timestamp))
+ await anc_any.notes.delete(new_note)
+ _test_create_delete(new_note)
+
+
+def _test_create_delete(new_note: notes.Note):
assert isinstance(new_note.note_id, int)
assert isinstance(new_note.etag, str)
assert isinstance(new_note.title, str)
@@ -74,8 +107,51 @@ def test_get_update_note(nc_any):
nc_any.notes.delete(new_note)
+@pytest.mark.asyncio(scope="session")
+async def test_get_update_note_async(anc_any):
+ if await anc_any.notes.available is False:
+ pytest.skip("Notes is not installed")
+
+ for i in await anc_any.notes.get_list():
+ await anc_any.notes.delete(i)
+
+ assert not await anc_any.notes.get_list()
+ unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()
+ new_note = await anc_any.notes.create(str(unix_timestamp))
+ try:
+ all_notes = await anc_any.notes.get_list()
+ assert all_notes[0] == new_note
+ assert not await anc_any.notes.get_list(etag=True)
+ assert (await anc_any.notes.get_list())[0] == new_note
+ assert await anc_any.notes.by_id(new_note) == new_note
+ updated_note = await anc_any.notes.update(new_note, content="content")
+ assert updated_note.content == "content"
+ all_notes = await anc_any.notes.get_list()
+ assert all_notes[0].content == "content"
+ all_notes_no_content = await anc_any.notes.get_list(no_content=True)
+ assert all_notes_no_content[0].content == ""
+ assert (await anc_any.notes.by_id(new_note)).content == "content"
+ with pytest.raises(NextcloudException):
+ assert await anc_any.notes.update(new_note, content="should be rejected")
+ new_note = await anc_any.notes.update(new_note, content="should not be rejected", overwrite=True)
+ await anc_any.notes.update(new_note, category="test_category", favorite=True)
+ new_note = await anc_any.notes.by_id(new_note)
+ assert new_note.favorite is True
+ assert new_note.category == "test_category"
+ finally:
+ await anc_any.notes.delete(new_note)
+
+
def test_update_note_invalid_param(nc_any):
if nc_any.notes.available is False:
pytest.skip("Notes is not installed")
with pytest.raises(ValueError):
nc_any.notes.update(notes.Note({"id": 0, "etag": "42242"}))
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_update_note_invalid_param_async(anc_any):
+ if await anc_any.notes.available is False:
+ pytest.skip("Notes is not installed")
+ with pytest.raises(ValueError):
+ await anc_any.notes.update(notes.Note({"id": 0, "etag": "42242"}))
diff --git a/tests/actual_tests/notifications_test.py b/tests/actual_tests/notifications_test.py
index 6f71b0f6..011489d6 100644
--- a/tests/actual_tests/notifications_test.py
+++ b/tests/actual_tests/notifications_test.py
@@ -9,14 +9,23 @@ def test_available(nc_app):
assert nc_app.notifications.available
+@pytest.mark.asyncio(scope="session")
+async def test_available_async(anc_app):
+ assert await anc_app.notifications.available
+
+
def test_create_as_client(nc_client):
with pytest.raises(NotImplementedError):
nc_client.notifications.create("caption")
-def test_create(nc_app):
- obj_id = nc_app.notifications.create("subject0123", "message456")
- new_notification = nc_app.notifications.by_object_id(obj_id)
+@pytest.mark.asyncio(scope="session")
+async def test_create_as_client_async(anc_client):
+ with pytest.raises(NotImplementedError):
+ await anc_client.notifications.create("caption")
+
+
+def _test_create(new_notification: Notification):
assert isinstance(new_notification, Notification)
assert new_notification.subject == "subject0123"
assert new_notification.message == "message456"
@@ -27,6 +36,19 @@ def test_create(nc_app):
assert isinstance(new_notification.object_type, str)
+def test_create(nc_app):
+ obj_id = nc_app.notifications.create("subject0123", "message456")
+ new_notification = nc_app.notifications.by_object_id(obj_id)
+ _test_create(new_notification)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_create_async(anc_app):
+ obj_id = await anc_app.notifications.create("subject0123", "message456")
+ new_notification = await anc_app.notifications.by_object_id(obj_id)
+ _test_create(new_notification)
+
+
def test_create_link_icon(nc_app):
obj_id = nc_app.notifications.create("1", "", link="https://some.link/gg")
new_notification = nc_app.notifications.by_object_id(obj_id)
@@ -37,6 +59,17 @@ def test_create_link_icon(nc_app):
assert new_notification.link == "https://some.link/gg"
+@pytest.mark.asyncio(scope="session")
+async def test_create_link_icon_async(anc_app):
+ obj_id = await anc_app.notifications.create("1", "", link="https://some.link/gg")
+ new_notification = await anc_app.notifications.by_object_id(obj_id)
+ assert isinstance(new_notification, Notification)
+ assert new_notification.subject == "1"
+ assert not new_notification.message
+ assert new_notification.icon
+ assert new_notification.link == "https://some.link/gg"
+
+
def test_delete_all(nc_app):
nc_app.notifications.create("subject0123", "message456")
obj_id1 = nc_app.notifications.create("subject0123", "message456")
@@ -52,6 +85,22 @@ def test_delete_all(nc_app):
assert not nc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id])
+@pytest.mark.asyncio(scope="session")
+async def test_delete_all_async(anc_app):
+ await anc_app.notifications.create("subject0123", "message456")
+ obj_id1 = await anc_app.notifications.create("subject0123", "message456")
+ ntf1 = await anc_app.notifications.by_object_id(obj_id1)
+ assert ntf1
+ obj_id2 = await anc_app.notifications.create("subject0123", "message456")
+ ntf2 = await anc_app.notifications.by_object_id(obj_id2)
+ assert ntf2
+ await anc_app.notifications.delete_all()
+ assert await anc_app.notifications.by_object_id(obj_id1) is None
+ assert await anc_app.notifications.by_object_id(obj_id2) is None
+ assert not await anc_app.notifications.get_all()
+ assert not await anc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id])
+
+
def test_delete_one(nc_app):
obj_id1 = nc_app.notifications.create("subject0123")
obj_id2 = nc_app.notifications.create("subject0123")
@@ -64,11 +113,30 @@ def test_delete_one(nc_app):
nc_app.notifications.delete(ntf2.notification_id)
+@pytest.mark.asyncio(scope="session")
+async def test_delete_one_async(anc_app):
+ obj_id1 = await anc_app.notifications.create("subject0123")
+ obj_id2 = await anc_app.notifications.create("subject0123")
+ ntf1 = await anc_app.notifications.by_object_id(obj_id1)
+ ntf2 = await anc_app.notifications.by_object_id(obj_id2)
+ await anc_app.notifications.delete(ntf1.notification_id)
+ assert await anc_app.notifications.by_object_id(obj_id1) is None
+ assert await anc_app.notifications.by_object_id(obj_id2)
+ assert await anc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id]) == [ntf2.notification_id]
+ await anc_app.notifications.delete(ntf2.notification_id)
+
+
def test_create_invalid_args(nc_app):
with pytest.raises(ValueError):
nc_app.notifications.create("")
+@pytest.mark.asyncio(scope="session")
+async def test_create_invalid_args_async(anc_app):
+ with pytest.raises(ValueError):
+ await anc_app.notifications.create("")
+
+
def test_get_one(nc_app):
nc_app.notifications.delete_all()
obj_id1 = nc_app.notifications.create("subject0123")
@@ -79,3 +147,16 @@ def test_get_one(nc_app):
ntf2_2 = nc_app.notifications.get_one(ntf2.notification_id)
assert ntf1 == ntf1_2
assert ntf2 == ntf2_2
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_one_async(anc_app):
+ await anc_app.notifications.delete_all()
+ obj_id1 = await anc_app.notifications.create("subject0123")
+ obj_id2 = await anc_app.notifications.create("subject0123")
+ ntf1 = await anc_app.notifications.by_object_id(obj_id1)
+ ntf2 = await anc_app.notifications.by_object_id(obj_id2)
+ ntf1_2 = await anc_app.notifications.get_one(ntf1.notification_id)
+ ntf2_2 = await anc_app.notifications.get_one(ntf2.notification_id)
+ assert ntf1 == ntf1_2
+ assert ntf2 == ntf2_2
diff --git a/tests/actual_tests/preferences_test.py b/tests/actual_tests/preferences_test.py
index 5cadd3cb..f6a0f0bc 100644
--- a/tests/actual_tests/preferences_test.py
+++ b/tests/actual_tests/preferences_test.py
@@ -7,6 +7,11 @@ def test_available(nc):
assert isinstance(nc.preferences.available, bool)
+@pytest.mark.asyncio(scope="session")
+async def test_available_async(anc):
+ assert isinstance(await anc.preferences.available, bool)
+
+
def test_preferences_set(nc):
if not nc.preferences.available:
pytest.skip("provisioning_api is not available")
@@ -15,9 +20,27 @@ def test_preferences_set(nc):
nc.preferences.set_value("non_existing_app", "some_cfg_name", "2")
+@pytest.mark.asyncio(scope="session")
+async def test_preferences_set_async(anc):
+ if not await anc.preferences.available:
+ pytest.skip("provisioning_api is not available")
+ await anc.preferences.set_value("dav", key="user_status_automation", value="yes")
+ with pytest.raises(NextcloudException):
+ await anc.preferences.set_value("non_existing_app", "some_cfg_name", "2")
+
+
def test_preferences_delete(nc):
if not nc.preferences.available:
pytest.skip("provisioning_api is not available")
nc.preferences.delete("dav", key="user_status_automation")
with pytest.raises(NextcloudException):
nc.preferences.delete("non_existing_app", "some_cfg_name")
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_preferences_delete_async(anc):
+ if not await anc.preferences.available:
+ pytest.skip("provisioning_api is not available")
+ await anc.preferences.delete("dav", key="user_status_automation")
+ with pytest.raises(NextcloudException):
+ await anc.preferences.delete("non_existing_app", "some_cfg_name")
diff --git a/tests/actual_tests/talk_bot_test.py b/tests/actual_tests/talk_bot_test.py
new file mode 100644
index 00000000..c1277166
--- /dev/null
+++ b/tests/actual_tests/talk_bot_test.py
@@ -0,0 +1,179 @@
+from os import environ
+
+import httpx
+import pytest
+
+from nc_py_api import talk, talk_bot
+
+
+@pytest.mark.require_nc(major=27, minor=1)
+def test_register_unregister_talk_bot(nc_app):
+ if nc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ nc_app.unregister_talk_bot("/talk_bot_coverage")
+ list_of_bots = nc_app.talk.list_bots()
+ nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
+ assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots())
+ nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
+ assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots())
+ assert nc_app.unregister_talk_bot("/talk_bot_coverage") is True
+ assert len(list_of_bots) == len(nc_app.talk.list_bots())
+ assert nc_app.unregister_talk_bot("/talk_bot_coverage") is False
+ assert len(list_of_bots) == len(nc_app.talk.list_bots())
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27, minor=1)
+async def test_register_unregister_talk_bot_async(anc_app):
+ if await anc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ await anc_app.unregister_talk_bot("/talk_bot_coverage")
+ list_of_bots = await anc_app.talk.list_bots()
+ await anc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
+ assert len(list_of_bots) + 1 == len(await anc_app.talk.list_bots())
+ await anc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
+ assert len(list_of_bots) + 1 == len(await anc_app.talk.list_bots())
+ assert await anc_app.unregister_talk_bot("/talk_bot_coverage") is True
+ assert len(list_of_bots) == len(await anc_app.talk.list_bots())
+ assert await anc_app.unregister_talk_bot("/talk_bot_coverage") is False
+ assert len(list_of_bots) == len(await anc_app.talk.list_bots())
+
+
+def _test_list_bots(registered_bot: talk.BotInfo):
+ assert isinstance(registered_bot.bot_id, int)
+ assert registered_bot.url.find("/some_url") != -1
+ assert registered_bot.description == "some desc"
+ assert registered_bot.state == 1
+ assert not registered_bot.error_count
+ assert registered_bot.last_error_date == 0
+ assert registered_bot.last_error_message is None
+ assert isinstance(registered_bot.url_hash, str)
+
+
+@pytest.mark.require_nc(major=27, minor=1)
+def test_list_bots(nc, nc_app):
+ if nc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ nc_app.register_talk_bot("/some_url", "some bot name", "some desc")
+ registered_bot = next(i for i in nc.talk.list_bots() if i.bot_name == "some bot name")
+ _test_list_bots(registered_bot)
+ conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ conversation_bots = nc.talk.conversation_list_bots(conversation)
+ assert conversation_bots
+ assert str(conversation_bots[0]).find("name=") != -1
+ finally:
+ nc.talk.delete_conversation(conversation.token)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27, minor=1)
+async def test_list_bots_async(anc, anc_app):
+ if await anc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ await anc_app.register_talk_bot("/some_url", "some bot name", "some desc")
+ registered_bot = next(i for i in await anc.talk.list_bots() if i.bot_name == "some bot name")
+ _test_list_bots(registered_bot)
+ conversation = await anc.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ conversation_bots = await anc.talk.conversation_list_bots(conversation)
+ assert conversation_bots
+ assert str(conversation_bots[0]).find("name=") != -1
+ finally:
+ await anc.talk.delete_conversation(conversation.token)
+
+
+# We're testing the async bot first, since it doesn't have invalid auth tests that triggers brute-force protection.
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub")
+@pytest.mark.require_nc(major=27, minor=1)
+async def test_chat_bot_receive_message_async(anc_app):
+ if await anc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ httpx.delete(f"{'http'}://{environ.get('APP_HOST', '127.0.0.1')}:{environ['APP_PORT']}/reset_bot_secret")
+ talk_bot_inst = talk_bot.AsyncTalkBot("/talk_bot_coverage", "Coverage bot", "Desc")
+ await talk_bot_inst.enabled_handler(True, anc_app)
+ conversation = await anc_app.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ coverage_bot = next(i for i in await anc_app.talk.list_bots() if i.url.endswith("/talk_bot_coverage"))
+ c_bot_info = next(
+ i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 0
+ await anc_app.talk.enable_bot(conversation, coverage_bot)
+ c_bot_info = next(
+ i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 1
+ with pytest.raises(ValueError):
+ await anc_app.talk.send_message("Here are the msg!")
+ await anc_app.talk.send_message("Here are the msg!", conversation)
+ msg_from_bot = None
+ for _ in range(40):
+ messages = await anc_app.talk.receive_messages(conversation, look_in_future=True, timeout=1)
+ if messages[-1].message == "Hello from bot!":
+ msg_from_bot = messages[-1]
+ break
+ assert msg_from_bot
+ c_bot_info = next(
+ i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 1
+ await anc_app.talk.disable_bot(conversation, coverage_bot)
+ c_bot_info = next(
+ i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 0
+ finally:
+ await anc_app.talk.delete_conversation(conversation.token)
+ await talk_bot_inst.enabled_handler(False, anc_app)
+ talk_bot_inst.callback_url = "invalid_url"
+ with pytest.raises(RuntimeError):
+ await talk_bot_inst.send_message("message", 999999, token="sometoken")
+
+
+@pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub")
+@pytest.mark.require_nc(major=27, minor=1)
+def test_chat_bot_receive_message(nc_app):
+ if nc_app.talk.bots_available is False:
+ pytest.skip("Need Talk bots support")
+ httpx.delete(f"{'http'}://{environ.get('APP_HOST', '127.0.0.1')}:{environ['APP_PORT']}/reset_bot_secret")
+ talk_bot_inst = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc")
+ talk_bot_inst.enabled_handler(True, nc_app)
+ conversation = nc_app.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ coverage_bot = next(i for i in nc_app.talk.list_bots() if i.url.endswith("/talk_bot_coverage"))
+ c_bot_info = next(
+ i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 0
+ nc_app.talk.enable_bot(conversation, coverage_bot)
+ c_bot_info = next(
+ i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 1
+ with pytest.raises(ValueError):
+ nc_app.talk.send_message("Here are the msg!")
+ nc_app.talk.send_message("Here are the msg!", conversation)
+ msg_from_bot = None
+ for _ in range(40):
+ messages = nc_app.talk.receive_messages(conversation, look_in_future=True, timeout=1)
+ if messages[-1].message == "Hello from bot!":
+ msg_from_bot = messages[-1]
+ break
+ assert msg_from_bot
+ c_bot_info = next(
+ i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 1
+ nc_app.talk.disable_bot(conversation, coverage_bot)
+ c_bot_info = next(
+ i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
+ )
+ assert c_bot_info.state == 0
+ finally:
+ nc_app.talk.delete_conversation(conversation.token)
+ talk_bot_inst.enabled_handler(False, nc_app)
+ talk_bot_inst.callback_url = "invalid_url"
+ with pytest.raises(RuntimeError):
+ talk_bot_inst.send_message("message", 999999, token="sometoken")
diff --git a/tests/actual_tests/talk_test.py b/tests/actual_tests/talk_test.py
index 77201ed9..2c1903ec 100644
--- a/tests/actual_tests/talk_test.py
+++ b/tests/actual_tests/talk_test.py
@@ -4,7 +4,7 @@
import pytest
from PIL import Image
-from nc_py_api import Nextcloud, NextcloudException, files, talk, talk_bot
+from nc_py_api import AsyncNextcloud, Nextcloud, NextcloudException, files, talk
def test_conversation_create_delete(nc):
@@ -98,6 +98,40 @@ def test_get_conversations_modified_since(nc):
nc.talk.delete_conversation(conversation.token)
+@pytest.mark.asyncio(scope="session")
+async def test_get_conversations_modified_since_async(anc):
+ if await anc.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+ conversation = await anc.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ conversations = await anc.talk.get_user_conversations()
+ assert conversations
+ anc.talk.modified_since += 2 # read notes for ``modified_since`` param in docs.
+ conversations = await anc.talk.get_user_conversations(modified_since=True)
+ assert not conversations
+ conversations = await anc.talk.get_user_conversations(modified_since=9992708529, no_status_update=False)
+ assert not conversations
+ finally:
+ await anc.talk.delete_conversation(conversation.token)
+
+
+def _test_get_conversations_include_status(participants: list[talk.Participant]):
+ assert len(participants) == 2
+ second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"])
+ assert second_participant.actor_type == "users"
+ assert isinstance(second_participant.attendee_id, int)
+ assert isinstance(second_participant.display_name, str)
+ assert isinstance(second_participant.participant_type, talk.ParticipantType)
+ assert isinstance(second_participant.last_ping, int)
+ assert second_participant.participant_flags == talk.InCallFlags.DISCONNECTED
+ assert isinstance(second_participant.permissions, talk.AttendeePermissions)
+ assert isinstance(second_participant.attendee_permissions, talk.AttendeePermissions)
+ assert isinstance(second_participant.session_ids, list)
+ assert isinstance(second_participant.breakout_token, str)
+ assert second_participant.status_message == ""
+ assert str(second_participant).find("last_ping=") != -1
+
+
def test_get_conversations_include_status(nc, nc_client):
if nc.talk.available is False:
pytest.skip("Nextcloud Talk is not installed")
@@ -117,20 +151,7 @@ def test_get_conversations_include_status(nc, nc_client):
assert first_conv.status_message == "my status message"
assert first_conv.status_icon == "😇"
participants = nc.talk.list_participants(first_conv)
- assert len(participants) == 2
- second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"])
- assert second_participant.actor_type == "users"
- assert isinstance(second_participant.attendee_id, int)
- assert isinstance(second_participant.display_name, str)
- assert isinstance(second_participant.participant_type, talk.ParticipantType)
- assert isinstance(second_participant.last_ping, int)
- assert second_participant.participant_flags == talk.InCallFlags.DISCONNECTED
- assert isinstance(second_participant.permissions, talk.AttendeePermissions)
- assert isinstance(second_participant.attendee_permissions, talk.AttendeePermissions)
- assert isinstance(second_participant.session_ids, list)
- assert isinstance(second_participant.breakout_token, str)
- assert second_participant.status_message == ""
- assert str(second_participant).find("last_ping=") != -1
+ _test_get_conversations_include_status(participants)
participants = nc.talk.list_participants(first_conv, include_status=True)
assert len(participants) == 2
second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"])
@@ -140,6 +161,36 @@ def test_get_conversations_include_status(nc, nc_client):
nc.talk.leave_conversation(conversation.token)
+@pytest.mark.asyncio(scope="session")
+async def test_get_conversations_include_status_async(anc, anc_client):
+ if await anc.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+ nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"])
+ nc_second_user.user_status.set_status_type("away")
+ nc_second_user.user_status.set_status("my status message-async", status_icon="😇")
+ conversation = await anc.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"])
+ try:
+ conversations = await anc.talk.get_user_conversations(include_status=False)
+ assert conversations
+ first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id)
+ assert not first_conv.status_type
+ conversations = await anc.talk.get_user_conversations(include_status=True)
+ assert conversations
+ first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id)
+ assert first_conv.status_type == "away"
+ assert first_conv.status_message == "my status message-async"
+ assert first_conv.status_icon == "😇"
+ participants = await anc.talk.list_participants(first_conv)
+ _test_get_conversations_include_status(participants)
+ participants = await anc.talk.list_participants(first_conv, include_status=True)
+ assert len(participants) == 2
+ second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"])
+ assert second_participant.status_message == "my status message-async"
+ assert str(conversation).find("type=") != -1
+ finally:
+ await anc.talk.leave_conversation(conversation.token)
+
+
def test_rename_description_favorite_get_conversation(nc_any):
if nc_any.talk.available is False:
pytest.skip("Nextcloud Talk is not installed")
@@ -172,6 +223,39 @@ def test_rename_description_favorite_get_conversation(nc_any):
nc_any.talk.delete_conversation(conversation)
+@pytest.mark.asyncio(scope="session")
+async def test_rename_description_favorite_get_conversation_async(anc_any):
+ if await anc_any.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+ conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ await anc_any.talk.rename_conversation(conversation, "new era")
+ assert conversation.is_favorite is False
+ await anc_any.talk.set_conversation_description(conversation, "the description")
+ await anc_any.talk.set_conversation_fav(conversation, True)
+ await anc_any.talk.set_conversation_readonly(conversation, True)
+ await anc_any.talk.set_conversation_public(conversation, True)
+ await anc_any.talk.set_conversation_notify_lvl(conversation, talk.NotificationLevel.NEVER_NOTIFY)
+ await anc_any.talk.set_conversation_password(conversation, "zJf4aLafv8941nvs")
+ conversation = await anc_any.talk.get_conversation_by_token(conversation)
+ assert conversation.display_name == "new era"
+ assert conversation.description == "the description"
+ assert conversation.is_favorite is True
+ assert conversation.read_only is True
+ assert conversation.notification_level == talk.NotificationLevel.NEVER_NOTIFY
+ assert conversation.has_password is True
+ await anc_any.talk.set_conversation_fav(conversation, False)
+ await anc_any.talk.set_conversation_readonly(conversation, False)
+ await anc_any.talk.set_conversation_password(conversation, "")
+ await anc_any.talk.set_conversation_public(conversation, False)
+ conversation = await anc_any.talk.get_conversation_by_token(conversation)
+ assert conversation.is_favorite is False
+ assert conversation.read_only is False
+ assert conversation.has_password is False
+ finally:
+ await anc_any.talk.delete_conversation(conversation)
+
+
@pytest.mark.require_nc(major=27)
def test_message_send_delete_reactions(nc_any):
if nc_any.talk.available is False:
@@ -203,89 +287,53 @@ def test_message_send_delete_reactions(nc_any):
nc_any.talk.delete_conversation(conversation)
-@pytest.mark.require_nc(major=27, minor=1)
-def test_register_unregister_talk_bot(nc_app):
- if nc_app.talk.bots_available is False:
- pytest.skip("Need Talk bots support")
- nc_app.unregister_talk_bot("/talk_bot_coverage")
- list_of_bots = nc_app.talk.list_bots()
- nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
- assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots())
- nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc")
- assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots())
- assert nc_app.unregister_talk_bot("/talk_bot_coverage") is True
- assert len(list_of_bots) == len(nc_app.talk.list_bots())
- assert nc_app.unregister_talk_bot("/talk_bot_coverage") is False
- assert len(list_of_bots) == len(nc_app.talk.list_bots())
-
-
-@pytest.mark.require_nc(major=27, minor=1)
-def test_list_bots(nc, nc_app):
- if nc_app.talk.bots_available is False:
- pytest.skip("Need Talk bots support")
- nc_app.register_talk_bot("/some_url", "some bot name", "some desc")
- registered_bot = next(i for i in nc.talk.list_bots() if i.bot_name == "some bot name")
- assert isinstance(registered_bot.bot_id, int)
- assert registered_bot.url.find("/some_url") != -1
- assert registered_bot.description == "some desc"
- assert registered_bot.state == 1
- assert not registered_bot.error_count
- assert registered_bot.last_error_date == 0
- assert registered_bot.last_error_message is None
- assert isinstance(registered_bot.url_hash, str)
- conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin")
- try:
- conversation_bots = nc.talk.conversation_list_bots(conversation)
- assert conversation_bots
- assert str(conversation_bots[0]).find("name=") != -1
- finally:
- nc.talk.delete_conversation(conversation.token)
-
-
-@pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub")
-@pytest.mark.require_nc(major=27, minor=1)
-def test_chat_bot_receive_message(nc_app):
- if nc_app.talk.bots_available is False:
- pytest.skip("Need Talk bots support")
- talk_bot_inst = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc")
- talk_bot_inst.enabled_handler(True, nc_app)
- conversation = nc_app.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27)
+async def test_message_send_delete_reactions_async(anc_any):
+ if await anc_any.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+ conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
try:
- coverage_bot = next(i for i in nc_app.talk.list_bots() if i.url.endswith("/talk_bot_coverage"))
- c_bot_info = next(
- i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
- )
- assert c_bot_info.state == 0
- nc_app.talk.enable_bot(conversation, coverage_bot)
- c_bot_info = next(
- i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
- )
- assert c_bot_info.state == 1
- with pytest.raises(ValueError):
- nc_app.talk.send_message("Here are the msg!")
- nc_app.talk.send_message("Here are the msg!", conversation)
- msg_from_bot = None
- for _ in range(40):
- messages = nc_app.talk.receive_messages(conversation, look_in_future=True, timeout=1)
- if messages[-1].message == "Hello from bot!":
- msg_from_bot = messages[-1]
- break
- assert msg_from_bot
- c_bot_info = next(
- i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
- )
- assert c_bot_info.state == 1
- nc_app.talk.disable_bot(conversation, coverage_bot)
- c_bot_info = next(
- i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id
- )
- assert c_bot_info.state == 0
+ msg = await anc_any.talk.send_message("yo yo yo!", conversation)
+ reactions = await anc_any.talk.react_to_message(msg, "❤️")
+ assert "❤️" in reactions
+ assert len(reactions["❤️"]) == 1
+ reaction = reactions["❤️"][0]
+ assert reaction.actor_id == await anc_any.user
+ assert reaction.actor_type == "users"
+ assert reaction.actor_display_name
+ assert isinstance(reaction.timestamp, int)
+ reactions2 = await anc_any.talk.get_message_reactions(msg)
+ assert reactions == reactions2
+ await anc_any.talk.react_to_message(msg, "☝️️")
+ assert await anc_any.talk.delete_reaction(msg, "❤️")
+ assert not await anc_any.talk.delete_reaction(msg, "☝️️")
+ assert not await anc_any.talk.get_message_reactions(msg)
+ result = await anc_any.talk.delete_message(msg)
+ assert result.system_message == "message_deleted"
+ messages = await anc_any.talk.receive_messages(conversation)
+ deleted = [i for i in messages if i.system_message == "message_deleted"]
+ assert deleted
+ assert str(deleted[0]).find("time=") != -1
finally:
- nc_app.talk.delete_conversation(conversation.token)
- talk_bot_inst.enabled_handler(False, nc_app)
- talk_bot_inst.callback_url = "invalid_url"
- with pytest.raises(RuntimeError):
- talk_bot_inst.send_message("message", 999999, token="sometoken")
+ await anc_any.talk.delete_conversation(conversation)
+
+
+def _test_create_close_poll(poll: talk.Poll, closed: bool, user: str, conversation_token: str):
+ assert isinstance(poll.poll_id, int)
+ assert poll.question == "When was this test written?"
+ assert poll.options == ["2000", "2023", "2030"]
+ assert poll.max_votes == 1
+ assert poll.num_voters == 0
+ assert poll.hidden_results is True
+ assert poll.details == []
+ assert poll.closed is closed
+ assert poll.conversation_token == conversation_token
+ assert poll.actor_type == "users"
+ assert poll.actor_id == user
+ assert isinstance(poll.actor_display_name, str)
+ assert poll.voted_self == []
+ assert poll.votes == []
def test_create_close_poll(nc_any):
@@ -295,35 +343,38 @@ def test_create_close_poll(nc_any):
conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
try:
poll = nc_any.talk.create_poll(conversation, "When was this test written?", ["2000", "2023", "2030"])
-
- def check_poll(closed: bool):
- assert isinstance(poll.poll_id, int)
- assert poll.question == "When was this test written?"
- assert poll.options == ["2000", "2023", "2030"]
- assert poll.max_votes == 1
- assert poll.num_voters == 0
- assert poll.hidden_results is True
- assert poll.details == []
- assert poll.closed is closed
- assert poll.conversation_token == conversation.token
- assert poll.actor_type == "users"
- assert poll.actor_id == nc_any.user
- assert isinstance(poll.actor_display_name, str)
- assert poll.voted_self == []
- assert poll.votes == []
-
assert str(poll).find("author=") != -1
- check_poll(False)
+ _test_create_close_poll(poll, False, nc_any.user, conversation.token)
poll = nc_any.talk.get_poll(poll)
- check_poll(False)
+ _test_create_close_poll(poll, False, nc_any.user, conversation.token)
poll = nc_any.talk.get_poll(poll.poll_id, conversation.token)
- check_poll(False)
+ _test_create_close_poll(poll, False, nc_any.user, conversation.token)
poll = nc_any.talk.close_poll(poll.poll_id, conversation.token)
- check_poll(True)
+ _test_create_close_poll(poll, True, nc_any.user, conversation.token)
finally:
nc_any.talk.delete_conversation(conversation)
+@pytest.mark.asyncio(scope="session")
+async def test_create_close_poll_async(anc_any):
+ if await anc_any.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+
+ conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ poll = await anc_any.talk.create_poll(conversation, "When was this test written?", ["2000", "2023", "2030"])
+ assert str(poll).find("author=") != -1
+ _test_create_close_poll(poll, False, await anc_any.user, conversation.token)
+ poll = await anc_any.talk.get_poll(poll)
+ _test_create_close_poll(poll, False, await anc_any.user, conversation.token)
+ poll = await anc_any.talk.get_poll(poll.poll_id, conversation.token)
+ _test_create_close_poll(poll, False, await anc_any.user, conversation.token)
+ poll = await anc_any.talk.close_poll(poll.poll_id, conversation.token)
+ _test_create_close_poll(poll, True, await anc_any.user, conversation.token)
+ finally:
+ await anc_any.talk.delete_conversation(conversation)
+
+
def test_vote_poll(nc_any):
if nc_any.talk.available is False:
pytest.skip("Nextcloud Talk is not installed")
@@ -359,6 +410,42 @@ def test_vote_poll(nc_any):
nc_any.talk.delete_conversation(conversation)
+@pytest.mark.asyncio(scope="session")
+async def test_vote_poll_async(anc_any):
+ if await anc_any.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+
+ conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ poll = await anc_any.talk.create_poll(
+ conversation, "what color is the grass", ["red", "green", "blue"], hidden_results=False, max_votes=3
+ )
+ assert poll.hidden_results is False
+ assert not poll.voted_self
+ poll = await anc_any.talk.vote_poll([0, 2], poll)
+ assert poll.voted_self == [0, 2]
+ assert poll.votes == {
+ "option-0": 1,
+ "option-2": 1,
+ }
+ assert poll.num_voters == 1
+ poll = await anc_any.talk.vote_poll([1], poll.poll_id, conversation)
+ assert poll.voted_self == [1]
+ assert poll.votes == {
+ "option-1": 1,
+ }
+ poll = await anc_any.talk.close_poll(poll)
+ assert poll.closed is True
+ assert len(poll.details) == 1
+ assert poll.details[0].actor_id == await anc_any.user
+ assert poll.details[0].actor_type == "users"
+ assert poll.details[0].option == 1
+ assert isinstance(poll.details[0].actor_display_name, str)
+ assert str(poll.details[0]).find("actor=") != -1
+ finally:
+ await anc_any.talk.delete_conversation(conversation)
+
+
@pytest.mark.require_nc(major=27)
def test_conversation_avatar(nc_any):
if nc_any.talk.available is False:
@@ -389,6 +476,37 @@ def test_conversation_avatar(nc_any):
nc_any.talk.delete_conversation(conversation)
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27)
+async def test_conversation_avatar_async(anc_any):
+ if await anc_any.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+
+ conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin")
+ try:
+ assert conversation.is_custom_avatar is False
+ r = await anc_any.talk.get_conversation_avatar(conversation)
+ assert isinstance(r, bytes)
+ im = Image.effect_mandelbrot((512, 512), (-3, -2.5, 2, 2.5), 100)
+ buffer = BytesIO()
+ im.save(buffer, format="PNG")
+ buffer.seek(0)
+ r = await anc_any.talk.set_conversation_avatar(conversation, buffer.read())
+ assert r.is_custom_avatar is True
+ r = await anc_any.talk.get_conversation_avatar(conversation)
+ assert isinstance(r, bytes)
+ r = await anc_any.talk.delete_conversation_avatar(conversation)
+ assert r.is_custom_avatar is False
+ r = await anc_any.talk.set_conversation_avatar(conversation, ("🫡", None))
+ assert r.is_custom_avatar is True
+ r = await anc_any.talk.get_conversation_avatar(conversation, dark=True)
+ assert isinstance(r, bytes)
+ with pytest.raises(NextcloudException):
+ await anc_any.talk.get_conversation_avatar("not_exist_conversation")
+ finally:
+ await anc_any.talk.delete_conversation(conversation)
+
+
def test_send_receive_file(nc_client):
if nc_client.talk.available is False:
pytest.skip("Nextcloud Talk is not installed")
@@ -423,3 +541,40 @@ def test_send_receive_file(nc_client):
assert fs_node.is_dir is True
finally:
nc_client.talk.leave_conversation(conversation.token)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_send_receive_file_async(anc_client):
+ if await anc_client.talk.available is False:
+ pytest.skip("Nextcloud Talk is not installed")
+
+ nc_second_user = AsyncNextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"])
+ conversation = await anc_client.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"])
+ try:
+ r, reference_id = await anc_client.talk.send_file("/test_dir/test_12345_text.txt", conversation)
+ assert isinstance(reference_id, str)
+ assert isinstance(r, files.Share)
+ for _ in range(10):
+ m = await nc_second_user.talk.receive_messages(conversation, limit=1)
+ if m and isinstance(m[0], talk.TalkFileMessage):
+ break
+ m_t: talk.TalkFileMessage = m[0] # noqa
+ fs_node = m_t.to_fs_node()
+ assert await nc_second_user.files.download(fs_node) == b"12345"
+ assert m_t.reference_id == reference_id
+ assert fs_node.is_dir is False
+ # test with directory
+ directory = await anc_client.files.by_path("/test_dir/")
+ r, reference_id = await anc_client.talk.send_file(directory, conversation)
+ assert isinstance(reference_id, str)
+ assert isinstance(r, files.Share)
+ for _ in range(10):
+ m = await nc_second_user.talk.receive_messages(conversation, limit=1)
+ if m and m[0].reference_id == reference_id:
+ break
+ m_t: talk.TalkFileMessage = m[0] # noqa
+ assert m_t.reference_id == reference_id
+ fs_node = m_t.to_fs_node()
+ assert fs_node.is_dir is True
+ finally:
+ await anc_client.talk.leave_conversation(conversation.token)
diff --git a/tests/actual_tests/theming_test.py b/tests/actual_tests/theming_test.py
index 82017821..3d2d1ff3 100644
--- a/tests/actual_tests/theming_test.py
+++ b/tests/actual_tests/theming_test.py
@@ -1,5 +1,7 @@
from copy import deepcopy
+import pytest
+
from nc_py_api._theming import convert_str_color # noqa
@@ -29,6 +31,14 @@ def test_get_theme(nc):
assert isinstance(theme["background_default"], bool)
+@pytest.mark.asyncio(scope="session")
+async def test_get_theme_async(anc_any):
+ theme = await anc_any.theme
+ assert isinstance(theme["name"], str)
+ assert isinstance(theme["url"], str)
+ assert isinstance(theme["slogan"], str)
+
+
def test_convert_str_color_values_in(nc_any):
theme = deepcopy(nc_any.theme)
for i in ("#", ""):
diff --git a/tests/actual_tests/ui_files_actions_test.py b/tests/actual_tests/ui_files_actions_test.py
index e214760b..d32d61bb 100644
--- a/tests/actual_tests/ui_files_actions_test.py
+++ b/tests/actual_tests/ui_files_actions_test.py
@@ -37,6 +37,41 @@ def test_register_ui_file_actions(nc_app):
assert str(result).find("name=test_ui_action")
+@pytest.mark.asyncio(scope="session")
+async def test_register_ui_file_actions_async(anc_app):
+ await anc_app.ui.files_dropdown_menu.register("test_ui_action_im", "UI TEST Image", "/ui_action_test", mime="image")
+ result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_im")
+ assert result.name == "test_ui_action_im"
+ assert result.display_name == "UI TEST Image"
+ assert result.action_handler == "ui_action_test"
+ assert result.mime == "image"
+ assert result.permissions == 31
+ assert result.order == 0
+ assert result.icon == ""
+ assert result.appid == "nc_py_api"
+ await anc_app.ui.files_dropdown_menu.unregister(result.name)
+ await anc_app.ui.files_dropdown_menu.register("test_ui_action_any", "UI TEST", "ui_action", permissions=1, order=1)
+ result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any")
+ assert result.name == "test_ui_action_any"
+ assert result.display_name == "UI TEST"
+ assert result.action_handler == "ui_action"
+ assert result.mime == "file"
+ assert result.permissions == 1
+ assert result.order == 1
+ assert result.icon == ""
+ await anc_app.ui.files_dropdown_menu.register("test_ui_action_any", "UI", "/ui_action2", icon="/img/icon.svg")
+ result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any")
+ assert result.name == "test_ui_action_any"
+ assert result.display_name == "UI"
+ assert result.action_handler == "ui_action2"
+ assert result.mime == "file"
+ assert result.permissions == 31
+ assert result.order == 0
+ assert result.icon == "img/icon.svg"
+ await anc_app.ui.files_dropdown_menu.unregister(result.name)
+ assert str(result).find("name=test_ui_action")
+
+
def test_unregister_ui_file_actions(nc_app):
nc_app.ui.files_dropdown_menu.register("test_ui_action", "NcPyApi UI TEST", "/any_rel_url")
nc_app.ui.files_dropdown_menu.unregister("test_ui_action")
@@ -46,6 +81,16 @@ def test_unregister_ui_file_actions(nc_app):
nc_app.ui.files_dropdown_menu.unregister("test_ui_action", not_fail=False)
+@pytest.mark.asyncio(scope="session")
+async def test_unregister_ui_file_actions_async(anc_app):
+ await anc_app.ui.files_dropdown_menu.register("test_ui_action", "NcPyApi UI TEST", "/any_rel_url")
+ await anc_app.ui.files_dropdown_menu.unregister("test_ui_action")
+ assert await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action") is None
+ await anc_app.ui.files_dropdown_menu.unregister("test_ui_action")
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_app.ui.files_dropdown_menu.unregister("test_ui_action", not_fail=False)
+
+
def test_ui_file_to_fs_node(nc_app):
def ui_action_check(directory: str, fs_object: FsNode):
permissions = 0
diff --git a/tests/actual_tests/ui_resources_test.py b/tests/actual_tests/ui_resources_test.py
index 2ab7b855..66819ae1 100644
--- a/tests/actual_tests/ui_resources_test.py
+++ b/tests/actual_tests/ui_resources_test.py
@@ -21,6 +21,25 @@ def test_initial_state(nc_app):
nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False)
+@pytest.mark.asyncio(scope="session")
+async def test_initial_state_async(anc_app):
+ await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key")
+ assert await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") is None
+ await anc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": 1, "k2": 2})
+ r = await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key")
+ assert r.appid == anc_app.app_cfg.app_name
+ assert r.name == "some_page"
+ assert r.key == "some_key"
+ assert r.value == {"k1": 1, "k2": 2}
+ await anc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": "v1"})
+ r = await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key")
+ assert r.value == {"k1": "v1"}
+ assert str(r).find("key=some_key")
+ await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False)
+
+
def test_initial_states(nc_app):
nc_app.ui.resources.set_initial_state("top_menu", "some_page", "key1", [])
nc_app.ui.resources.set_initial_state("top_menu", "some_page", "key2", {"k2": "v2"})
@@ -53,6 +72,26 @@ def test_script(nc_app):
nc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False)
+@pytest.mark.asyncio(scope="session")
+async def test_script_async(anc_app):
+ await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script")
+ assert await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") is None
+ await anc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script")
+ r = await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script")
+ assert r.appid == anc_app.app_cfg.app_name
+ assert r.name == "some_page"
+ assert r.path == "js/some_script"
+ assert r.after_app_id == ""
+ await anc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script", "core")
+ r = await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script")
+ assert r.path == "js/some_script"
+ assert r.after_app_id == "core"
+ assert str(r).find("path=js/some_script")
+ await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False)
+
+
def test_scripts(nc_app):
nc_app.ui.resources.set_script("top_menu", "some_page", "js/script1")
nc_app.ui.resources.set_script("top_menu", "some_page", "js/script2", "core")
@@ -92,6 +131,21 @@ def test_style(nc_app):
nc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False)
+@pytest.mark.asyncio(scope="session")
+async def test_style_async(anc_app):
+ await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path")
+ assert await anc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path") is None
+ await anc_app.ui.resources.set_style("top_menu", "some_page", "css/some_path")
+ r = await anc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path")
+ assert r.appid == anc_app.app_cfg.app_name
+ assert r.name == "some_page"
+ assert r.path == "css/some_path"
+ assert str(r).find("path=css/some_path")
+ await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False)
+
+
def test_styles(nc_app):
nc_app.ui.resources.set_style("top_menu", "some_page", "css/style1")
nc_app.ui.resources.set_style("top_menu", "some_page", "css/style2")
diff --git a/tests/actual_tests/ui_top_menu_test.py b/tests/actual_tests/ui_top_menu_test.py
index 4753b24e..2e5278e5 100644
--- a/tests/actual_tests/ui_top_menu_test.py
+++ b/tests/actual_tests/ui_top_menu_test.py
@@ -10,7 +10,7 @@ def test_register_ui_top_menu(nc_app):
assert result.display_name == "Disp name"
assert result.icon == ""
assert result.admin_required is False
- assert result.appid == "nc_py_api"
+ assert result.appid == nc_app.app_cfg.app_name
nc_app.ui.top_menu.unregister(result.name)
assert nc_app.ui.top_menu.get_entry("test_name") is None
nc_app.ui.top_menu.unregister(result.name)
@@ -31,3 +31,34 @@ def test_register_ui_top_menu(nc_app):
nc_app.ui.top_menu.unregister(result.name)
assert nc_app.ui.top_menu.get_entry("test_name") is None
assert str(result).find("name=test_name")
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_register_ui_top_menu_async(anc_app):
+ await anc_app.ui.top_menu.register("test_name", "Disp name", "")
+ result = await anc_app.ui.top_menu.get_entry("test_name")
+ assert result.name == "test_name"
+ assert result.display_name == "Disp name"
+ assert result.icon == ""
+ assert result.admin_required is False
+ assert result.appid == anc_app.app_cfg.app_name
+ await anc_app.ui.top_menu.unregister(result.name)
+ assert await anc_app.ui.top_menu.get_entry("test_name") is None
+ await anc_app.ui.top_menu.unregister(result.name)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_app.ui.top_menu.unregister(result.name, not_fail=False)
+ await anc_app.ui.top_menu.register("test_name", "display", "/img/test.svg", admin_required=True)
+ result = await anc_app.ui.top_menu.get_entry("test_name")
+ assert result.name == "test_name"
+ assert result.display_name == "display"
+ assert result.icon == "img/test.svg"
+ assert result.admin_required is True
+ await anc_app.ui.top_menu.register("test_name", "Display name", "", admin_required=False)
+ result = await anc_app.ui.top_menu.get_entry("test_name")
+ assert result.name == "test_name"
+ assert result.display_name == "Display name"
+ assert result.icon == ""
+ assert result.admin_required is False
+ await anc_app.ui.top_menu.unregister(result.name)
+ assert await anc_app.ui.top_menu.get_entry("test_name") is None
+ assert str(result).find("name=test_name")
diff --git a/tests/actual_tests/user_status_test.py b/tests/actual_tests/user_status_test.py
index abd2451e..e2d9799e 100644
--- a/tests/actual_tests/user_status_test.py
+++ b/tests/actual_tests/user_status_test.py
@@ -2,13 +2,23 @@
import pytest
-from nc_py_api.user_status import ClearAt, UserStatus
+from nc_py_api.user_status import (
+ ClearAt,
+ CurrentUserStatus,
+ PredefinedStatus,
+ UserStatus,
+)
def test_available(nc):
assert nc.user_status.available
+@pytest.mark.asyncio(scope="session")
+async def test_available_async(anc):
+ assert await anc.user_status.available
+
+
def compare_user_statuses(p1: UserStatus, p2: UserStatus):
assert p1.user_id == p2.user_id
assert p1.status_message == p2.status_message
@@ -17,12 +27,7 @@ def compare_user_statuses(p1: UserStatus, p2: UserStatus):
assert p1.status_type == p2.status_type
-@pytest.mark.parametrize("message", ("1 2 3", None, ""))
-def test_get_status(nc, message):
- nc.user_status.set_status(message)
- r1 = nc.user_status.get_current()
- r2 = nc.user_status.get(nc.user)
- compare_user_statuses(r1, r2)
+def _test_get_status(r1: CurrentUserStatus, message):
assert r1.user_id == "admin"
assert r1.status_icon is None
assert r1.status_clear_at is None
@@ -34,22 +39,59 @@ def test_get_status(nc, message):
assert str(r1).find("status_id=") != -1
+@pytest.mark.parametrize("message", ("1 2 3", None, ""))
+def test_get_status(nc, message):
+ nc.user_status.set_status(message)
+ r1 = nc.user_status.get_current()
+ r2 = nc.user_status.get(nc.user)
+ compare_user_statuses(r1, r2)
+ _test_get_status(r1, message)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("message", ("1 2 3", None, ""))
+async def test_get_status_async(anc, message):
+ await anc.user_status.set_status(message)
+ r1 = await anc.user_status.get_current()
+ r2 = await anc.user_status.get(await anc.user)
+ compare_user_statuses(r1, r2)
+ _test_get_status(r1, message)
+
+
def test_get_status_non_existent_user(nc):
assert nc.user_status.get("no such user") is None
+@pytest.mark.asyncio(scope="session")
+async def test_get_status_non_existent_user_async(anc):
+ assert await anc.user_status.get("no such user") is None
+
+
+def _test_get_predefined(r: list[PredefinedStatus]):
+ assert isinstance(r, list)
+ assert r
+ for i in r:
+ assert isinstance(i.status_id, str)
+ assert isinstance(i.message, str)
+ assert isinstance(i.icon, str)
+ assert isinstance(i.clear_at, ClearAt) or i.clear_at is None
+
+
def test_get_predefined(nc):
r = nc.user_status.get_predefined()
if nc.srv_version["major"] < 27:
assert r == []
else:
- assert isinstance(r, list)
- assert r
- for i in r:
- assert isinstance(i.status_id, str)
- assert isinstance(i.message, str)
- assert isinstance(i.icon, str)
- assert isinstance(i.clear_at, ClearAt) or i.clear_at is None
+ _test_get_predefined(r)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_predefined_async(anc):
+ r = await anc.user_status.get_predefined()
+ if (await anc.srv_version)["major"] < 27:
+ assert r == []
+ else:
+ _test_get_predefined(r)
def test_get_list(nc):
@@ -63,6 +105,18 @@ def test_get_list(nc):
assert str(i).find("status_type=") != -1
+@pytest.mark.asyncio(scope="session")
+async def test_get_list_async(anc):
+ r_all = await anc.user_status.get_list()
+ assert r_all
+ assert isinstance(r_all, list)
+ r_current = await anc.user_status.get_current()
+ for i in r_all:
+ if i.user_id == await anc.user:
+ compare_user_statuses(i, r_current)
+ assert str(i).find("status_type=") != -1
+
+
def test_set_status(nc):
time_clear = int(time()) + 60
nc.user_status.set_status("cool status", time_clear)
@@ -82,6 +136,26 @@ def test_set_status(nc):
assert r.status_icon is None
+@pytest.mark.asyncio(scope="session")
+async def test_set_status_async(anc):
+ time_clear = int(time()) + 60
+ await anc.user_status.set_status("cool status", time_clear)
+ r = await anc.user_status.get_current()
+ assert r.status_message == "cool status"
+ assert r.status_clear_at == time_clear
+ assert r.status_icon is None
+ await anc.user_status.set_status("Sick!", status_icon="🤒")
+ r = await anc.user_status.get_current()
+ assert r.status_message == "Sick!"
+ assert r.status_clear_at is None
+ assert r.status_icon == "🤒"
+ await anc.user_status.set_status(None)
+ r = await anc.user_status.get_current()
+ assert r.status_message is None
+ assert r.status_clear_at is None
+ assert r.status_icon is None
+
+
@pytest.mark.parametrize("value", ("online", "away", "dnd", "invisible", "offline"))
def test_set_status_type(nc, value):
nc.user_status.set_status_type(value)
@@ -90,6 +164,15 @@ def test_set_status_type(nc, value):
assert r.status_type_defined
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("value", ("online", "away", "dnd", "invisible", "offline"))
+async def test_set_status_type_async(anc, value):
+ await anc.user_status.set_status_type(value)
+ r = await anc.user_status.get_current()
+ assert r.status_type == value
+ assert r.status_type_defined
+
+
@pytest.mark.parametrize("clear_at", (None, int(time()) + 360))
def test_set_predefined(nc, clear_at):
if nc.srv_version["major"] < 27:
@@ -105,15 +188,43 @@ def test_set_predefined(nc, clear_at):
assert r.status_clear_at == clear_at
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.parametrize("clear_at", (None, int(time()) + 360))
+async def test_set_predefined_async(anc, clear_at):
+ if (await anc.srv_version)["major"] < 27:
+ await anc.user_status.set_predefined("meeting")
+ else:
+ predefined_statuses = await anc.user_status.get_predefined()
+ for i in predefined_statuses:
+ await anc.user_status.set_predefined(i.status_id, clear_at)
+ r = await anc.user_status.get_current()
+ assert r.status_message == i.message
+ assert r.status_id == i.status_id
+ assert r.message_predefined
+ assert r.status_clear_at == clear_at
+
+
@pytest.mark.require_nc(major=27)
def test_get_back_status_from_from_empty_user(nc_app):
orig_user = nc_app._session.user
- nc_app._session.user = ""
+ nc_app._session.set_user("")
try:
with pytest.raises(ValueError):
nc_app.user_status.get_backup_status("")
finally:
- nc_app._session.user = orig_user
+ nc_app._session.set_user(orig_user)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27)
+async def test_get_back_status_from_from_empty_user_async(anc_app):
+ orig_user = await anc_app._session.user
+ anc_app._session.set_user("")
+ try:
+ with pytest.raises(ValueError):
+ await anc_app.user_status.get_backup_status("")
+ finally:
+ anc_app._session.set_user(orig_user)
@pytest.mark.require_nc(major=27)
@@ -121,6 +232,18 @@ def test_get_back_status_from_from_non_exist_user(nc):
assert nc.user_status.get_backup_status("mёm_m-m.l") is None
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27)
+async def test_get_back_status_from_from_non_exist_user_async(anc):
+ assert await anc.user_status.get_backup_status("mёm_m-m.l") is None
+
+
@pytest.mark.require_nc(major=27)
def test_restore_from_non_existing_back_status(nc):
assert nc.user_status.restore_backup_status("no such backup status") is None
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.require_nc(major=27)
+async def test_restore_from_non_existing_back_status_async(anc):
+ assert await anc.user_status.restore_backup_status("no such backup status") is None
diff --git a/tests/actual_tests/users_groups_test.py b/tests/actual_tests/users_groups_test.py
index a68fcb88..1fd7639f 100644
--- a/tests/actual_tests/users_groups_test.py
+++ b/tests/actual_tests/users_groups_test.py
@@ -5,6 +5,7 @@
import pytest
from nc_py_api import NextcloudException
+from nc_py_api.users_groups import GroupDetails
def test_group_get_list(nc, nc_client):
@@ -20,8 +21,21 @@ def test_group_get_list(nc, nc_client):
assert groups[0] != nc.users_groups.get_list(limit=1, offset=1)[0]
-def test_group_get_details(nc, nc_client):
- groups = nc.users_groups.get_details(mask=environ["TEST_GROUP_BOTH"])
+@pytest.mark.asyncio(scope="session")
+async def test_group_get_list_async(anc, anc_client):
+ groups = await anc.users_groups.get_list()
+ assert isinstance(groups, list)
+ assert len(groups) >= 3
+ assert environ["TEST_GROUP_BOTH"] in groups
+ assert environ["TEST_GROUP_USER"] in groups
+ groups = await anc.users_groups.get_list(mask="test_nc_py_api_group")
+ assert len(groups) == 2
+ groups = await anc.users_groups.get_list(limit=1)
+ assert len(groups) == 1
+ assert groups[0] != (await anc.users_groups.get_list(limit=1, offset=1))[0]
+
+
+def _test_group_get_details(groups: list[GroupDetails]):
assert len(groups) == 1
group = groups[0]
assert group.group_id == environ["TEST_GROUP_BOTH"]
@@ -33,12 +47,30 @@ def test_group_get_details(nc, nc_client):
assert str(group).find("user_count=") != -1
+def test_group_get_details(nc, nc_client):
+ groups = nc.users_groups.get_details(mask=environ["TEST_GROUP_BOTH"])
+ _test_group_get_details(groups)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_group_get_details_async(anc, anc_client):
+ groups = await anc.users_groups.get_details(mask=environ["TEST_GROUP_BOTH"])
+ _test_group_get_details(groups)
+
+
def test_get_non_existing_group(nc_client):
groups = nc_client.users_groups.get_list(mask="Such group should not be present")
assert isinstance(groups, list)
assert not groups
+@pytest.mark.asyncio(scope="session")
+async def test_get_non_existing_group_async(anc_client):
+ groups = await anc_client.users_groups.get_list(mask="Such group should not be present")
+ assert isinstance(groups, list)
+ assert not groups
+
+
def test_group_edit(nc_client):
display_name = str(int(datetime.now(timezone.utc).timestamp()))
nc_client.users_groups.edit(environ["TEST_GROUP_USER"], display_name=display_name)
@@ -52,6 +84,20 @@ def test_group_edit(nc_client):
)
+@pytest.mark.asyncio(scope="session")
+async def test_group_edit_async(anc_client):
+ display_name = str(int(datetime.now(timezone.utc).timestamp()))
+ await anc_client.users_groups.edit(environ["TEST_GROUP_USER"], display_name=display_name)
+ assert (await anc_client.users_groups.get_details(mask=environ["TEST_GROUP_USER"]))[0].display_name == display_name
+ with pytest.raises(NextcloudException) as exc_info:
+ await anc_client.users_groups.edit("non_existing_group", display_name="earth people")
+ # remove 996 in the future, PR was already accepted in Nextcloud Server
+ assert exc_info.value.status_code in (
+ 404,
+ 996,
+ )
+
+
def test_group_display_name_promote_demote(nc_client):
group_id = "test_group_display_name_promote_demote"
with contextlib.suppress(NextcloudException):
@@ -88,3 +134,42 @@ def test_group_display_name_promote_demote(nc_client):
nc_client.users_groups.delete(group_id)
with pytest.raises(NextcloudException):
nc_client.users_groups.delete(group_id)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_group_display_name_promote_demote_async(anc_client):
+ group_id = "test_group_display_name_promote_demote"
+ with contextlib.suppress(NextcloudException):
+ await anc_client.users_groups.delete(group_id)
+ await anc_client.users_groups.create(group_id, display_name="12345")
+ try:
+ group_details = await anc_client.users_groups.get_details(mask=group_id)
+ assert len(group_details) == 1
+ assert group_details[0].display_name == "12345"
+
+ group_members = await anc_client.users_groups.get_members(group_id)
+ assert isinstance(group_members, list)
+ assert not group_members
+ group_subadmins = await anc_client.users_groups.get_subadmins(group_id)
+ assert isinstance(group_subadmins, list)
+ assert not group_subadmins
+
+ await anc_client.users.add_to_group(environ["TEST_USER_ID"], group_id)
+ group_members = await anc_client.users_groups.get_members(group_id)
+ assert group_members[0] == environ["TEST_USER_ID"]
+ group_subadmins = await anc_client.users_groups.get_subadmins(group_id)
+ assert not group_subadmins
+ await anc_client.users.promote_to_subadmin(environ["TEST_USER_ID"], group_id)
+ group_subadmins = await anc_client.users_groups.get_subadmins(group_id)
+ assert group_subadmins[0] == environ["TEST_USER_ID"]
+
+ await anc_client.users.demote_from_subadmin(environ["TEST_USER_ID"], group_id)
+ group_subadmins = await anc_client.users_groups.get_subadmins(group_id)
+ assert not group_subadmins
+ await anc_client.users.remove_from_group(environ["TEST_USER_ID"], group_id)
+ group_members = await anc_client.users_groups.get_members(group_id)
+ assert not group_members
+ finally:
+ await anc_client.users_groups.delete(group_id)
+ with pytest.raises(NextcloudException):
+ await anc_client.users_groups.delete(group_id)
diff --git a/tests/actual_tests/users_test.py b/tests/actual_tests/users_test.py
index 7e8ef1b1..9bb0cac1 100644
--- a/tests/actual_tests/users_test.py
+++ b/tests/actual_tests/users_test.py
@@ -7,6 +7,7 @@
from PIL import Image
from nc_py_api import (
+ AsyncNextcloudApp,
NextcloudApp,
NextcloudException,
NextcloudExceptionNotFound,
@@ -14,9 +15,7 @@
)
-def test_get_user_info(nc):
- admin = nc.users.get_user("admin")
- current_user = nc.users.get_user()
+def _test_get_user_info(admin: users.UserInfo, current_user: users.UserInfo):
for i in (
"user_id",
"email",
@@ -55,17 +54,44 @@ def test_get_user_info(nc):
assert str(admin).find("last_login=") != -1
+def test_get_user_info(nc):
+ admin = nc.users.get_user("admin")
+ current_user = nc.users.get_user()
+ _test_get_user_info(admin, current_user)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_user_info_async(anc):
+ admin = await anc.users.get_user("admin")
+ current_user = await anc.users.get_user()
+ _test_get_user_info(admin, current_user)
+
+
def test_get_current_user_wo_user(nc):
orig_user = nc._session.user
try:
- nc._session.user = ""
+ nc._session.set_user("")
if isinstance(nc, NextcloudApp):
with pytest.raises(NextcloudException):
nc.users.get_user()
else:
assert isinstance(nc.users.get_user(), users.UserInfo)
finally:
- nc._session.user = orig_user
+ nc._session.set_user(orig_user)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_current_user_wo_user_async(anc):
+ orig_user = await anc._session.user
+ try:
+ anc._session.set_user("")
+ if isinstance(anc, AsyncNextcloudApp):
+ with pytest.raises(NextcloudException):
+ await anc.users.get_user()
+ else:
+ assert isinstance(await anc.users.get_user(), users.UserInfo)
+ finally:
+ anc._session.set_user(orig_user)
def test_get_user_404(nc):
@@ -73,12 +99,25 @@ def test_get_user_404(nc):
nc.users.get_user("non existing user")
+@pytest.mark.asyncio(scope="session")
+async def test_get_user_404_async(anc):
+ with pytest.raises(NextcloudException):
+ await anc.users.get_user("non existing user")
+
+
def test_create_user_with_groups(nc_client):
admin_group = nc_client.users_groups.get_members("admin")
assert environ["TEST_ADMIN_ID"] in admin_group
assert environ["TEST_USER_ID"] not in admin_group
+@pytest.mark.asyncio(scope="session")
+async def test_create_user_with_groups_async(anc_client):
+ admin_group = await anc_client.users_groups.get_members("admin")
+ assert environ["TEST_ADMIN_ID"] in admin_group
+ assert environ["TEST_USER_ID"] not in admin_group
+
+
def test_create_user_no_name_mail(nc_client):
test_user_name = "test_create_user_no_name_mail"
with contextlib.suppress(NextcloudException):
@@ -91,6 +130,19 @@ def test_create_user_no_name_mail(nc_client):
nc_client.users.create(test_user_name, email="")
+@pytest.mark.asyncio(scope="session")
+async def test_create_user_no_name_mail_async(anc_client):
+ test_user_name = "test_create_user_no_name_mail"
+ with contextlib.suppress(NextcloudException):
+ await anc_client.users.delete(test_user_name)
+ with pytest.raises(ValueError):
+ await anc_client.users.create(test_user_name)
+ with pytest.raises(ValueError):
+ await anc_client.users.create(test_user_name, password="")
+ with pytest.raises(ValueError):
+ await anc_client.users.create(test_user_name, email="")
+
+
def test_delete_user(nc_client):
test_user_name = "test_delete_user"
with contextlib.suppress(NextcloudException):
@@ -100,17 +152,41 @@ def test_delete_user(nc_client):
nc_client.users.delete(test_user_name)
+@pytest.mark.asyncio(scope="session")
+async def test_delete_user_async(anc_client):
+ test_user_name = "test_delete_user"
+ with contextlib.suppress(NextcloudException):
+ await anc_client.users.create(test_user_name, password="az1dcaNG4c42")
+ await anc_client.users.delete(test_user_name)
+ with pytest.raises(NextcloudExceptionNotFound):
+ await anc_client.users.delete(test_user_name)
+
+
def test_users_get_list(nc, nc_client):
- users = nc.users.get_list()
- assert isinstance(users, list)
- assert nc.user in users
- assert environ["TEST_ADMIN_ID"] in users
- assert environ["TEST_USER_ID"] in users
- users = nc.users.get_list(limit=1)
- assert len(users) == 1
- assert users[0] != nc.users.get_list(limit=1, offset=1)[0]
- users = nc.users.get_list(mask=environ["TEST_ADMIN_ID"])
- assert len(users) == 1
+ _users = nc.users.get_list()
+ assert isinstance(_users, list)
+ assert nc.user in _users
+ assert environ["TEST_ADMIN_ID"] in _users
+ assert environ["TEST_USER_ID"] in _users
+ _users = nc.users.get_list(limit=1)
+ assert len(_users) == 1
+ assert _users[0] != nc.users.get_list(limit=1, offset=1)[0]
+ _users = nc.users.get_list(mask=environ["TEST_ADMIN_ID"])
+ assert len(_users) == 1
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_users_get_list_async(anc, anc_client):
+ _users = await anc.users.get_list()
+ assert isinstance(_users, list)
+ assert await anc.user in _users
+ assert environ["TEST_ADMIN_ID"] in _users
+ assert environ["TEST_USER_ID"] in _users
+ _users = await anc.users.get_list(limit=1)
+ assert len(_users) == 1
+ assert _users[0] != (await anc.users.get_list(limit=1, offset=1))[0]
+ _users = await anc.users.get_list(mask=environ["TEST_ADMIN_ID"])
+ assert len(_users) == 1
def test_enable_disable_user(nc_client):
@@ -124,12 +200,31 @@ def test_enable_disable_user(nc_client):
nc_client.users.delete(test_user_name)
+@pytest.mark.asyncio(scope="session")
+async def test_enable_disable_user_async(anc_client):
+ test_user_name = "test_enable_disable_user"
+ with contextlib.suppress(NextcloudException):
+ await anc_client.users.create(test_user_name, password="az1dcaNG4c42")
+ await anc_client.users.disable(test_user_name)
+ assert (await anc_client.users.get_user(test_user_name)).enabled is False
+ await anc_client.users.enable(test_user_name)
+ assert (await anc_client.users.get_user(test_user_name)).enabled is True
+ await anc_client.users.delete(test_user_name)
+
+
def test_user_editable_fields(nc):
editable_fields = nc.users.editable_fields()
assert isinstance(editable_fields, list)
assert editable_fields
+@pytest.mark.asyncio(scope="session")
+async def test_user_editable_fields_async(anc):
+ editable_fields = await anc.users.editable_fields()
+ assert isinstance(editable_fields, list)
+ assert editable_fields
+
+
def test_edit_user(nc_client):
nc_client.users.edit(nc_client.user, address="Le Pame", email="admino@gmx.net")
current_user = nc_client.users.get_user()
@@ -141,10 +236,27 @@ def test_edit_user(nc_client):
assert current_user.email == "admin@gmx.net"
+@pytest.mark.asyncio(scope="session")
+async def test_edit_user_async(anc_client):
+ await anc_client.users.edit(await anc_client.user, address="Le Pame", email="admino@gmx.net")
+ current_user = await anc_client.users.get_user()
+ assert current_user.address == "Le Pame"
+ assert current_user.email == "admino@gmx.net"
+ await anc_client.users.edit(await anc_client.user, address="", email="admin@gmx.net")
+ current_user = await anc_client.users.get_user()
+ assert current_user.address == ""
+ assert current_user.email == "admin@gmx.net"
+
+
def test_resend_user_email(nc_client):
nc_client.users.resend_welcome_email(nc_client.user)
+@pytest.mark.asyncio(scope="session")
+async def test_resend_user_email_async(anc_client):
+ await anc_client.users.resend_welcome_email(await anc_client.user)
+
+
def test_avatars(nc):
im = nc.users.get_avatar()
im_64 = nc.users.get_avatar(size=64)
@@ -157,3 +269,18 @@ def test_avatars(nc):
img.load()
with pytest.raises(NextcloudException):
nc.users.get_avatar("not_existing_user")
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_avatars_async(anc):
+ im = await anc.users.get_avatar()
+ im_64 = await anc.users.get_avatar(size=64)
+ im_black = await anc.users.get_avatar(dark=True)
+ im_64_black = await anc.users.get_avatar(size=64, dark=True)
+ assert len(im_64) < len(im)
+ assert len(im_64_black) < len(im_black)
+ for i in (im, im_64, im_black, im_64_black):
+ img = Image.open(BytesIO(i))
+ img.load()
+ with pytest.raises(NextcloudException):
+ await anc.users.get_avatar("not_existing_user")
diff --git a/tests/actual_tests/weather_status_test.py b/tests/actual_tests/weather_status_test.py
index 3e4a8e72..98f0cd00 100644
--- a/tests/actual_tests/weather_status_test.py
+++ b/tests/actual_tests/weather_status_test.py
@@ -7,34 +7,75 @@ def test_available(nc):
assert nc.weather_status.available
-def test_get_set_location(nc):
- nc.weather_status.set_location(longitude=0.0, latitude=0.0)
- loc = nc.weather_status.get_location()
+@pytest.mark.asyncio(scope="session")
+async def test_available_async(anc):
+ assert await anc.weather_status.available
+
+
+def test_get_set_location(nc_any):
+ nc_any.weather_status.set_location(longitude=0.0, latitude=0.0)
+ loc = nc_any.weather_status.get_location()
assert loc.latitude == 0.0
assert loc.longitude == 0.0
assert isinstance(loc.address, str)
assert isinstance(loc.mode, int)
try:
- assert nc.weather_status.set_location(address="Paris, 75007, France")
+ assert nc_any.weather_status.set_location(address="Paris, 75007, France")
except NextcloudException as e:
if e.status_code in (500, 996):
pytest.skip("Some network problem on the host")
raise e from None
- loc = nc.weather_status.get_location()
+ loc = nc_any.weather_status.get_location()
assert loc.latitude
assert loc.longitude
if loc.address.find("Unknown") != -1:
pytest.skip("Some network problem on the host")
assert loc.address.find("Paris") != -1
- assert nc.weather_status.set_location(latitude=41.896655, longitude=12.488776)
- loc = nc.weather_status.get_location()
+ assert nc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776)
+ loc = nc_any.weather_status.get_location()
assert loc.latitude == 41.896655
assert loc.longitude == 12.488776
if loc.address.find("Unknown") != -1:
pytest.skip("Some network problem on the host")
assert loc.address.find("Rom") != -1
- assert nc.weather_status.set_location(latitude=41.896655, longitude=12.488776, address="Paris, France")
- loc = nc.weather_status.get_location()
+ assert nc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776, address="Paris, France")
+ loc = nc_any.weather_status.get_location()
+ assert loc.latitude == 41.896655
+ assert loc.longitude == 12.488776
+ if loc.address.find("Unknown") != -1:
+ pytest.skip("Some network problem on the host")
+ assert loc.address.find("Rom") != -1
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_set_location_async(anc_any):
+ await anc_any.weather_status.set_location(longitude=0.0, latitude=0.0)
+ loc = await anc_any.weather_status.get_location()
+ assert loc.latitude == 0.0
+ assert loc.longitude == 0.0
+ assert isinstance(loc.address, str)
+ assert isinstance(loc.mode, int)
+ try:
+ assert await anc_any.weather_status.set_location(address="Paris, 75007, France")
+ except NextcloudException as e:
+ if e.status_code in (500, 996):
+ pytest.skip("Some network problem on the host")
+ raise e from None
+ loc = await anc_any.weather_status.get_location()
+ assert loc.latitude
+ assert loc.longitude
+ if loc.address.find("Unknown") != -1:
+ pytest.skip("Some network problem on the host")
+ assert loc.address.find("Paris") != -1
+ assert await anc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776)
+ loc = await anc_any.weather_status.get_location()
+ assert loc.latitude == 41.896655
+ assert loc.longitude == 12.488776
+ if loc.address.find("Unknown") != -1:
+ pytest.skip("Some network problem on the host")
+ assert loc.address.find("Rom") != -1
+ assert await anc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776, address="Paris, France")
+ loc = await anc_any.weather_status.get_location()
assert loc.latitude == 41.896655
assert loc.longitude == 12.488776
if loc.address.find("Unknown") != -1:
@@ -47,11 +88,28 @@ def test_get_set_location_no_lat_lon_address(nc):
nc.weather_status.set_location()
-def test_get_forecast(nc):
- nc.weather_status.set_location(latitude=41.896655, longitude=12.488776)
- if nc.weather_status.get_location().address.find("Unknown") != -1:
+@pytest.mark.asyncio(scope="session")
+async def test_get_set_location_no_lat_lon_address_async(anc):
+ with pytest.raises(ValueError):
+ await anc.weather_status.set_location()
+
+
+def test_get_forecast(nc_any):
+ nc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776)
+ if nc_any.weather_status.get_location().address.find("Unknown") != -1:
+ pytest.skip("Some network problem on the host")
+ forecast = nc_any.weather_status.get_forecast()
+ assert isinstance(forecast, list)
+ assert forecast
+ assert isinstance(forecast[0], dict)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_get_forecast_async(anc_any):
+ await anc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776)
+ if (await anc_any.weather_status.get_location()).address.find("Unknown") != -1:
pytest.skip("Some network problem on the host")
- forecast = nc.weather_status.get_forecast()
+ forecast = await anc_any.weather_status.get_forecast()
assert isinstance(forecast, list)
assert forecast
assert isinstance(forecast[0], dict)
@@ -68,6 +126,18 @@ def test_get_set_favorites(nc):
assert any("Madrid" in x for x in r)
+@pytest.mark.asyncio(scope="session")
+async def test_get_set_favorites_async(anc):
+ await anc.weather_status.set_favorites([])
+ r = await anc.weather_status.get_favorites()
+ assert isinstance(r, list)
+ assert not r
+ await anc.weather_status.set_favorites(["Paris, France", "Madrid, Spain"])
+ r = await anc.weather_status.get_favorites()
+ assert any("Paris" in x for x in r)
+ assert any("Madrid" in x for x in r)
+
+
def test_set_mode(nc):
nc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION)
assert nc.weather_status.get_location().mode == weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION.value
@@ -75,8 +145,28 @@ def test_set_mode(nc):
assert nc.weather_status.get_location().mode == weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION.value
+@pytest.mark.asyncio(scope="session")
+async def test_set_mode_async(anc):
+ await anc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION)
+ assert (
+ await anc.weather_status.get_location()
+ ).mode == weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION.value
+ await anc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION)
+ assert (
+ await anc.weather_status.get_location()
+ ).mode == weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION.value
+
+
def test_set_mode_invalid(nc):
with pytest.raises(ValueError):
nc.weather_status.set_mode(weather_status.WeatherLocationMode.UNKNOWN)
with pytest.raises(ValueError):
nc.weather_status.set_mode(0)
+
+
+@pytest.mark.asyncio(scope="session")
+async def test_set_mode_invalid_async(anc):
+ with pytest.raises(ValueError):
+ await anc.weather_status.set_mode(weather_status.WeatherLocationMode.UNKNOWN)
+ with pytest.raises(ValueError):
+ await anc.weather_status.set_mode(0)
diff --git a/tests/actual_tests/z_special_test.py b/tests/actual_tests/z_special_test.py
index 9a7924b9..14802399 100644
--- a/tests/actual_tests/z_special_test.py
+++ b/tests/actual_tests/z_special_test.py
@@ -13,11 +13,7 @@
@pytest.mark.skipif("NC_AUTH_USER" not in environ or "NC_AUTH_PASS" not in environ, reason="Needs login & paasword.")
@pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub")
def test_password_confirmation(nc_client):
- # patch "PasswordConfirmationMiddleware.php" decreasing asking before Password Confirmation from 30 min to 15 secs
- patch_path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "data/nc_pass_confirm.patch")
- cwd_path = path.dirname(path.dirname(path.dirname(path.dirname(path.abspath(__file__)))))
- run(["patch", "-p", "1", "-i", patch_path], cwd=cwd_path, check=True)
- sleep(6)
+ patch_path, cwd_path = _test_password_confirmation()
nc_client.update_server_info()
old_adapter = nc_client._session.adapter
with contextlib.suppress(NextcloudException):
@@ -25,3 +21,26 @@ def test_password_confirmation(nc_client):
nc_client.users.delete("test_cover_user_spec")
assert old_adapter != nc_client._session.adapter
run(["git", "apply", "-R", patch_path], cwd=cwd_path, check=True)
+
+
+@pytest.mark.asyncio(scope="session")
+@pytest.mark.skipif("NC_AUTH_USER" not in environ or "NC_AUTH_PASS" not in environ, reason="Needs login & paasword.")
+@pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub")
+async def test_password_confirmation_async(anc_client):
+ patch_path, cwd_path = _test_password_confirmation()
+ await anc_client.update_server_info()
+ old_adapter = anc_client._session.adapter
+ with contextlib.suppress(NextcloudException):
+ await anc_client.users.create("test_cover_user_spec", password="ThisIsA54StrongPassword013")
+ await anc_client.users.delete("test_cover_user_spec")
+ assert old_adapter != anc_client._session.adapter
+ run(["git", "apply", "-R", patch_path], cwd=cwd_path, check=True)
+
+
+def _test_password_confirmation() -> tuple[str, str]:
+ # patch "PasswordConfirmationMiddleware.php" decreasing asking before Password Confirmation from 30 min to 5 secs
+ patch_path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "data/nc_pass_confirm.patch")
+ cwd_path = path.dirname(path.dirname(path.dirname(path.dirname(path.abspath(__file__)))))
+ run(["patch", "-p", "1", "-i", patch_path], cwd=cwd_path, check=True)
+ sleep(6)
+ return patch_path, cwd_path
diff --git a/tests/conftest.py b/tests/conftest.py
index 39bdbe8d..70d79d95 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,19 +3,29 @@
import pytest
-from nc_py_api import Nextcloud, NextcloudApp, _session # noqa
+from nc_py_api import ( # noqa
+ AsyncNextcloud,
+ AsyncNextcloudApp,
+ Nextcloud,
+ NextcloudApp,
+ _session,
+)
from . import gfixture_set_env # noqa
_TEST_FAILED_INCREMENTAL: dict[str, dict[tuple[int, ...], str]] = {}
NC_CLIENT = None if environ.get("SKIP_NC_CLIENT_TESTS", False) else Nextcloud()
+NC_CLIENT_ASYNC = None if environ.get("SKIP_NC_CLIENT_TESTS", False) else AsyncNextcloud()
if environ.get("SKIP_AA_TESTS", False):
NC_APP = None
+ NC_APP_ASYNC = None
else:
NC_APP = NextcloudApp(user="admin")
+ NC_APP_ASYNC = AsyncNextcloudApp(user="admin")
if "app_api" not in NC_APP.capabilities:
NC_APP = None
+ NC_APP_ASYNC = None
if NC_CLIENT is None and NC_APP is None:
raise EnvironmentError("Tests require at least Nextcloud or NextcloudApp.")
@@ -32,6 +42,13 @@ def nc_client() -> Optional[Nextcloud]:
return NC_CLIENT
+@pytest.fixture(scope="session")
+def anc_client() -> Optional[AsyncNextcloud]:
+ if NC_CLIENT_ASYNC is None:
+ pytest.skip("Need Async Client mode")
+ return NC_CLIENT_ASYNC
+
+
@pytest.fixture(scope="session")
def nc_app() -> Optional[NextcloudApp]:
if NC_APP is None:
@@ -39,18 +56,37 @@ def nc_app() -> Optional[NextcloudApp]:
return NC_APP
+@pytest.fixture(scope="session")
+def anc_app() -> Optional[AsyncNextcloudApp]:
+ if NC_APP_ASYNC is None:
+ pytest.skip("Need Async App mode")
+ return NC_APP_ASYNC
+
+
@pytest.fixture(scope="session")
def nc_any() -> Union[Nextcloud, NextcloudApp]:
"""Marks a test to run once for any of the modes."""
return NC_APP if NC_APP else NC_CLIENT
+@pytest.fixture(scope="session")
+def anc_any() -> Union[AsyncNextcloud, AsyncNextcloudApp]:
+ """Marks a test to run once for any of the modes."""
+ return NC_APP_ASYNC if NC_APP_ASYNC else NC_CLIENT_ASYNC
+
+
@pytest.fixture(scope="session")
def nc(request) -> Union[Nextcloud, NextcloudApp]:
"""Marks a test to run for both modes if possible."""
return request.param
+@pytest.fixture(scope="session")
+def anc(request) -> Union[AsyncNextcloud, AsyncNextcloudApp]:
+ """Marks a test to run for both modes if possible."""
+ return request.param
+
+
def pytest_generate_tests(metafunc):
if "nc" in metafunc.fixturenames:
values_ids = []
@@ -62,6 +98,16 @@ def pytest_generate_tests(metafunc):
values.append(NC_APP)
values_ids.append("app")
metafunc.parametrize("nc", values, ids=values_ids)
+ if "anc" in metafunc.fixturenames:
+ values_ids = []
+ values = []
+ if NC_CLIENT_ASYNC is not None:
+ values.append(NC_CLIENT_ASYNC)
+ values_ids.append("client_async")
+ if NC_APP_ASYNC is not None:
+ values.append(NC_APP_ASYNC)
+ values_ids.append("app_async")
+ metafunc.parametrize("anc", values, ids=values_ids)
def pytest_collection_modifyitems(items):