This commit is contained in:
2024-03-09 21:10:40 +02:00
parent b8f3ec0979
commit 5c4997e137
6 changed files with 34 additions and 149 deletions

View File

@ -75,3 +75,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

View File

@ -7,12 +7,14 @@ pydantic~=2.6.3
black~=24.2
flake8~=7.0
isort~=5.13
mypy~=1.9
# Version and package support
twine~=5.0
bump-my-version~=0.18
build~=1.1
pur~=7.3
setuptools~=69.1
# Testing and debugging
pytest~=8.0

View File

@ -1,5 +1,5 @@
import logging
from abc import ABC, abstractmethod
from abc import ABC
from typing import Any
import requests
@ -38,27 +38,28 @@ class BaseAPIClient(ABC):
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"),
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"),
)
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(
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", {})))},
)
response = self._session.send(request, auth=self.prepare_authentication)
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})
@ -66,9 +67,9 @@ class BaseAPIClient(ABC):
response.raise_for_status()
return response
@abstractmethod
def prepare_authentication(self, request: requests.Request) -> requests.Request:
raise NotImplementedError()
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, **kwargs)

View File

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

View File

@ -5,7 +5,7 @@ from dataclasses import asdict
from typing import Any
from uuid import uuid4
from api_client.utils.parse import ServiceJSONEncoder
from .parse import APIClientJSONEncoder
def get_logger(name: str = "service") -> logging.Logger:
@ -28,7 +28,7 @@ def shortify_log_value(dct: Any) -> str:
dct = asdict(dct)
if not isinstance(dct, dict):
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:

View File

@ -7,103 +7,11 @@ 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",
"APIClientJSONEncoder",
]
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):
class APIClientJSONEncoder(json.JSONEncoder):
def default(self, obj: Any) -> Any:
try:
if isinstance(obj, decimal.Decimal):