v1 (#1)
Reviewed-on: #1 Co-authored-by: Ēriks K <git@72.lv> Co-committed-by: Ēriks K <git@72.lv>
This commit is contained in:
4
.flake8
Normal file
4
.flake8
Normal file
@ -0,0 +1,4 @@
|
||||
[flake8]
|
||||
extend-ignore = E203, E704, W605, E701
|
||||
exclude = .git,__pycache__,venv,migrations
|
||||
max-line-length = 160
|
10
Makefile
10
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
|
||||
|
71
README.md
71
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}"}}
|
||||
```
|
||||
|
@ -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
|
||||
|
24
requirements.txt
Normal file
24
requirements.txt
Normal file
@ -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
|
@ -2,7 +2,10 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<content url="file://$MODULE_DIR$/src" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (sdlv_api_client)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
4
src/api_client/__init__.py
Normal file
4
src/api_client/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .client import BaseAPIClient
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__all__ = ["BaseAPIClient"]
|
103
src/api_client/client.py
Normal file
103
src/api_client/client.py
Normal file
@ -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)
|
4
src/api_client/utils/__init__.py
Normal file
4
src/api_client/utils/__init__.py
Normal file
@ -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"]
|
79
src/api_client/utils/logs.py
Normal file
79
src/api_client/utils/logs.py
Normal file
@ -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)
|
36
src/api_client/utils/parse.py
Normal file
36
src/api_client/utils/parse.py
Normal file
@ -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
|
@ -1 +0,0 @@
|
||||
__version__ = "0.1.1"
|
Reference in New Issue
Block a user