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):