diff --git a/docs/conf.py b/docs/conf.py index 4cbe72d9..e75cbe77 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,7 +70,7 @@ (r"py:class", r"ComputedFieldInfo"), (r"py:class", r"FieldInfo"), (r"py:class", r"ConfigDict"), - (r"py:.*", r"httpx.*"), + (r"py:.*", r"niquests.*"), ] autodoc_member_order = "bysource" diff --git a/examples/as_app/talk_bot/lib/main.py b/examples/as_app/talk_bot/lib/main.py index 41076970..32697bba 100644 --- a/examples/as_app/talk_bot/lib/main.py +++ b/examples/as_app/talk_bot/lib/main.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import Annotated -import httpx +import niquests from fastapi import BackgroundTasks, Depends, FastAPI, Response from nc_py_api import NextcloudApp, talk_bot @@ -32,7 +32,7 @@ def convert_currency(amount, from_currency, to_currency): base_url = "https://api.exchangerate-api.com/v4/latest/" # Fetch latest exchange rates - response = httpx.get(base_url + from_currency, timeout=60) + response = niquests.get(base_url + from_currency, timeout=60) data = response.json() if "rates" in data: diff --git a/nc_py_api/_exceptions.py b/nc_py_api/_exceptions.py index 3a58814c..c9365e46 100644 --- a/nc_py_api/_exceptions.py +++ b/nc_py_api/_exceptions.py @@ -1,6 +1,6 @@ """Exceptions for the Nextcloud API.""" -from httpx import Response, codes +from niquests import HTTPError, Response class NextcloudException(Exception): @@ -60,6 +60,8 @@ def check_error(response: Response, info: str = ""): else: phrase = "Unknown error" raise NextcloudException(status_code, reason=phrase, info=info) - if not codes.is_error(status_code): - return - raise NextcloudException(status_code, reason=codes(status_code).phrase, info=info) + + try: + response.raise_for_status() + except HTTPError as e: + raise NextcloudException(status_code, reason=response.reason, info=info) from e diff --git a/nc_py_api/_session.py b/nc_py_api/_session.py index cdaa42a6..6ecf393d 100644 --- a/nc_py_api/_session.py +++ b/nc_py_api/_session.py @@ -10,9 +10,11 @@ from enum import IntEnum from json import loads from os import environ +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from httpx import AsyncClient, Client, Headers, Limits, ReadTimeout, Request, Response -from httpx import __version__ as httpx_version +from niquests import AsyncSession, ReadTimeout, Request, Response, Session +from niquests import __version__ as niquests_version +from niquests.structures import CaseInsensitiveDict from starlette.requests import HTTPConnection from . import options @@ -49,6 +51,13 @@ class ServerVersion(typing.TypedDict): """Indicates if the subscription has extended support""" +@dataclass +class Limits: + max_keepalive_connections: int | None = 20 + max_connections: int | None = 100 + keepalive_expiry: int | float | None = 5 + + @dataclass class RuntimeOptions: xdebug_session: str @@ -134,11 +143,11 @@ def __init__(self, **kwargs): class NcSessionBase(ABC): - adapter: AsyncClient | Client - adapter_dav: AsyncClient | Client + adapter: AsyncSession | Session + adapter_dav: AsyncSession | Session cfg: BasicConfig custom_headers: dict - response_headers: Headers + response_headers: CaseInsensitiveDict _user: str _capabilities: dict @@ -150,7 +159,7 @@ def __init__(self, **kwargs): self.limits = Limits(max_keepalive_connections=20, max_connections=20, keepalive_expiry=60.0) self.init_adapter() self.init_adapter_dav() - self.response_headers = Headers() + self.response_headers = CaseInsensitiveDict() self._ocs_regexp = re.compile(r"/ocs/v[12]\.php/|/apps/groupfolders/") def init_adapter(self, restart=False) -> None: @@ -172,7 +181,7 @@ def init_adapter_dav(self, restart=False) -> None: self.adapter_dav.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION) @abstractmethod - def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + def _create_adapter(self, dav: bool = False) -> AsyncSession | Session: pass # pragma: no cover @property @@ -187,8 +196,8 @@ def ae_url_v2(self) -> str: class NcSessionBasic(NcSessionBase, ABC): - adapter: Client - adapter_dav: Client + adapter: Session + adapter_dav: Session def ocs( self, @@ -206,9 +215,7 @@ def ocs( info = f"request: {method} {path}" nested_req = kwargs.pop("nested_req", False) try: - response = self.adapter.request( - method, path, content=content, json=json, params=params, files=files, **kwargs - ) + response = self.adapter.request(method, path, data=content, json=json, params=params, files=files, **kwargs) except ReadTimeout: raise NextcloudException(408, info=info) from None @@ -281,18 +288,18 @@ def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]: return { "base_url": self.cfg.dav_endpoint, "timeout": self.cfg.options.timeout_dav, - "event_hooks": {"request": [], "response": [self._response_event]}, + "event_hooks": {"pre_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]}, + "event_hooks": {"pre_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"}) + request.url = patch_param(request.url, "format", "json") request.headers["Accept"] = "application/json" def _response_event(self, response: Response) -> None: @@ -305,15 +312,15 @@ def _response_event(self, response: Response) -> None: def download2fp(self, url_path: str, fp, dav: bool, params=None, **kwargs): adapter = self.adapter_dav if dav else self.adapter - with adapter.stream("GET", url_path, params=params, headers=kwargs.get("headers")) as response: + with adapter.get(url_path, params=params, headers=kwargs.get("headers"), stream=True) as response: check_error(response) - for data_chunk in response.iter_bytes(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)): + for data_chunk in response.iter_raw(chunk_size=kwargs.get("chunk_size", -1)): fp.write(data_chunk) class AsyncNcSessionBasic(NcSessionBase, ABC): - adapter: AsyncClient - adapter_dav: AsyncClient + adapter: AsyncSession + adapter_dav: AsyncSession async def ocs( self, @@ -332,7 +339,7 @@ async def ocs( nested_req = kwargs.pop("nested_req", False) try: response = await self.adapter.request( - method, path, content=content, json=json, params=params, files=files, **kwargs + method, path, data=content, json=json, params=params, files=files, **kwargs ) except ReadTimeout: raise NextcloudException(408, info=info) from None @@ -350,7 +357,7 @@ async def ocs( and ocs_meta["statuscode"] == 403 and str(ocs_meta["message"]).lower().find("password confirmation is required") != -1 ): - await self.adapter.aclose() + await self.adapter.close() self.init_adapter(restart=True) return await self.ocs( method, path, **kwargs, content=content, json=json, params=params, nested_req=True @@ -408,18 +415,18 @@ def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]: return { "base_url": self.cfg.dav_endpoint, "timeout": self.cfg.options.timeout_dav, - "event_hooks": {"request": [], "response": [self._response_event]}, + "event_hooks": {"pre_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]}, + "event_hooks": {"pre_request": [self._request_event_ocs], "response": [self._response_event]}, } 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 - request.url = request.url.copy_merge_params({"format": "json"}) + request.url = patch_param(request.url, "format", "json") request.headers["Accept"] = "application/json" async def _response_event(self, response: Response) -> None: @@ -432,10 +439,12 @@ async def _response_event(self, response: Response) -> None: async def download2fp(self, url_path: str, fp, dav: bool, params=None, **kwargs): adapter = self.adapter_dav if dav else self.adapter - async with adapter.stream("GET", url_path, params=params, headers=kwargs.get("headers")) as response: - check_error(response) - async for data_chunk in response.aiter_bytes(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)): - fp.write(data_chunk) + response = await adapter.get(url_path, params=params, headers=kwargs.get("headers"), stream=True) + + check_error(response) + + async for data_chunk in await response.iter_raw(chunk_size=kwargs.get("chunk_size", -1)): + fp.write(data_chunk) class NcSession(NcSessionBasic): @@ -445,15 +454,20 @@ def __init__(self, **kwargs): self.cfg = Config(**kwargs) super().__init__() - def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: - return Client( - follow_redirects=True, - limits=self.limits, - verify=self.cfg.options.nc_cert, - **self._get_adapter_kwargs(dav), - auth=self.cfg.auth, + def _create_adapter(self, dav: bool = False) -> AsyncSession | Session: + session_kwargs = self._get_adapter_kwargs(dav) + hooks = session_kwargs.pop("event_hooks") + + session = Session( + keepalive_delay=self.limits.keepalive_expiry, pool_maxsize=self.limits.max_connections, **session_kwargs ) + session.auth = self.cfg.auth + session.verify = self.cfg.options.nc_cert + session.hooks.update(hooks) + + return session + class AsyncNcSession(AsyncNcSessionBasic): cfg: Config @@ -462,21 +476,28 @@ def __init__(self, **kwargs): self.cfg = Config(**kwargs) super().__init__() - def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: - return AsyncClient( - follow_redirects=True, - limits=self.limits, - verify=self.cfg.options.nc_cert, - **self._get_adapter_kwargs(dav), - auth=self.cfg.auth, + def _create_adapter(self, dav: bool = False) -> AsyncSession | Session: + session_kwargs = self._get_adapter_kwargs(dav) + hooks = session_kwargs.pop("event_hooks") + + session = AsyncSession( + keepalive_delay=self.limits.keepalive_expiry, + pool_maxsize=self.limits.max_connections, + **session_kwargs, ) + session.verify = self.cfg.options.nc_cert + session.auth = self.cfg.auth + session.hooks.update(hooks) + + return session + class NcSessionAppBasic(ABC): cfg: AppConfig _user: str - adapter: AsyncClient | Client - adapter_dav: AsyncClient | Client + adapter: AsyncSession | Session + adapter_dav: AsyncSession | Session def __init__(self, **kwargs): self.cfg = AppConfig(**kwargs) @@ -505,22 +526,29 @@ def sign_check(self, request: HTTPConnection) -> str: 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, - "user-agent": f"ExApp/{self.cfg.app_name}/{self.cfg.app_version} (httpx/{httpx_version})", - }, + def _create_adapter(self, dav: bool = False) -> AsyncSession | Session: + session_kwargs = self._get_adapter_kwargs(dav) + session_kwargs["event_hooks"]["pre_request"].append(self._add_auth) + + hooks = session_kwargs.pop("event_hooks") + + session = Session( + keepalive_delay=self.limits.keepalive_expiry, + pool_maxsize=self.limits.max_connections, + **session_kwargs, ) + session.verify = self.cfg.options.nc_cert + session.headers = { + "AA-VERSION": self.cfg.aa_version, + "EX-APP-ID": self.cfg.app_name, + "EX-APP-VERSION": self.cfg.app_version, + "user-agent": f"ExApp/{self.cfg.app_name}/{self.cfg.app_version} (niquests/{niquests_version})", + } + session.hooks.update(hooks) + + return session + def _add_auth(self, request: Request): request.headers.update( {"AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))} @@ -530,23 +558,39 @@ def _add_auth(self, request: Request): 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, - "User-Agent": f"ExApp/{self.cfg.app_name}/{self.cfg.app_version} (httpx/{httpx_version})", - }, + def _create_adapter(self, dav: bool = False) -> AsyncSession | Session: + session_kwargs = self._get_adapter_kwargs(dav) + session_kwargs["event_hooks"]["pre_request"].append(self._add_auth) + + hooks = session_kwargs.pop("event_hooks") + + session = AsyncSession( + keepalive_delay=self.limits.keepalive_expiry, + pool_maxsize=self.limits.max_connections, + **session_kwargs, ) + session.verify = self.cfg.options.nc_cert + session.headers = { + "AA-VERSION": self.cfg.aa_version, + "EX-APP-ID": self.cfg.app_name, + "EX-APP-VERSION": self.cfg.app_version, + "User-Agent": f"ExApp/{self.cfg.app_name}/{self.cfg.app_version} (niquests/{niquests_version})", + } + session.hooks.update(hooks) + + return session async def _add_auth(self, request: Request): request.headers.update( {"AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))} ) + + +def patch_param(url: str, key: str, value: str) -> str: + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query[key] = value + + new_query = urlencode(query, doseq=True) + + return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) diff --git a/nc_py_api/calendar_api.py b/nc_py_api/calendar_api.py index 0832591b..0be6180e 100644 --- a/nc_py_api/calendar_api.py +++ b/nc_py_api/calendar_api.py @@ -23,7 +23,7 @@ def request(self, url, method="GET", body="", headers={}): # noqa pylint: disab if body: body = body.replace(b"\n", b"\r\n").replace(b"\r\r\n", b"\r\n") r = self._session.adapter_dav.request( - method, url if isinstance(url, str) else str(url), content=body, headers=headers + method, url if isinstance(url, str) else str(url), data=body, headers=headers ) return DAVResponse(r) diff --git a/nc_py_api/ex_app/integration_fastapi.py b/nc_py_api/ex_app/integration_fastapi.py index 36ab326d..a1017fcc 100644 --- a/nc_py_api/ex_app/integration_fastapi.py +++ b/nc_py_api/ex_app/integration_fastapi.py @@ -9,7 +9,7 @@ import typing from urllib.parse import urlparse -import httpx +import niquests from fastapi import ( BackgroundTasks, Depends, @@ -143,7 +143,8 @@ def __fetch_model_as_file( ) -> str | None: result_path = download_options.pop("save_path", urlparse(model_path).path.split("/")[-1]) try: - with httpx.stream("GET", model_path, follow_redirects=True) as response: + + with niquests.get("GET", model_path, stream=True) as response: if not response.is_success: nc.log(LogLvl.ERROR, f"Downloading of '{model_path}' returned {response.status_code} status.") return None @@ -171,7 +172,7 @@ def __fetch_model_as_file( with builtins.open(result_path, "wb") as file: last_progress = current_progress - for chunk in response.iter_bytes(5 * 1024 * 1024): + for chunk in response.iter_raw(-1): downloaded_size += file.write(chunk) if total_size: new_progress = min(current_progress + int(progress_for_task * downloaded_size / total_size), 99) diff --git a/nc_py_api/files/_files.py b/nc_py_api/files/_files.py index da5138bb..5276201f 100644 --- a/nc_py_api/files/_files.py +++ b/nc_py_api/files/_files.py @@ -9,7 +9,7 @@ from xml.etree import ElementTree import xmltodict -from httpx import Response +from niquests import Response from .._exceptions import NextcloudException, check_error from .._misc import check_capabilities, clear_from_params_empty diff --git a/nc_py_api/files/files.py b/nc_py_api/files/files.py index 515dcee3..32b14d65 100644 --- a/nc_py_api/files/files.py +++ b/nc_py_api/files/files.py @@ -5,7 +5,7 @@ from pathlib import Path from urllib.parse import quote -from httpx import Headers +from niquests.structures import CaseInsensitiveDict from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error from .._misc import random_string, require_capabilities @@ -80,7 +80,7 @@ def find(self, req: list, path: str | FsNode = "") -> list[FsNode]: # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid" root = build_find_request(req, path, self._session.user, self._session.capabilities) webdav_response = self._session.adapter_dav.request( - "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"} + "SEARCH", "", data=element_tree_as_str(root), headers={"Content-Type": "text/xml"} ) request_info = f"find: {self._session.user}, {req}, {path}" return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info) @@ -133,7 +133,7 @@ def upload(self, path: str | FsNode, content: bytes | str) -> FsNode: """ path = path.user_path if isinstance(path, FsNode) else path full_path = dav_get_obj_path(self._session.user, path) - response = self._session.adapter_dav.put(quote(full_path), content=content) + response = self._session.adapter_dav.put(quote(full_path), data=content) check_error(response, f"upload: user={self._session.user}, path={path}, size={len(content)}") return FsNode(full_path.strip("/"), **etag_fileid_from_response(response)) @@ -215,7 +215,7 @@ def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest ) dest = self._session.cfg.dav_endpoint + quote(full_dest_path) - headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8"), "Overwrite": "T" if overwrite else "F"}) response = self._session.adapter_dav.request( "MOVE", quote(dav_get_obj_path(self._session.user, path_src)), @@ -237,7 +237,7 @@ def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest ) dest = self._session.cfg.dav_endpoint + quote(full_dest_path) - headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8"), "Overwrite": "T" if overwrite else "F"}) response = self._session.adapter_dav.request( "COPY", quote(dav_get_obj_path(self._session.user, path_src)), @@ -257,7 +257,7 @@ def list_by_criteria( """ root = build_list_by_criteria_req(properties, tags, self._session.capabilities) webdav_response = self._session.adapter_dav.request( - "REPORT", dav_get_obj_path(self._session.user), content=element_tree_as_str(root) + "REPORT", dav_get_obj_path(self._session.user), data=element_tree_as_str(root) ) request_info = f"list_files_by_criteria: {self._session.user}" check_error(webdav_response, request_info) @@ -272,7 +272,7 @@ def setfav(self, path: str | FsNode, value: int | bool) -> None: path = path.user_path if isinstance(path, FsNode) else path root = build_setfav_req(value) webdav_response = self._session.adapter_dav.request( - "PROPPATCH", quote(dav_get_obj_path(self._session.user, path)), content=element_tree_as_str(root) + "PROPPATCH", quote(dav_get_obj_path(self._session.user, path)), data=element_tree_as_str(root) ) check_error(webdav_response, f"setfav: path={path}, value={value}") @@ -293,7 +293,7 @@ def trashbin_restore(self, path: str | FsNode) -> None: path = path.user_path if isinstance(path, FsNode) else path dest = self._session.cfg.dav_endpoint + f"/trashbin/{self._session.user}/restore/{restore_name}" - headers = Headers({"Destination": dest}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8")}) response = self._session.adapter_dav.request( "MOVE", quote(f"/trashbin/{self._session.user}/{path}"), @@ -336,7 +336,7 @@ def restore_version(self, file_object: FsNode) -> None: """ require_capabilities("files.versioning", self._session.capabilities) dest = self._session.cfg.dav_endpoint + f"/versions/{self._session.user}/restore/{file_object.name}" - headers = Headers({"Destination": dest}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8")}) response = self._session.adapter_dav.request( "MOVE", quote(f"/versions/{self._session.user}/{file_object.user_path}"), @@ -347,7 +347,7 @@ def restore_version(self, file_object: FsNode) -> None: def list_tags(self) -> list[SystemTag]: """Returns list of the avalaible Tags.""" root = build_list_tag_req() - response = self._session.adapter_dav.request("PROPFIND", "/systemtags", content=element_tree_as_str(root)) + response = self._session.adapter_dav.request("PROPFIND", "/systemtags", data=element_tree_as_str(root)) return build_list_tags_response(response) def get_tags(self, file_id: FsNode | int) -> list[SystemTag]: @@ -389,7 +389,7 @@ def update_tag( 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 = self._session.adapter_dav.request( - "PROPPATCH", f"/systemtags/{tag_id}", content=element_tree_as_str(root) + "PROPPATCH", f"/systemtags/{tag_id}", data=element_tree_as_str(root) ) check_error(response) @@ -466,7 +466,7 @@ def _listdir( webdav_response = self._session.adapter_dav.request( "PROPFIND", quote(dav_path), - content=element_tree_as_str(root), + data=element_tree_as_str(root), headers={"Depth": "infinity" if depth == -1 else str(depth)}, ) return build_listdir_response( @@ -478,7 +478,9 @@ def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode: _dav_path = quote(dav_get_obj_path(self._session.user, _tmp_path, root_path="/uploads")) _v2 = bool(self._session.cfg.options.upload_chunk_v2 and chunk_size >= 5 * 1024 * 1024) full_path = dav_get_obj_path(self._session.user, path) - headers = Headers({"Destination": self._session.cfg.dav_endpoint + quote(full_path)}, encoding="utf-8") + headers = CaseInsensitiveDict( + {"Destination": (self._session.cfg.dav_endpoint + quote(full_path)).encode("utf-8")} + ) if _v2: response = self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers) else: @@ -494,11 +496,11 @@ def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode: end_bytes = start_bytes + len(piece) if _v2: response = self._session.adapter_dav.put( - _dav_path + "/" + str(chunk_number), content=piece, headers=headers + _dav_path + "/" + str(chunk_number), data=piece, headers=headers ) else: _filename = str(start_bytes).rjust(15, "0") + "-" + str(end_bytes).rjust(15, "0") - response = self._session.adapter_dav.put(_dav_path + "/" + _filename, content=piece) + response = self._session.adapter_dav.put(_dav_path + "/" + _filename, data=piece) check_error( response, f"upload_stream(v={_v2}): user={self._session.user}, path={path}, cur_size={end_bytes}", diff --git a/nc_py_api/files/files_async.py b/nc_py_api/files/files_async.py index 54c8b6fb..1d1e2648 100644 --- a/nc_py_api/files/files_async.py +++ b/nc_py_api/files/files_async.py @@ -5,7 +5,7 @@ from pathlib import Path from urllib.parse import quote -from httpx import Headers +from niquests.structures import CaseInsensitiveDict from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error from .._misc import random_string, require_capabilities @@ -82,7 +82,7 @@ async def find(self, req: list, path: str | FsNode = "") -> list[FsNode]: # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid" root = build_find_request(req, path, await self._session.user, await self._session.capabilities) webdav_response = await self._session.adapter_dav.request( - "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"} + "SEARCH", "", data=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) @@ -137,7 +137,7 @@ async def upload(self, path: str | FsNode, content: bytes | str) -> FsNode: """ 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(quote(full_path), content=content) + response = await self._session.adapter_dav.put(quote(full_path), data=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)) @@ -219,7 +219,7 @@ async def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite= await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest ) dest = self._session.cfg.dav_endpoint + quote(full_dest_path) - headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8"), "Overwrite": "T" if overwrite else "F"}) response = await self._session.adapter_dav.request( "MOVE", quote(dav_get_obj_path(await self._session.user, path_src)), @@ -241,7 +241,7 @@ async def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite= await self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest ) dest = self._session.cfg.dav_endpoint + quote(full_dest_path) - headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8"), "Overwrite": "T" if overwrite else "F"}) response = await self._session.adapter_dav.request( "COPY", quote(dav_get_obj_path(await self._session.user, path_src)), @@ -261,7 +261,7 @@ async def list_by_criteria( """ root = build_list_by_criteria_req(properties, tags, await self._session.capabilities) webdav_response = await self._session.adapter_dav.request( - "REPORT", dav_get_obj_path(await self._session.user), content=element_tree_as_str(root) + "REPORT", dav_get_obj_path(await self._session.user), data=element_tree_as_str(root) ) request_info = f"list_files_by_criteria: {await self._session.user}" check_error(webdav_response, request_info) @@ -276,7 +276,7 @@ async def setfav(self, path: str | FsNode, value: int | bool) -> None: path = path.user_path if isinstance(path, FsNode) else path root = build_setfav_req(value) webdav_response = await self._session.adapter_dav.request( - "PROPPATCH", quote(dav_get_obj_path(await self._session.user, path)), content=element_tree_as_str(root) + "PROPPATCH", quote(dav_get_obj_path(await self._session.user, path)), data=element_tree_as_str(root) ) check_error(webdav_response, f"setfav: path={path}, value={value}") @@ -302,7 +302,7 @@ async def trashbin_restore(self, path: str | FsNode) -> None: 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") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8")}) response = await self._session.adapter_dav.request( "MOVE", quote(f"/trashbin/{await self._session.user}/{path}"), @@ -345,7 +345,7 @@ async def restore_version(self, file_object: FsNode) -> None: """ 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") + headers = CaseInsensitiveDict({"Destination": dest.encode("utf-8")}) response = await self._session.adapter_dav.request( "MOVE", quote(f"/versions/{await self._session.user}/{file_object.user_path}"), @@ -356,7 +356,7 @@ async def restore_version(self, file_object: FsNode) -> None: 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)) + response = await self._session.adapter_dav.request("PROPFIND", "/systemtags", data=element_tree_as_str(root)) return build_list_tags_response(response) async def get_tags(self, file_id: FsNode | int) -> list[SystemTag]: @@ -398,7 +398,7 @@ async def update_tag( 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) + "PROPPATCH", f"/systemtags/{tag_id}", data=element_tree_as_str(root) ) check_error(response) @@ -435,7 +435,7 @@ async def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_L quote(full_path), headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)}, ) - check_error(response, f"lock: user={self._session.user}, path={full_path}") + check_error(response, f"lock: user={await self._session.user}, path={full_path}") async def unlock(self, path: FsNode | str) -> None: """Unlocks the file. @@ -449,7 +449,7 @@ async def unlock(self, path: FsNode | str) -> None: quote(full_path), headers={"X-User-Lock": "1"}, ) - check_error(response, f"unlock: user={self._session.user}, path={full_path}") + check_error(response, f"unlock: user={await self._session.user}, path={full_path}") 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 @@ -475,7 +475,7 @@ async def _listdir( webdav_response = await self._session.adapter_dav.request( "PROPFIND", quote(dav_path), - content=element_tree_as_str(root), + data=element_tree_as_str(root), headers={"Depth": "infinity" if depth == -1 else str(depth)}, ) return build_listdir_response( @@ -487,7 +487,9 @@ async def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode: _dav_path = quote(dav_get_obj_path(await self._session.user, _tmp_path, 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 + quote(full_path)}, encoding="utf-8") + headers = CaseInsensitiveDict( + {"Destination": (self._session.cfg.dav_endpoint + quote(full_path)).encode("utf-8")} + ) if _v2: response = await self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers) else: @@ -502,11 +504,11 @@ async def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode: end_bytes = start_bytes + len(piece) if _v2: response = await self._session.adapter_dav.put( - _dav_path + "/" + str(chunk_number), content=piece, headers=headers + _dav_path + "/" + str(chunk_number), data=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) + response = await self._session.adapter_dav.put(_dav_path + "/" + _filename, data=piece) check_error( response, f"upload_stream(v={_v2}): user={await self._session.user}, path={path}, cur_size={end_bytes}", diff --git a/nc_py_api/loginflow_v2.py b/nc_py_api/loginflow_v2.py index 119225a3..8bb68cbb 100644 --- a/nc_py_api/loginflow_v2.py +++ b/nc_py_api/loginflow_v2.py @@ -5,7 +5,7 @@ import time from dataclasses import dataclass -import httpx +import niquests from ._exceptions import check_error from ._session import AsyncNcSession, NcSession @@ -156,6 +156,6 @@ async def poll( return r_model -def _res_to_json(response: httpx.Response) -> dict: +def _res_to_json(response: niquests.Response) -> dict: check_error(response) return json.loads(response.text) diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index 9a648ce7..645b8f9c 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -4,7 +4,7 @@ import typing from abc import ABC -from httpx import Headers +from niquests.structures import CaseInsensitiveDict from ._exceptions import NextcloudExceptionNotFound from ._misc import check_capabilities, require_capabilities @@ -112,8 +112,8 @@ def update_server_info(self) -> None: self._session.update_server_info() @property - def response_headers(self) -> Headers: - """Returns the `HTTPX headers `_ from the last response.""" + def response_headers(self) -> CaseInsensitiveDict: + """Returns the `Niquests headers `_ from the last response.""" return self._session.response_headers @property @@ -216,8 +216,8 @@ async def update_server_info(self) -> None: await self._session.update_server_info() @property - def response_headers(self) -> Headers: - """Returns the `HTTPX headers `_ from the last response.""" + def response_headers(self) -> CaseInsensitiveDict: + """Returns the `Niquests headers `_ from the last response.""" return self._session.response_headers @property diff --git a/nc_py_api/notes.py b/nc_py_api/notes.py index abc14901..b47908f3 100644 --- a/nc_py_api/notes.py +++ b/nc_py_api/notes.py @@ -5,7 +5,7 @@ import json import typing -import httpx +import niquests from ._exceptions import check_error from ._misc import check_capabilities, clear_from_params_empty, require_capabilities @@ -367,6 +367,6 @@ async def set_settings(self, notes_path: str | None = None, file_suffix: str | N check_error(await self._session.adapter.put(self._ep_base + "/settings", json=params)) -def _res_to_json(response: httpx.Response) -> dict: +def _res_to_json(response: niquests.Response) -> dict: check_error(response) return json.loads(response.text) if response.status_code != 304 else {} diff --git a/nc_py_api/options.py b/nc_py_api/options.py index c7300732..a7981a74 100644 --- a/nc_py_api/options.py +++ b/nc_py_api/options.py @@ -33,22 +33,13 @@ SSL certificates (a.k.a CA bundle) used to verify the identity of requested hosts. Either **True** (default CA bundle), a path to an SSL certificate file, or **False** (which will disable verification).""" str_val = environ.get("NPA_NC_CERT", "True") -# https://github.com/encode/httpx/issues/302 -# when "httpx" will switch to use "truststore" by default - uncomment next line -# NPA_NC_CERT = True + +NPA_NC_CERT = True + if str_val.lower() in ("false", "0"): NPA_NC_CERT = False elif str_val.lower() not in ("true", "1"): NPA_NC_CERT = str_val -else: - # Temporary workaround, see comment above. - # Use system certificate stores - - import ssl - - import truststore - - NPA_NC_CERT = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) CHUNKED_UPLOAD_V2 = environ.get("CHUNKED_UPLOAD_V2", True) """Option to enable/disable **version 2** chunked upload(better Object Storages support). diff --git a/nc_py_api/talk_bot.py b/nc_py_api/talk_bot.py index 3b29160c..1d4447f7 100644 --- a/nc_py_api/talk_bot.py +++ b/nc_py_api/talk_bot.py @@ -7,7 +7,7 @@ import os import typing -import httpx +import niquests from . import options from ._misc import random_string @@ -117,7 +117,7 @@ def enabled_handler(self, enabled: bool, nc: NextcloudApp) -> None: def send_message( self, message: str, reply_to_message: int | TalkBotMessage, silent: bool = False, token: str = "" - ) -> tuple[httpx.Response, str]: + ) -> tuple[niquests.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. @@ -127,7 +127,7 @@ def send_message( :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". + :returns: Tuple, where fist element is :py:class:`niquests.Response` and second is a "reference string". :raises ValueError: in case of an invalid usage. :raises RuntimeError: in case of a broken installation. """ @@ -143,7 +143,7 @@ def send_message( } return self._sign_send_request("POST", f"/{token}/message", params, message), reference_id - def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> niquests.Response: """React to a message. :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. @@ -162,7 +162,7 @@ def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: } return self._sign_send_request("POST", f"/{token}/reaction/{message_id}", params, reaction) - def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> niquests.Response: """Removes reaction from a message. :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to remove reaction from. @@ -181,7 +181,7 @@ def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: s } return self._sign_send_request("DELETE", f"/{token}/reaction/{message_id}", params, reaction) - def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> httpx.Response: + def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> niquests.Response: secret = get_bot_secret(self.callback_url) if secret is None: raise RuntimeError("Can't find the 'secret' of the bot. Has the bot been installed?") @@ -189,7 +189,8 @@ def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_s 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() - with httpx.Client(verify=nc_app_cfg.options.nc_cert) as client: + with niquests.Session() as client: + client.verify = nc_app_cfg.options.nc_cert return client.request( method, url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix, @@ -234,7 +235,7 @@ async def enabled_handler(self, enabled: bool, nc: AsyncNextcloudApp) -> None: async def send_message( self, message: str, reply_to_message: int | TalkBotMessage, silent: bool = False, token: str = "" - ) -> tuple[httpx.Response, str]: + ) -> tuple[niquests.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. @@ -244,7 +245,7 @@ async def send_message( :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". + :returns: Tuple, where fist element is :py:class:`niquests.Response` and second is a "reference string". :raises ValueError: in case of an invalid usage. :raises RuntimeError: in case of a broken installation. """ @@ -260,7 +261,9 @@ async def send_message( } 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: + async def react_to_message( + self, message: int | TalkBotMessage, reaction: str, token: str = "" + ) -> niquests.Response: """React to a message. :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. @@ -279,7 +282,7 @@ async def react_to_message(self, message: int | TalkBotMessage, reaction: str, t } 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: + async def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> niquests.Response: """Removes reaction from a message. :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to remove reaction from. @@ -298,7 +301,9 @@ async def delete_reaction(self, message: int | TalkBotMessage, reaction: str, to } 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: + async def _sign_send_request( + self, method: str, url_suffix: str, data: dict, data_to_sign: str + ) -> niquests.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?") @@ -306,7 +311,9 @@ async def _sign_send_request(self, method: str, url_suffix: str, data: dict, dat 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: + async with niquests.AsyncSession() as aclient: + aclient.verify = nc_app_cfg.options.nc_cert + return await aclient.request( method, url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix, diff --git a/pyproject.toml b/pyproject.toml index 9da6f9f2..69dfc528 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,10 +46,9 @@ dynamic = [ ] dependencies = [ "fastapi>=0.109.2", - "httpx>=0.25.2", + "niquests>=3,<4", "pydantic>=2.1.1", "python-dotenv>=1", - "truststore==0.10", "xmltodict>=0.13", ] optional-dependencies.app = [ diff --git a/tests/_app_security_checks.py b/tests/_app_security_checks.py index e5ab56ce..97780cd0 100644 --- a/tests/_app_security_checks.py +++ b/tests/_app_security_checks.py @@ -2,19 +2,19 @@ from os import environ from sys import argv -import httpx +import niquests def sign_request(req_headers: dict, secret=None, user: str = ""): app_secret = secret if secret is not None else environ["APP_SECRET"] - req_headers["AUTHORIZATION-APP-API"] = b64encode(f"{user}:{app_secret}".encode("UTF=8")) + req_headers["AUTHORIZATION-APP-API"] = b64encode(f"{user}:{app_secret}".encode("utf-8")) # params: app base url if __name__ == "__main__": request_url = argv[1] + "/sec_check?value=1" headers = {} - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 401 # Missing headers headers.update( { @@ -24,22 +24,22 @@ def sign_request(req_headers: dict, secret=None, user: str = ""): } ) sign_request(headers) - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 200 # Invalid AA-SIGNATURE sign_request(headers, secret="xxx") - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 401 sign_request(headers) - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 200 # Invalid EX-APP-ID old_app_name = headers.get("EX-APP-ID") headers["EX-APP-ID"] = "unknown_app" sign_request(headers) - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 401 headers["EX-APP-ID"] = old_app_name sign_request(headers) - result = httpx.put(request_url, headers=headers) + result = niquests.put(request_url, headers=headers) assert result.status_code == 200 diff --git a/tests/_install_wait.py b/tests/_install_wait.py index 8a88b988..c89c15a6 100644 --- a/tests/_install_wait.py +++ b/tests/_install_wait.py @@ -2,7 +2,7 @@ from sys import argv from time import sleep -from requests import get +from niquests import get def check_heartbeat(url: str, regexp: str, n_tries: int, wait_interval: float) -> int: diff --git a/tests/actual_tests/logs_test.py b/tests/actual_tests/logs_test.py index dc1ae6af..f375b362 100644 --- a/tests/actual_tests/logs_test.py +++ b/tests/actual_tests/logs_test.py @@ -128,9 +128,9 @@ def test_logging(nc_app): def test_recursive_logging(nc_app): - logging.getLogger("httpx").setLevel(logging.DEBUG) + logging.getLogger("niquests").setLevel(logging.DEBUG) log_handler = setup_nextcloud_logging() logger = logging.getLogger() logger.fatal("testing logging.fatal") logger.removeHandler(log_handler) - logging.getLogger("httpx").setLevel(logging.ERROR) + logging.getLogger("niquests").setLevel(logging.ERROR) diff --git a/tests/actual_tests/misc_test.py b/tests/actual_tests/misc_test.py index 7dff8c8e..ec021c3a 100644 --- a/tests/actual_tests/misc_test.py +++ b/tests/actual_tests/misc_test.py @@ -3,7 +3,7 @@ import os import pytest -from httpx import Request, Response +from niquests import PreparedRequest, Response from nc_py_api import ( AsyncNextcloud, @@ -21,11 +21,18 @@ @pytest.mark.parametrize("code", (995, 996, 997, 998, 999, 1000)) def test_check_error(code): + resp = Response() + + resp.status_code = code + resp.request = PreparedRequest() + resp.request.url = "https://example" + resp.request.method = "GET" + if 996 <= code <= 999: with pytest.raises(NextcloudException): - check_error(Response(code, request=Request(method="GET", url="https://example"))) + check_error(resp) else: - check_error(Response(code, request=Request(method="GET", url="https://example"))) + check_error(resp) def test_nc_exception_to_str(): diff --git a/tests/actual_tests/talk_bot_test.py b/tests/actual_tests/talk_bot_test.py index 99d695ae..ce879f25 100644 --- a/tests/actual_tests/talk_bot_test.py +++ b/tests/actual_tests/talk_bot_test.py @@ -1,6 +1,6 @@ from os import environ -import httpx +import niquests import pytest from nc_py_api import talk, talk_bot @@ -88,7 +88,7 @@ async def test_list_bots_async(anc, anc_app): 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") + niquests.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") @@ -136,7 +136,7 @@ def test_chat_bot_receive_message(nc_app): 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") + niquests.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")