diff --git a/.editorconfig b/.editorconfig index 18b706d..1505fae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,7 +3,7 @@ root = true [*] -max_line_length = 160 +max_line_length = 120 indent_style = space indent_size = 4 charset = utf-8 diff --git a/.flake8 b/.flake8 index 2643256..5c85899 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] extend-ignore = E203, E704, W605, E701 -exclude = .git,__pycache__,venv,migrations -max-line-length = 160 +exclude = .git,__pycache__,venv +max-line-length = 120 diff --git a/pyproject.toml b/pyproject.toml index e518be3..aa51579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "requests~=2.31", + "httpx[http2]~=0.27.0", "pydantic~=2.6", ] @@ -42,7 +42,7 @@ version = { attr = "api_client.__version__" } [tool.bumpversion] -current_version = "1.0.1" +current_version = "1.1.0" commit = true tag = true tag_name = "v{new_version}" @@ -58,7 +58,7 @@ filename = "src/api_client/__init__.py" [tool.black] -line-length = 160 +line-length = 120 target-version = ['py311'] include = '\.pyi?$' extend-exclude = '''( @@ -69,7 +69,7 @@ workers = 4 [tool.isort] profile = "black" -line_length = 160 +line_length = 120 skip = ["migrations", "env", "venv", ".venv", ".git", "media"] diff --git a/requirements.txt b/requirements.txt index d2b8bf3..7f9b45c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ ## Requirements for development of library # Base requirements -requests==2.31.0 +httpx[http2]==0.27.0 pydantic==2.6.3 # Code style @@ -10,7 +10,6 @@ isort==5.13.2 # Type check mypy==1.9 -types-requests==2.31.0.20240218 # Version and package support twine==5.0 diff --git a/src/api_client/__init__.py b/src/api_client/__init__.py index d0020b4..a1426c9 100644 --- a/src/api_client/__init__.py +++ b/src/api_client/__init__.py @@ -1,4 +1,5 @@ -from .client import BaseAPIClient +from .async_client import BaseAPIClient as AsyncClient +from .sync_client import BaseAPIClient as SyncClient -__version__ = "1.0.1" -__all__ = ["BaseAPIClient"] +__version__ = "1.1.0" +__all__ = ["SyncClient", "AsyncClient"] diff --git a/src/api_client/_base.py b/src/api_client/_base.py new file mode 100644 index 0000000..471b216 --- /dev/null +++ b/src/api_client/_base.py @@ -0,0 +1,125 @@ +import logging +from abc import ABC, abstractmethod +from typing import Any, Coroutine + +import httpx +from pydantic import HttpUrl + +from .utils import get_logger +from .utils.logs import get_nonce, shortify_log_value + +_TypeSyncAsyncResponse = httpx.Response | Coroutine[Any, Any, httpx.Response] + + +class BaseClient(ABC): + base_url: HttpUrl + _session: httpx._client.BaseClient + _nonce: str + _logger: logging.Logger + + def __init__(self, nonce: str = "", session: httpx._client.BaseClient | None = None): + self._nonce = nonce or get_nonce() + self._session = session or httpx.Client() + self._logger = get_logger("APIClient") + + @abstractmethod + def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def _request(self, method: str, endpoint: str, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + def _make_full_url(self, endpoint: str) -> str: + return f"{self.base_url}{endpoint}" + + def prepare_authentication(self, request: httpx.Request) -> httpx.Request: + return request + + def _prepare_request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Request: + full_url = self._make_full_url(endpoint) + + self._debug_log_request(method, full_url) + request: httpx.Request = self._session.build_request(method=method, url=full_url, **kwargs) + self._debug_log_prepared_request(request) + self._debug(f"Prepared {request.method} request to '{request.url}'", extra=request.__dict__) + + self._info_log_request_sending( + request, kwargs.get("content") or kwargs.get("files") or kwargs.get("data") or kwargs.get("json") + ) + return request + + def _debug_log_request(self, method: str, full_url: str) -> None: + self._debug(f"Preparing {method} request for '{full_url}'") + + def _debug_log_prepared_request(self, request: httpx.Request) -> None: + self._debug(f"Prepared {request.method} request to '{request.url}'", extra=request.__dict__) + + def _info_log_request_sending(self, request: httpx.Request, log_payload: Any) -> None: + self._info( + f"Sending {request.method} request to '{request.url}' with payload={shortify_log_value(log_payload)!r}", + extra={"payload": shortify_log_value(log_payload)}, + ) + + def _info_log_response(self, response: httpx.Response) -> None: + str_repr_content = response.content.decode("utf8")[:500] + self._info( + f"Response {response.status_code=} {str_repr_content=}", + extra={"status_code": response.status_code, "content": str_repr_content}, + ) + + def _info(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("INFO", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("DEBUG", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("WARNING", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _error(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("ERROR", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _critical(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("CRITICAL", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _log( + self, level: str, msg: object, *args: object, stacklevel: int = 4, extra: dict | None = None, **kwargs: Any + ) -> None: + extra = extra or {} + extra.update(nonce=self._nonce) + self._logger.log( + logging.getLevelNamesMapping()[level], msg, *args, stacklevel=stacklevel, extra=extra, **kwargs + ) diff --git a/src/api_client/async_client.py b/src/api_client/async_client.py new file mode 100644 index 0000000..fd441e7 --- /dev/null +++ b/src/api_client/async_client.py @@ -0,0 +1,46 @@ +from typing import Any + +import httpx + +from ._base import BaseClient + + +class BaseAPIClient(BaseClient): + _session: httpx.AsyncClient + + def __init__(self, nonce: str = "", session: httpx.AsyncClient | None = None): + super().__init__(nonce, session or httpx.AsyncClient(http2=True)) + + async def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("POST", endpoint, json=json, data=data, **kwargs) + + async def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("PUT", endpoint, json=json, data=data, **kwargs) + + async def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, json=json, data=data, **kwargs) + + async def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: + request = self._prepare_request(method, endpoint, **kwargs) + response = await self._session.send(request, auth=self.prepare_authentication) + self._info_log_response(response) + + return response diff --git a/src/api_client/client.py b/src/api_client/client.py deleted file mode 100644 index ede9efc..0000000 --- a/src/api_client/client.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -from abc import ABC -from typing import Any - -import requests -from pydantic import HttpUrl - -from .utils import get_logger -from .utils.logs import get_nonce, shortify_log_value - - -class BaseAPIClient(ABC): - base_url: HttpUrl - _session: requests.Session - _nonce: str - _logger: logging.Logger - - def __init__(self, nonce: str = ""): - self._nonce = nonce or get_nonce() - self._session = requests.Session() - self._logger = get_logger("APIClient") - - def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("GET", endpoint, params=params, **kwargs) - - def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("POST", endpoint, json=json, data=data, **kwargs) - - def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("PUT", endpoint, json=json, data=data, **kwargs) - - def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("PATCH", endpoint, json=json, data=data, **kwargs) - - def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("DELETE", endpoint, params=params, **kwargs) - - def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("HEAD", endpoint, params=params, **kwargs) - - def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("OPTIONS", endpoint, params=params, **kwargs) - - def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("TRACE", endpoint, params=params, **kwargs) - - def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("CONNECT", endpoint, params=params, **kwargs) - - def _make_full_url(self, endpoint: str) -> str: - return f"{self.base_url}{endpoint}" - - def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response: - full_url = self._make_full_url(endpoint) - - # Create the Request. - req = requests.Request( - method=method.upper(), - url=full_url, - headers=kwargs.get("headers"), - files=kwargs.get("files"), - data=kwargs.get("data"), - json=kwargs.get("json"), - params=kwargs.get("params"), - auth=kwargs.get("auth"), - cookies=kwargs.get("cookies"), - hooks=kwargs.get("hooks"), - ) - self.prepare_authentication(req) - self._debug(f'Preparing {req.method} request to "{req.url}"', extra=req.__dict__) - prepared_request: requests.PreparedRequest = self._session.prepare_request(req) - - self._info( - f"Sending request with payload={prepared_request.body!r}", - extra={"payload": shortify_log_value(kwargs.get("json", kwargs.get("data", {})))}, - ) - response = self._session.send(prepared_request) - - str_repr_content = response.content.decode("utf8")[:500] - self._info(f"Response {response.status_code=} {str_repr_content=}", extra={"status_code": response.status_code, "content": str_repr_content}) - - response.raise_for_status() - return response - - def prepare_authentication(self, request: requests.Request) -> None: - """Do auth setup in-place""" - pass - - def _info(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("INFO", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("DEBUG", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("WARNING", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _error(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("ERROR", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _critical(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("CRITICAL", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _log(self, level: str, msg: object, *args: object, stacklevel: int = 3, extra: dict | None = None, **kwargs: Any) -> None: - extra = extra or {} - extra.update(nonce=self._nonce) - self._logger.log(logging.getLevelNamesMapping()[level], msg, *args, stacklevel=stacklevel, extra=extra, **kwargs) diff --git a/src/api_client/sync_client.py b/src/api_client/sync_client.py new file mode 100644 index 0000000..6c191e8 --- /dev/null +++ b/src/api_client/sync_client.py @@ -0,0 +1,47 @@ +from abc import ABC +from typing import Any + +import httpx + +from ._base import BaseClient + + +class BaseAPIClient(BaseClient, ABC): + _session: httpx.Client + + def __init__(self, nonce: str = "", session: httpx.Client | None = None): + super().__init__(nonce, session or httpx.Client(http2=True)) + + def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("GET", endpoint, params=params, **kwargs) + + def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("POST", endpoint, json=json, data=data, **kwargs) + + def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("PUT", endpoint, json=json, data=data, **kwargs) + + def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("PATCH", endpoint, json=json, data=data, **kwargs) + + def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("DELETE", endpoint, params=params, **kwargs) + + def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("HEAD", endpoint, params=params, **kwargs) + + def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("OPTIONS", endpoint, params=params, **kwargs) + + def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("TRACE", endpoint, params=params, **kwargs) + + def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("CONNECT", endpoint, params=params, **kwargs) + + def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: + request = self._prepare_request(method, endpoint, **kwargs) + response = self._session.send(request, auth=self.prepare_authentication) + self._info_log_response(response) + + return response