From cd489818cec384af751b872e2de4c1bc6331369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?E=CC=84riks=20K?= Date: Sat, 9 Mar 2024 23:03:43 +0200 Subject: [PATCH] v1 (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.72.lv/eriks/api_client/pulls/1 Co-authored-by: Ēriks K Co-committed-by: Ēriks K --- .flake8 | 4 ++ Makefile | 10 +-- README.md | 71 +++++++++++++++++++++ pyproject.toml | 36 +++++------ requirements.txt | 24 +++++++ sdlv_api_client.iml | 5 +- src/api_client/__init__.py | 4 ++ src/api_client/client.py | 103 +++++++++++++++++++++++++++++++ src/api_client/utils/__init__.py | 4 ++ src/api_client/utils/logs.py | 79 ++++++++++++++++++++++++ src/api_client/utils/parse.py | 36 +++++++++++ src/sdlv_api_client/__init__.py | 1 - 12 files changed, 351 insertions(+), 26 deletions(-) create mode 100644 .flake8 create mode 100644 requirements.txt create mode 100644 src/api_client/__init__.py create mode 100644 src/api_client/client.py create mode 100644 src/api_client/utils/__init__.py create mode 100644 src/api_client/utils/logs.py create mode 100644 src/api_client/utils/parse.py delete mode 100644 src/sdlv_api_client/__init__.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2643256 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +extend-ignore = E203, E704, W605, E701 +exclude = .git,__pycache__,venv,migrations +max-line-length = 160 diff --git a/Makefile b/Makefile index 1ffe972..b6c19c7 100644 --- a/Makefile +++ b/Makefile @@ -51,13 +51,15 @@ release: clean build/source build/wheel twine upload dist/* lint/flake8: ## check style with flake8 - flake8 src/sdlv_api_client + flake8 src/api_client lint/black: ## check style with black - black src/sdlv_api_client + black src/api_client lint/isort: ## check imports with isort - isort src/sdlv_api_client + isort src/api_client +lint/type: ## check imports with isort + mypy src/api_client -lint: lint/isort lint/black lint/flake8 ## check style +lint: lint/isort lint/black lint/flake8 lint/type ## check style test: ## run tests quickly with the default Python pytest -v diff --git a/README.md b/README.md index e69de29..ad295ea 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,71 @@ +# Example code +```python +# client.py +from api_client import BaseAPIClient + + +class LocalClient(BaseAPIClient): + name = "local" + _base_url = "https://obligari.serveo.net/ping/local" + + def __init__(self, nonce=None): + super().__init__(nonce) + self._session.headers.update( + {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:123.0) Gecko/20100101 Firefox/123.0"} + ) + + def send_post_ping(self, var1: str, var2: int) -> bool: + res = self.post("/some-post", json={"variable_one": var1, "second_variable": var2}) + return res.json().get("status") + + def send_put_ping(self, var1: str, var2: int) -> bool: + res = self.put("/some-put", data={"variable_one": var1, "second_variable": var2}) + return res.json().get("status") + + def send_get_ping(self, var1: str, var2: int) -> bool: + res = self.get("/some-get", params={"variable_one": var1, "second_variable": var2}) + return res.json().get("status") + + def send_patch_ping(self, var1: str, var2: int) -> bool: + res = self.put("/some-patch", data=(("variable_one", var1), ("variable_one", var2))) + return res.json().get("status") + + def send_trace_ping(self, var1: str, var2: int) -> bool: + res = self.trace("/some-trace", params=(("variable_one", var1), ("variable_one", var2))) + return res.json().get("status") + + +``` + +```python +# main.py +from .client import LocalClient + + +client = LocalClient() +client.send_post_ping("asd", 123) +client.send_put_ping("asd", 123) +client.send_get_ping("asd", 123) +client.send_patch_ping("asd", 123) +client.send_trace_ping("asd", 123) +``` + +## Log output +### Simple +```text +[14d709e02c0c] Preparing POST request to "https://obligari.serveo.net/ping/local/some-post" +[14d709e02c0c] Sending request with payload=b'{"variable_one": "asd", "second_variable": 123}' +[14d709e02c0c] Response response.status_code=200 str_repr_content='{"status":true,"request_id":62}' +[14d709e02c0c] Preparing GET request to "https://obligari.serveo.net/ping/local/some-get" +[14d709e02c0c] Sending request with payload=None +[14d709e02c0c] Response response.status_code=200 str_repr_content='{"status":true,"request_id":63}' +``` +### Structured +```json +{"app": "dev", "level": "DEBUG", "name": "APIClient", "date_time": "2024-03-09 22:59:24", "location": "api_client/client.py:_request:71", "message": "[cfbdadc56f53] Preparing POST request to \"https://obligari.serveo.net/ping/local/some-post\"", "extra_data": {"hooks": {"response": []}, "method": "POST", "url": "https://obligari.serveo.net/ping/local/some-post", "headers": {}, "files": [], "data": [], "json": {"variable_one": "asd", "second_variable": 123}, "params": {}, "auth": null, "cookies": null}} +{"app": "dev", "level": "INFO", "name": "APIClient", "date_time": "2024-03-09 22:59:24", "location": "api_client/client.py:_request:74", "message": "[cfbdadc56f53] Sending request with payload=b'{\"variable_one\": \"asd\", \"second_variable\": 123}'", "extra_data": {"payload": "{\"variable_one\": \"asd\", \"second_variable\": 123}"}} +{"app": "dev", "level": "INFO", "name": "APIClient", "date_time": "2024-03-09 22:59:25", "location": "api_client/client.py:_request:81", "message": "[cfbdadc56f53] Response response.status_code=200 str_repr_content='{\"status\":true,\"request_id\":72}'", "extra_data": {"status_code": 200, "content": "{\"status\":true,\"request_id\":72}"}} +{"app": "dev", "level": "DEBUG", "name": "APIClient", "date_time": "2024-03-09 22:59:25", "location": "api_client/client.py:_request:71", "message": "[cfbdadc56f53] Preparing GET request to \"https://obligari.serveo.net/ping/local/some-get\"", "extra_data": {"hooks": {"response": []}, "method": "GET", "url": "https://obligari.serveo.net/ping/local/some-get", "headers": {}, "files": [], "data": [], "json": null, "params": {"variable_one": "asd", "second_variable": 123}, "auth": null, "cookies": null}} +{"app": "dev", "level": "INFO", "name": "APIClient", "date_time": "2024-03-09 22:59:25", "location": "api_client/client.py:_request:74", "message": "[cfbdadc56f53] Sending request with payload=None", "extra_data": {"payload": "{}"}} +{"app": "dev", "level": "INFO", "name": "APIClient", "date_time": "2024-03-09 22:59:25", "location": "api_client/client.py:_request:81", "message": "[cfbdadc56f53] Response response.status_code=200 str_repr_content='{\"status\":true,\"request_id\":74}'", "extra_data": {"status_code": 200, "content": "{\"status\":true,\"request_id\":73}"}} +``` diff --git a/pyproject.toml b/pyproject.toml index 9bf09d0..d3880c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ ] dependencies = [ "requests~=2.31", + "pydantic~=2.6", ] @@ -35,30 +36,15 @@ dependencies = [ "Homepage" = "https://pypi.org/project/sdlv-api-client/" "Bug Reports" = "https://git.72.lv/eriks/api_client/issues" "Funding" = "https://donate.pypi.org" -"Say Thanks!" = "http://saythanks.io/to/example" "Source" = "https://git.72.lv/eriks/api_client" -[project.optional-dependencies] -dev = [ - "black ~= 24.2.0", - "flake8 ~= 7.0.0", - "isort ~= 5.13.0", - "twine ~= 5.0.0", - "bump-my-version ~= 0.18.3", - "build ~= 1.1.1" -] -test = [ - "pytest ~= 8.0", -] - - [tool.setuptools.dynamic] -version = { attr = "sdlv_api_client.__version__" } +version = { attr = "api_client.__version__" } [tool.bumpversion] -current_version = "0.1.1" +current_version = "0.1.8" commit = true tag = true tag_name = "v{new_version}" @@ -70,7 +56,7 @@ message = "Bump version: {current_version} → {new_version}" [[tool.bumpversion.files]] -filename = "src/sdlv_api_client/__init__.py" +filename = "src/api_client/__init__.py" [tool.black] @@ -78,9 +64,7 @@ line-length = 160 target-version = ['py311'] include = '\.pyi?$' extend-exclude = '''( - migrations/* | .git/* - | media/* )''' workers = 4 @@ -89,3 +73,15 @@ workers = 4 profile = "black" line_length = 160 skip = ["migrations", "env", "venv", ".venv", ".git", "media"] + + +[tool.mypy] +python_version = "3.11" +exclude = ['^\.?venv/',] +plugins = ["pydantic.mypy"] +warn_unused_configs = true +disallow_untyped_defs = true +implicit_optional = true +warn_redundant_casts = true +warn_no_return = false +ignore_missing_imports = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d2b8bf3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +## Requirements for development of library +# Base requirements +requests==2.31.0 +pydantic==2.6.3 + +# Code style +black==24.2 +flake8==7.0 +isort==5.13.2 + +# Type check +mypy==1.9 +types-requests==2.31.0.20240218 + +# Version and package support +twine==5.0 +bump-my-version==0.18.3 +build==1.1.1 +pur==7.3.1 +setuptools==69.1.1 + +# Testing and debugging +pytest==8.1.1 +ipython==8.22.2 diff --git a/sdlv_api_client.iml b/sdlv_api_client.iml index 0d7b94d..bf01f66 100644 --- a/sdlv_api_client.iml +++ b/sdlv_api_client.iml @@ -2,7 +2,10 @@ - + + + + diff --git a/src/api_client/__init__.py b/src/api_client/__init__.py new file mode 100644 index 0000000..979c104 --- /dev/null +++ b/src/api_client/__init__.py @@ -0,0 +1,4 @@ +from .client import BaseAPIClient + +__version__ = "0.1.8" +__all__ = ["BaseAPIClient"] diff --git a/src/api_client/client.py b/src/api_client/client.py new file mode 100644 index 0000000..b7a1659 --- /dev/null +++ b/src/api_client/client.py @@ -0,0 +1,103 @@ +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): + name: str + _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._logger.info(f"[{self._nonce}] {msg}", *args, stacklevel=2, **kwargs) + + def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._logger.debug(f"[{self._nonce}] {msg}", *args, stacklevel=2, **kwargs) + + def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._logger.warning(f"[{self._nonce}] {msg}", *args, stacklevel=2, **kwargs) + + def _error(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._logger.error(f"[{self._nonce}] {msg}", *args, stacklevel=2, **kwargs) + + def _critical(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._logger.critical(f"[{self._nonce}] {msg}", *args, stacklevel=2, **kwargs) diff --git a/src/api_client/utils/__init__.py b/src/api_client/utils/__init__.py new file mode 100644 index 0000000..5b0c658 --- /dev/null +++ b/src/api_client/utils/__init__.py @@ -0,0 +1,4 @@ +from .logs import APIClientLogJSONFormatter, get_logger, shortify_log_value +from .parse import APIClientJSONEncoder + +__all__ = ["get_logger", "APIClientLogJSONFormatter", "shortify_log_value", "APIClientJSONEncoder"] diff --git a/src/api_client/utils/logs.py b/src/api_client/utils/logs.py new file mode 100644 index 0000000..c4c58a1 --- /dev/null +++ b/src/api_client/utils/logs.py @@ -0,0 +1,79 @@ +import dataclasses +import datetime +import json +import logging +import os +from dataclasses import asdict +from logging import LogRecord +from typing import Any +from uuid import uuid4 + +from .parse import APIClientJSONEncoder + + +def get_logger(name: str = "service") -> logging.Logger: + return logging.getLogger(name) + + +def _shorten_dict_keys(dct: dict) -> dict: + res = {} + for k, v in dct.items(): + if isinstance(v, str) and len(v) > 64: + v = f"{v[:30]}...{v[-30:]}" + elif isinstance(v, dict): + v = _shorten_dict_keys(v) + res[k] = v + return res + + +def shortify_log_value(dct: Any) -> str: + if dataclasses.is_dataclass(dct): + dct = asdict(dct) + if not isinstance(dct, dict): + return str(dct) + return json.dumps(_shorten_dict_keys(dct), cls=APIClientJSONEncoder) + + +def get_nonce() -> str: + return uuid4().hex[:12] + + +class APIClientLogJSONFormatter(logging.Formatter): + default_time_format = "%Y-%m-%d %H:%M:%S" + + def format(self, record: LogRecord) -> str: + record_default_keys = [ + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "exc_info", + "filename", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "message", + "asctime", + "module", + "exc_text", + "stack_info", + ] + structured_data = dict( + app=os.environ.get("APP_NAME", "dev"), + level=record.levelname, + name=record.name, + date_time=datetime.datetime.fromtimestamp(record.created).strftime(self.default_time_format), + location=f"{record.pathname or record.filename}:{record.funcName}:{record.lineno}", + message=record.getMessage(), + extra_data={k: record.__dict__[k] for k in record.__dict__.keys() if k not in record_default_keys}, + ) + + return json.dumps(structured_data, cls=APIClientJSONEncoder) diff --git a/src/api_client/utils/parse.py b/src/api_client/utils/parse.py new file mode 100644 index 0000000..1ebc40f --- /dev/null +++ b/src/api_client/utils/parse.py @@ -0,0 +1,36 @@ +import datetime +import decimal +import json +from typing import Any + +from pydantic import BaseModel + +__all__ = [ + "APIClientJSONEncoder", +] + + +class APIClientJSONEncoder(json.JSONEncoder): + def default(self, obj: Any) -> Any: + try: + if isinstance(obj, decimal.Decimal): + return str(obj) + if isinstance(obj, (datetime.datetime, datetime.date)): + return obj.isoformat() + if isinstance(obj, BaseModel): + return obj.model_dump_json() + if isinstance(obj, datetime.timedelta): + return dict(__type__="timedelta", total_seconds=obj.total_seconds()) + if hasattr(obj, "as_dict"): + if callable(getattr(obj, "as_dict")): + return super().default(obj.as_dict()) + return super().default(obj.as_dict) + if isinstance(obj, set): + return list(obj) + if hasattr(obj, "__dict__"): + return obj.__dict__ + return super().default(obj) + except TypeError as exc: + if "not JSON serializable" in str(exc): + return str(obj) + raise exc diff --git a/src/sdlv_api_client/__init__.py b/src/sdlv_api_client/__init__.py deleted file mode 100644 index 485f44a..0000000 --- a/src/sdlv_api_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.1"