This commit is contained in:
Eriks Karls
2024-03-07 17:43:02 +02:00
parent 417c4c8bdc
commit b8f3ec0979
11 changed files with 323 additions and 20 deletions

4
.flake8 Normal file
View File

@ -0,0 +1,4 @@
[flake8]
extend-ignore = E203, E704, W605, E701
exclude = .git,__pycache__,venv,migrations
max-line-length = 160

View File

@ -51,11 +51,11 @@ 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: lint/isort lint/black lint/flake8 ## check style

View File

@ -28,6 +28,7 @@ classifiers = [
]
dependencies = [
"requests~=2.31",
"pydantic~=2.6",
]
@ -35,26 +36,11 @@ 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]

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
## 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
# Version and package support
twine~=5.0
bump-my-version~=0.18
build~=1.1
pur~=7.3
# Testing and debugging
pytest~=8.0
ipython~=8.22.0

View File

@ -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>

83
src/api_client/client.py Normal file
View File

@ -0,0 +1,83 @@
import logging
from abc import ABC, abstractmethod
from typing import Any
import requests
from pydantic import HttpUrl
from .utils import get_logger
from .utils.logging import get_nonce, shortify_log_value
class BaseAPIClient(ABC):
name: str
_base_url: HttpUrl
_caller: str
_session: requests.Session
_nonce: str
_logger: logging.Logger
def __init__(self, requests_client: requests.Session, caller: str = "default", nonce: str = ""):
self._nonce = nonce or get_nonce()
self._session = requests_client
self._caller = caller
self._logger = get_logger("APIClient")
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 get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response:
return self._request("GET", 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)
self._debug(f'Preparing request {method} "{full_url}"', extra={"method": method, "full_url": full_url, "kwargs": kwargs})
# Create the Request.
req = requests.Request(
method=method.upper(),
url=full_url,
headers=kwargs.get("headers"),
files=kwargs.get("headers"),
data=kwargs.get("headers") or {},
json=kwargs.get("headers"),
params=kwargs.get("headers") or {},
auth=kwargs.get("headers"),
cookies=kwargs.get("headers"),
hooks=kwargs.get("headers"),
)
request = self._session.prepare_request(method=method, url=full_url, **kwargs)
self._info(
f"Sending request with payload={shortify_log_value(kwargs.get('json', kwargs.get('data', {})))}",
extra={"payload": shortify_log_value(kwargs.get("json", kwargs.get("data", {})))},
)
response = self._session.send(request, auth=self.prepare_authentication)
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
@abstractmethod
def prepare_authentication(self, request: requests.Request) -> requests.Request:
raise NotImplementedError()
def _info(self, msg: str, *args: Any, **kwargs: Any) -> None:
self._logger.info(f"[{self._nonce}] {msg}", *args, **kwargs)
def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None:
self._logger.debug(f"[{self._nonce}] {msg}", *args, **kwargs)
def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None:
self._logger.warning(f"[{self._nonce}] {msg}", *args, **kwargs)
def _error(self, msg: str, *args: Any, **kwargs: Any) -> None:
self._logger.error(f"[{self._nonce}] {msg}", *args, **kwargs)

View File

@ -0,0 +1,6 @@
from .credentials import get_credential_token
from .fast_api import do_init
from .http_handle import HTTPXClientWrapper
from .logging import get_logger, setup_logging
__all__ = ["get_logger", "setup_logging", "HTTPXClientWrapper", "do_init", "get_credential_token"]

View File

@ -0,0 +1,38 @@
import httpx
from httpx import Timeout
from api_client.utils.logging import get_logger
LOGGER = get_logger("httpx")
class HTTPXClientWrapper:
"""httpx client wrapper - logic copied from https://stackoverflow.com/a/74397436"""
async_client: httpx.AsyncClient | None = None
def __init__(self, UA_string: str = httpx._client.USER_AGENT):
self.user_agent = UA_string
def start(self) -> None:
"""Instantiate the client. Call from the FastAPI startup hook."""
self.async_client = httpx.AsyncClient(headers={"user-agent": self.user_agent}, timeout=Timeout(120))
LOGGER.debug(f"httpx AsyncClient instantiated. Id {id(self.async_client)}")
async def stop(self) -> None:
"""Gracefully shutdown. Call from FastAPI shutdown hook."""
if self.async_client is None:
raise AssertionError("async_client is None!")
LOGGER.debug(f"httpx async_client.is_closed(): {self.async_client.is_closed} - Now close it. Id (will be unchanged): {id(self.async_client)}")
await self.async_client.aclose()
LOGGER.debug(f"httpx async_client.is_closed(): {self.async_client.is_closed}. Id (will be unchanged): {id(self.async_client)}")
self.async_client = None
LOGGER.debug("httpx AsyncClient closed")
def __call__(self) -> httpx.AsyncClient:
"""Calling the instantiated HTTPXClientWrapper returns the wrapped singleton."""
# Ensure we don't use it if not started / running
if self.async_client is None:
raise AssertionError("async_client is None!")
LOGGER.debug(f"httpx async_client.is_closed(): {self.async_client.is_closed}. Id (will be unchanged): {id(self.async_client)}")
return self.async_client

View File

@ -0,0 +1,35 @@
import dataclasses
import json
import logging
from dataclasses import asdict
from typing import Any
from uuid import uuid4
from api_client.utils.parse import ServiceJSONEncoder
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=ServiceJSONEncoder)
def get_nonce() -> str:
return uuid4().hex[:12]

View File

@ -0,0 +1,129 @@
import datetime
import decimal
import json
import re
from typing import Any, NoReturn
from pydantic import BaseModel
__all__ = [
"str_as_date",
"str_as_date_or_none",
"as_decimal",
"as_decimal_or_none",
"extract_dates_from_str",
"extract_date_from_str",
"ServiceJSONEncoder",
]
def str_as_date(date_str: str, date_format: str = "%Y-%m-%d") -> datetime.date | NoReturn:
try:
datetime_object = datetime.datetime.strptime(date_str, date_format)
return datetime_object.date()
except (ValueError, TypeError):
raise TypeError(f"Unable to cast {date_str=} to datetime.date!")
def str_as_date_or_none(date_str: str | None, date_format: str = "%Y-%m-%d") -> datetime.date | None:
if not isinstance(date_str, str):
return None
try:
return str_as_date(date_str, date_format)
except TypeError:
return None
def as_decimal(decimal_str: str | int | float) -> decimal.Decimal | NoReturn:
try:
return decimal.Decimal(str(decimal_str))
except (decimal.DecimalException, TypeError):
raise TypeError(f"Unable to cast {decimal_str=} to Decimal value!")
def as_decimal_or_none(decimal_str: str | int | float | None) -> decimal.Decimal | None:
if not isinstance(decimal_str, (str | int | float)):
return None
try:
return as_decimal(decimal_str)
except TypeError:
return None
def _date_from_re_search(pattern: re.Pattern, test_str: str) -> datetime.date | tuple[datetime.date, datetime.date] | None:
if match := pattern.search(test_str):
if "year" in match.re.groupindex:
try:
return datetime.date(int(match.group("year")), int(match.group("month")), int(match.group("day")))
except (TypeError, ValueError):
pass
elif "year1" in match.re.groupindex:
try:
return (
datetime.date(int(match.group("year1")), int(match.group("month1")), int(match.group("day1"))),
datetime.date(int(match.group("year2")), int(match.group("month2")), int(match.group("day2"))),
)
except (TypeError, ValueError):
pass
return None
def extract_date_from_str(input_str: str) -> datetime.date | None:
# date_time_re = r"(\d{0,4})(\-)(\d{0,2})(\-)(\d{0,2}) (\d{0,2})(:)(\d{0,2})(:)(\d{0,2})"
# date_time_re = r"(\d{0,2})(\.)(\d{0,2})(\.)(\d{0,4}) (\d{0,2})(:)(\d{0,2})(:)(\d{0,2})"
# date_time_re = r"(\d{0,2})(\.)(\d{0,2})(\.)(\d{0,4}) / (\d{0,2})(:)(\d{0,2})"
# date_time_re = r"(\d{0,2})(\.)(\d{0,2})(\.)(\d{0,4})"
for pattern in (
re.compile(r"(?P<day>\d{2})\.(?P<month>\d{2})\.(?P<year>\d{4}) / (?P<time>\d{2}:\d{2}:\d{2})"),
re.compile(r"(?P<day>\d{2})\.(?P<month>\d{2})\.(?P<year>\d{4}) (?P<time>\d{2}:\d{2}:\d{2})"),
re.compile(r"(?P<day>\d{2})\.(?P<month>\d{2})\.(?P<year>\d{4})"),
re.compile(r"(?P<year>\d{4})(.|-)(?P<month>\d{1,2})(.|-)(?P<day>\d{1,2})"),
):
if match := _date_from_re_search(pattern, input_str):
assert isinstance(match, datetime.date) # sanity check
return match
return None
def extract_dates_from_str(input_str: str) -> tuple[datetime.date, datetime.date] | None:
# period_re = re.search(r" (\d{0,2}\.\d{0,2}\.\d{0,4}) - (\d{0,2})\.(\d{0,2})\.(\d{0,4})")
# period_re = re.search(r".* (\d{0,2}\.\d{0,2}\.\d{0,4}) \W* (\d{0,2}\.\d{0,2}\.\d{0,4})", text)
# period_re = re.search(r".* (\d{0,2}\.\d{0,2}\.\d{0,4}) .* (\d{0,2}\.\d{0,2}\.\d{0,4})", line)
# r".* (\d{0,2}\.\d{0,2}\.\d{0,4}) .* (\d{0,2}\.\d{0,2}\.\d{0,4})"
# period_re = re.search(r"(\d{4}-\d{2}-\d{2}) - (\d{4}-\d{2}-\d{2})", line)
for pattern in (
re.compile(r"(?P<day1>\d{1,2})\.(?P<month1>\d{1,2})\.(?P<year1>\d{4}) - (?P<day2>\d{1,2})\.(?P<month2>\d{1,2})\.(?P<year2>\d{4})"),
re.compile(r"(?P<day1>\d{1,2})\.(?P<month1>\d{1,2})\.(?P<year1>\d{4})[\s\w\W]+(?P<day2>\d{1,2})\.(?P<month2>\d{1,2})\.(?P<year2>\d{4})"),
re.compile(r"(?P<year1>\d{4})(.|-)(?P<month1>\d{1,2})(.|-)(?P<day1>\d{1,2}) - (?P<year2>\d{4})(.|-)(?P<month2>\d{1,2})(.|-)(?P<day2>\d{1,2})"),
):
if match := _date_from_re_search(pattern, input_str):
assert not isinstance(match, datetime.date) # sanity check
return match
return None
class ServiceJSONEncoder(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