WIP
This commit is contained in:
@ -75,3 +75,15 @@ workers = 4
|
|||||||
profile = "black"
|
profile = "black"
|
||||||
line_length = 160
|
line_length = 160
|
||||||
skip = ["migrations", "env", "venv", ".venv", ".git", "media"]
|
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
|
||||||
|
@ -7,12 +7,14 @@ pydantic~=2.6.3
|
|||||||
black~=24.2
|
black~=24.2
|
||||||
flake8~=7.0
|
flake8~=7.0
|
||||||
isort~=5.13
|
isort~=5.13
|
||||||
|
mypy~=1.9
|
||||||
|
|
||||||
# Version and package support
|
# Version and package support
|
||||||
twine~=5.0
|
twine~=5.0
|
||||||
bump-my-version~=0.18
|
bump-my-version~=0.18
|
||||||
build~=1.1
|
build~=1.1
|
||||||
pur~=7.3
|
pur~=7.3
|
||||||
|
setuptools~=69.1
|
||||||
|
|
||||||
# Testing and debugging
|
# Testing and debugging
|
||||||
pytest~=8.0
|
pytest~=8.0
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -38,27 +38,28 @@ class BaseAPIClient(ABC):
|
|||||||
def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response:
|
def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response:
|
||||||
full_url = self._make_full_url(endpoint)
|
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.
|
# Create the Request.
|
||||||
req = requests.Request(
|
req = requests.Request(
|
||||||
method=method.upper(),
|
method=method.upper(),
|
||||||
url=full_url,
|
url=full_url,
|
||||||
headers=kwargs.get("headers"),
|
headers=kwargs.get("headers"),
|
||||||
files=kwargs.get("headers"),
|
files=kwargs.get("files"),
|
||||||
data=kwargs.get("headers") or {},
|
data=kwargs.get("data"),
|
||||||
json=kwargs.get("headers"),
|
json=kwargs.get("json"),
|
||||||
params=kwargs.get("headers") or {},
|
params=kwargs.get("params"),
|
||||||
auth=kwargs.get("headers"),
|
auth=kwargs.get("auth"),
|
||||||
cookies=kwargs.get("headers"),
|
cookies=kwargs.get("cookies"),
|
||||||
hooks=kwargs.get("headers"),
|
hooks=kwargs.get("hooks"),
|
||||||
)
|
)
|
||||||
request = self._session.prepare_request(method=method, url=full_url, **kwargs)
|
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(
|
self._info(
|
||||||
f"Sending request with payload={shortify_log_value(kwargs.get('json', kwargs.get('data', {})))}",
|
f"Sending request with payload={prepared_request.body}",
|
||||||
extra={"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)
|
response = self._session.send(prepared_request)
|
||||||
|
|
||||||
str_repr_content = response.content.decode("utf8")[:500]
|
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})
|
self._info(f"Response {response.status_code=} {str_repr_content=}", extra={"status_code": response.status_code, "content": str_repr_content})
|
||||||
@ -66,9 +67,9 @@ class BaseAPIClient(ABC):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@abstractmethod
|
def prepare_authentication(self, request: requests.Request) -> None:
|
||||||
def prepare_authentication(self, request: requests.Request) -> requests.Request:
|
""" Do auth setup in-place """
|
||||||
raise NotImplementedError()
|
pass
|
||||||
|
|
||||||
def _info(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
def _info(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||||
self._logger.info(f"[{self._nonce}] {msg}", *args, **kwargs)
|
self._logger.info(f"[{self._nonce}] {msg}", *args, **kwargs)
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
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
|
|
@ -5,7 +5,7 @@ from dataclasses import asdict
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from api_client.utils.parse import ServiceJSONEncoder
|
from .parse import APIClientJSONEncoder
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str = "service") -> logging.Logger:
|
def get_logger(name: str = "service") -> logging.Logger:
|
||||||
@ -28,7 +28,7 @@ def shortify_log_value(dct: Any) -> str:
|
|||||||
dct = asdict(dct)
|
dct = asdict(dct)
|
||||||
if not isinstance(dct, dict):
|
if not isinstance(dct, dict):
|
||||||
return str(dct)
|
return str(dct)
|
||||||
return json.dumps(_shorten_dict_keys(dct), cls=ServiceJSONEncoder)
|
return json.dumps(_shorten_dict_keys(dct), cls=APIClientJSONEncoder)
|
||||||
|
|
||||||
|
|
||||||
def get_nonce() -> str:
|
def get_nonce() -> str:
|
||||||
|
@ -7,103 +7,11 @@ from typing import Any, NoReturn
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"str_as_date",
|
"APIClientJSONEncoder",
|
||||||
"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:
|
class APIClientJSONEncoder(json.JSONEncoder):
|
||||||
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:
|
def default(self, obj: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
if isinstance(obj, decimal.Decimal):
|
if isinstance(obj, decimal.Decimal):
|
||||||
|
Reference in New Issue
Block a user