diff --git a/src/tests/test_eventkit.py b/src/tests/test_eventkit.py index e0a4ab262..e2f182ad8 100644 --- a/src/tests/test_eventkit.py +++ b/src/tests/test_eventkit.py @@ -1,30 +1,58 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar +import sys # do use `from sys import ...` +from re import search +from typing import TYPE_CHECKING, Any, ClassVar, cast from eventkit import Event from hypothesis import HealthCheck, given, settings from hypothesis.strategies import integers +from loguru import logger +from pytest import CaptureFixture +from tests.test_loguru_functions import func_test_eventkit from utilities.eventkit import add_listener -from utilities.functions import identity +from utilities.loguru import HandlerConfiguration, LogLevel if TYPE_CHECKING: from pytest import CaptureFixture -_T = TypeVar("_T") - class TestAddListener: + datetime: ClassVar[str] = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \| " + @given(n=integers()) @settings(suppress_health_check={HealthCheck.function_scoped_fixture}) async def test_main(self, *, capsys: CaptureFixture, n: int) -> None: - def func(obj: _T, /) -> _T: - print(obj) # noqa: T201 - return identity(obj) + handler: HandlerConfiguration = {"sink": sys.stdout, "level": LogLevel.TRACE} + _ = logger.configure(handlers=[cast(dict[str, Any], handler)]) event = Event() - _ = add_listener(event, func) + _ = add_listener(event, func_test_eventkit) event.emit(n) out = capsys.readouterr().out - assert out == f"{n}\n" + (line,) = out.splitlines() + expected = ( + self.datetime + + r"TRACE \| tests\.test_loguru_functions:func_test_eventkit:\d+ - n=-?\d+$" + ) + assert search(expected, line), line + + @given(n=integers()) + @settings(suppress_health_check={HealthCheck.function_scoped_fixture}) + async def test_error(self, *, capsys: CaptureFixture, n: int) -> None: + handler: HandlerConfiguration = {"sink": sys.stdout, "level": LogLevel.TRACE} + _ = logger.configure(handlers=[cast(dict[str, Any], handler)]) + + event = Event() + _ = add_listener(event, func_test_eventkit) + event.emit(n, n) + out = capsys.readouterr().out + (line1, line2, *_, last) = out.splitlines() + expected1 = r"ERROR \| utilities\.eventkit:_add_listener_error:\d+ - Error running Event<.*>$" + assert search(expected1, line1), line1 + assert line2 == "Traceback (most recent call last):" + assert ( + last + == "TypeError: func_test_eventkit() takes 1 positional argument but 2 were given" + ) diff --git a/src/tests/test_loguru.py b/src/tests/test_loguru.py index 7126d5a42..95b48c966 100644 --- a/src/tests/test_loguru.py +++ b/src/tests/test_loguru.py @@ -135,7 +135,7 @@ def test_main(self, *, capsys: CaptureFixture) -> None: logger.trace("message 2") out2 = capsys.readouterr().out - expected = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \| TRACE \| tests\.test_loguru:test_main:\d+ - message 2" + expected = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \| TRACE \| tests\.test_loguru:test_main:\d+ - message 2$" assert search(expected, out2), out2 @@ -167,7 +167,7 @@ def test_main(self, *, capsys: CaptureFixture) -> None: class TestLog: - datetime: ClassVar[str] = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \| " + datetime: ClassVar[str] = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \| " loguru: ClassVar[str] = r"tests\.test_loguru_functions:" trace: ClassVar[str] = datetime + r"TRACE \| " + loguru debug: ClassVar[str] = datetime + r"DEBUG \| " + loguru diff --git a/src/tests/test_loguru_functions.py b/src/tests/test_loguru_functions.py index b8f35ced7..380670c2f 100644 --- a/src/tests/test_loguru_functions.py +++ b/src/tests/test_loguru_functions.py @@ -2,11 +2,21 @@ from time import sleep +from loguru import logger from tenacity import retry, wait_fixed from utilities.loguru import LogLevel, log from utilities.tenacity import before_sleep_log +# eventkit + + +def func_test_eventkit(n: int, /) -> None: + logger.trace("n={n}", n=n) + + +# loguru + def func_test_log_entry_inc_and_dec(x: int, /) -> tuple[int, int]: with log(): @@ -67,6 +77,9 @@ def func_test_log_contextualize(x: int, /) -> int: return x + 1 +# tenacity + + _counter = 0 diff --git a/src/tests/test_math.py b/src/tests/test_math.py index a2fb10e1e..4c8e90b57 100644 --- a/src/tests/test_math.py +++ b/src/tests/test_math.py @@ -28,6 +28,7 @@ MIN_UINT64, CheckIntegerError, NumberOfDecimalsError, + SafeRoundError, _EWMParameters, _EWMParametersAlphaError, _EWMParametersArgumentsError, @@ -82,6 +83,7 @@ is_zero_or_non_micro_or_nan, number_of_decimals, order_of_magnitude, + safe_round, ) @@ -93,7 +95,7 @@ def test_equal_fail(self) -> None: with raises(CheckIntegerError, match="Integer must be equal to .*; got .*"): check_integer(0, equal=1) - @mark.parametrize("equal_or_approx", [param(10), param((11, 0.1))]) + @mark.parametrize("equal_or_approx", [param(10), param((11, 0.1))], ids=str) def test_equal_or_approx_pass( self, *, equal_or_approx: int | tuple[int, float] ) -> None: @@ -108,6 +110,7 @@ def test_equal_or_approx_pass( r"Integer must be approximately equal to .* \(error .*\); got .*", ), ], + ids=str, ) def test_equal_or_approx_fail( self, *, equal_or_approx: int | tuple[int, float], match: str @@ -207,12 +210,15 @@ class TestIsAtLeast: param(0.0, inf, False), param(0.0, nan, False), ], + ids=str, ) def test_main(self, *, x: float, y: float, expected: bool) -> None: assert is_at_least(x, y, abs_tol=1e-8) is expected @mark.parametrize( - "y", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)] + "y", + [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) def test_nan(self, *, y: float) -> None: assert is_at_least_or_nan(nan, y) @@ -235,12 +241,15 @@ class TestIsAtMost: param(0.0, inf, True), param(0.0, nan, False), ], + ids=str, ) def test_main(self, *, x: float, y: float, expected: bool) -> None: assert is_at_most(x, y, abs_tol=1e-8) is expected @mark.parametrize( - "y", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)] + "y", + [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) def test_nan(self, *, y: float) -> None: assert is_at_most_or_nan(nan, y) @@ -261,6 +270,7 @@ class TestIsBetween: param(0.0, 1.0, 1.0, False), param(nan, -1.0, 1.0, False), ], + ids=str, ) def test_main(self, *, x: float, low: float, high: float, expected: bool) -> None: assert is_between(x, low, high, abs_tol=1e-8) is expected @@ -268,10 +278,12 @@ def test_main(self, *, x: float, low: float, high: float, expected: bool) -> Non @mark.parametrize( "low", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) @mark.parametrize( "high", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) def test_nan(self, *, low: float, high: float) -> None: assert is_between_or_nan(nan, low, high) @@ -294,6 +306,7 @@ class TestIsEqual: param(0.0, inf, False), param(0.0, nan, False), ], + ids=str, ) def test_main(self, *, x: float, y: float, expected: bool) -> None: assert is_equal(x, y) is expected @@ -318,6 +331,7 @@ class TestIsEqualOrApprox: param((10, 0.1), (11, 0.1), True), param((10, 0.1), (12, 0.1), False), ], + ids=str, ) def test_main( self, *, x: int | tuple[int, float], y: int | tuple[int, float], expected: bool @@ -337,6 +351,7 @@ class TestIsFinite: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite(x) is expected @@ -366,6 +381,7 @@ class TestIsFiniteAndIntegral: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_integral(x, abs_tol=1e-8) is expected @@ -389,6 +405,7 @@ class TestIsFiniteAndNegative: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_negative(x, abs_tol=1e-8) is expected @@ -412,6 +429,7 @@ class TestIsFiniteAndNonNegative: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_non_negative(x, abs_tol=1e-8) is expected @@ -435,6 +453,7 @@ class TestIsFiniteAndNonPositive: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_non_positive(x, abs_tol=1e-8) is expected @@ -458,6 +477,7 @@ class TestIsFiniteAndNonZero: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_non_zero(x, abs_tol=1e-8) is expected @@ -481,6 +501,7 @@ class TestIsFiniteAndPositive: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_finite_and_positive(x, abs_tol=1e-8) is expected @@ -504,12 +525,15 @@ class TestIsGreaterThan: param(0.0, inf, False), param(0.0, nan, False), ], + ids=str, ) def test_main(self, *, x: float, y: float, expected: bool) -> None: assert is_greater_than(x, y, abs_tol=1e-8) is expected @mark.parametrize( - "y", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)] + "y", + [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) def test_nan(self, *, y: float) -> None: assert is_greater_than_or_nan(nan, y) @@ -538,6 +562,7 @@ class TestIsIntegral: param(inf, True, True), param(nan, False, True), ], + ids=str, ) def test_is_integral(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_integral(x, abs_tol=1e-8) is expected @@ -561,12 +586,15 @@ class TestIsLessThan: param(0.0, inf, True), param(0.0, nan, False), ], + ids=str, ) def test_main(self, *, x: float, y: float, expected: bool) -> None: assert is_less_than(x, y, abs_tol=1e-8) is expected @mark.parametrize( - "y", [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)] + "y", + [param(-inf), param(-1.0), param(0.0), param(1.0), param(inf), param(nan)], + ids=str, ) def test_nan(self, *, y: float) -> None: assert is_less_than_or_nan(nan, y) @@ -589,6 +617,7 @@ class TestIsNegative: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_negative(x, abs_tol=1e-8) is expected @@ -612,6 +641,7 @@ class TestIsNonNegative: param(inf, True, True), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_non_negative(x, abs_tol=1e-8) is expected @@ -635,6 +665,7 @@ class TestIsNonPositive: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_non_positive(x, abs_tol=1e-8) is expected @@ -658,6 +689,7 @@ class TestIsNonZero: param(inf, True), param(nan, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool) -> None: assert is_non_zero(x, abs_tol=1e-8) is expected @@ -681,6 +713,7 @@ class TestIsPositive: param(inf, True, True), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_positive(x, abs_tol=1e-8) is expected @@ -704,6 +737,7 @@ class TestIsZero: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_zero(x, abs_tol=1e-8) is expected @@ -727,6 +761,7 @@ class TestIsZeroOrFiniteAndNonMicro: param(inf, False, False), param(nan, False, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool, expected_nan: bool) -> None: assert is_zero_or_finite_and_non_micro(x, abs_tol=1e-8) is expected @@ -750,6 +785,7 @@ class TestIsZeroOrNonMicro: param(inf, True), param(nan, True), ], + ids=str, ) def test_main(self, *, x: float, expected: bool) -> None: assert is_zero_or_non_micro(x, abs_tol=1e-8) is expected @@ -769,6 +805,7 @@ class TestMaxLongAndDouble: param(MIN_UINT32, MAX_UINT32, uint32), param(MIN_UINT64, MAX_UINT64, uint64), ], + ids=str, ) def test_main(self, *, min_value: int, max_value: int, dtype: Any) -> None: info = iinfo(dtype) @@ -794,6 +831,7 @@ class TestNumberOfDecimals: param(0.12345678, 8), param(0.123456789, 9), ], + ids=str, ) def test_main(self, *, integer: int, frac: float, expected: int) -> None: x = integer + frac @@ -824,8 +862,9 @@ class TestOrderOfMagnitude: param(50.0, 1.69897, 2), param(100.0, 2.0, 2), ], + ids=str, ) - @mark.parametrize("sign", [param(1.0), param(-1.0)]) + @mark.parametrize("sign", [param(1.0), param(-1.0)], ids=str) def test_main( self, *, sign: float, x: float, exp_float: float, exp_int: int ) -> None: @@ -834,3 +873,35 @@ def test_main( assert res_float == approx(exp_float) res_int = order_of_magnitude(x_use, round_=True) assert res_int == exp_int + + +class TestSafeRound: + @mark.parametrize( + ("x", "expected"), + [param(-2.0, -2), param(-1.0, -1), param(0.0, 0), param(1.0, 1), param(2.0, 2)], + ids=str, + ) + def test_main(self, *, x: float, expected: int) -> None: + result = safe_round(x) + assert isinstance(result, int) + assert result == expected + + @mark.parametrize( + "x", + [ + param(-inf), + param(-1.5), + param(-0.5), + param(0.5), + param(1.5), + param(inf), + param(nan), + ], + ids=str, + ) + def test_error(self, *, x: float) -> None: + with raises( + SafeRoundError, + match=r"Unable to safely round .* \(rel_tol=.*, abs_tol=.*\)", + ): + _ = safe_round(x) diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py index 4e3b9f6de..64774df2d 100644 --- a/src/utilities/__init__.py +++ b/src/utilities/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.57.8" +__version__ = "0.57.9" diff --git a/src/utilities/eventkit.py b/src/utilities/eventkit.py index 63bebce21..5c9a58f53 100644 --- a/src/utilities/eventkit.py +++ b/src/utilities/eventkit.py @@ -1,5 +1,6 @@ from __future__ import annotations +from sys import stderr from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -13,12 +14,23 @@ def add_listener( listener: Callable[..., Any], /, *, - error: Callable[..., Any] | None = None, done: Callable[..., Any] | None = None, keep_ref: bool = False, ) -> Event: """Connect a listener to an event.""" - return event.connect(listener, error=error, done=done, keep_ref=keep_ref) + return event.connect( + listener, error=_add_listener_error, done=done, keep_ref=keep_ref + ) + + +def _add_listener_error(event: Event, exception: Exception, /) -> None: + """Run callback in the case of an error.""" + try: + from loguru import logger + except Exception as error: # noqa: BLE001 # pragma: no cover + _ = stderr.write(f"Error running {event}; got {error}") + else: + logger.opt(exception=exception).error("Error running {event}", event=event) __all__ = ["add_listener"] diff --git a/src/utilities/math.py b/src/utilities/math.py index 658d98701..614a11aa5 100644 --- a/src/utilities/math.py +++ b/src/utilities/math.py @@ -515,6 +515,26 @@ def order_of_magnitude(x: float, /, *, round_: bool = False) -> float: return round(result) if round_ else result +def safe_round( + x: float, /, *, rel_tol: float | None = None, abs_tol: float | None = None +) -> int: + """Safely round a float.""" + if is_finite_and_integral(x, rel_tol=rel_tol, abs_tol=abs_tol): + return round(x) + raise SafeRoundError(x=x, rel_tol=rel_tol, abs_tol=abs_tol) + + +@dataclass(kw_only=True) +class SafeRoundError(Exception): + x: float + rel_tol: float | None = None + abs_tol: float | None = None + + @override + def __str__(self) -> str: + return f"Unable to safely round {self.x} (rel_tol={self.rel_tol}, abs_tol={self.abs_tol})" + + # checks @@ -603,6 +623,7 @@ def __str__(self) -> str: "MIN_UINT64", "CheckIntegerError", "EWMParametersError", + "SafeRoundError", "check_integer", "ewm_parameters", "is_at_least", @@ -649,4 +670,5 @@ def __str__(self) -> str: "is_zero_or_non_micro_or_nan", "number_of_decimals", "order_of_magnitude", + "safe_round", ] diff --git a/src/utilities/slack_sdk.py b/src/utilities/slack_sdk.py index 2d1c253b0..ee483ebd8 100644 --- a/src/utilities/slack_sdk.py +++ b/src/utilities/slack_sdk.py @@ -2,17 +2,23 @@ from dataclasses import dataclass from http import HTTPStatus +from typing import TYPE_CHECKING from slack_sdk.webhook import WebhookClient, WebhookResponse from slack_sdk.webhook.async_client import AsyncWebhookClient from typing_extensions import override +from utilities.datetime import MINUTE, duration_to_float from utilities.functools import cache +from utilities.math import safe_round -_TIMEOUT = 30 +if TYPE_CHECKING: + from utilities.types import Duration +_TIMEOUT = MINUTE -def send_slack_sync(text: str, /, *, url: str, timeout: int = _TIMEOUT) -> None: + +def send_slack_sync(text: str, /, *, url: str, timeout: Duration = _TIMEOUT) -> None: """Send a message to Slack, synchronously.""" client = _get_client_sync(url, timeout=timeout) # pragma: no cover response = client.send(text=text) # pragma: no cover @@ -24,7 +30,7 @@ async def send_slack_async( /, *, url: str, - timeout: int = _TIMEOUT, # noqa: ASYNC109 + timeout: Duration = _TIMEOUT, # noqa: ASYNC109 ) -> None: """Send a message via Slack.""" client = _get_client_async(url, timeout=timeout) # pragma: no cover @@ -51,15 +57,19 @@ def __str__(self) -> str: @cache -def _get_client_sync(url: str, /, *, timeout: int = _TIMEOUT) -> WebhookClient: +def _get_client_sync(url: str, /, *, timeout: Duration = _TIMEOUT) -> WebhookClient: """Get the webhook client.""" - return WebhookClient(url, timeout=timeout) + timeout_use = safe_round(duration_to_float(timeout)) + return WebhookClient(url, timeout=timeout_use) @cache -def _get_client_async(url: str, /, *, timeout: int = _TIMEOUT) -> AsyncWebhookClient: +def _get_client_async( + url: str, /, *, timeout: Duration = _TIMEOUT +) -> AsyncWebhookClient: """Get the engine/sessionmaker for the required database.""" - return AsyncWebhookClient(url, timeout=timeout) + timeout_use = safe_round(duration_to_float(timeout)) + return AsyncWebhookClient(url, timeout=timeout_use) __all__ = ["send_slack_async", "send_slack_sync"]