From 7ff005d18b68e595a86fa225f65fe34327162625 Mon Sep 17 00:00:00 2001 From: Jens Bergmann <1742418+1cu@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:12:43 +0200 Subject: [PATCH] fix: chores (#565) --- README.md | 2 +- src/kleinanzeigen_bot/utils/files.py | 16 +++- src/kleinanzeigen_bot/utils/misc.py | 18 ++-- tests/unit/test_files.py | 4 +- tests/unit/test_utils_misc.py | 136 ++++++++++++++++++++++++--- 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 40d7be8..eee6f6b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist unters 1. Execute `bash build-image.sh` -1. Ensure the image is build: +1. Ensure the image is built: ``` $ docker image ls diff --git a/src/kleinanzeigen_bot/utils/files.py b/src/kleinanzeigen_bot/utils/files.py index f3138c9..cc930c6 100644 --- a/src/kleinanzeigen_bot/utils/files.py +++ b/src/kleinanzeigen_bot/utils/files.py @@ -6,15 +6,21 @@ import os def abspath(relative_path:str, relative_to:str | None = None) -> str: """ - Makes a given relative path absolute based on another file/folder + Return a normalized absolute path based on *relative_to*. + + If 'relative_path' is already absolute, it is normalized and returned. + Otherwise, the function joins 'relative_path' with 'relative_to' (or the current working directory if not provided), + normalizes the result, and returns the absolute path. """ + if not relative_to: return os.path.abspath(relative_path) if os.path.isabs(relative_path): - return relative_path + return os.path.normpath(relative_path) - if os.path.isfile(relative_to): - relative_to = os.path.dirname(relative_to) + base = os.path.abspath(relative_to) + if os.path.isfile(base): + base = os.path.dirname(base) - return os.path.normpath(os.path.join(relative_to, relative_path)) + return os.path.normpath(os.path.join(base, relative_path)) diff --git a/src/kleinanzeigen_bot/utils/misc.py b/src/kleinanzeigen_bot/utils/misc.py index d5d41a2..29f21ca 100644 --- a/src/kleinanzeigen_bot/utils/misc.py +++ b/src/kleinanzeigen_bot/utils/misc.py @@ -17,12 +17,16 @@ def ensure( condition:Any | bool | Callable[[], bool], # noqa: FBT001 Boolean-typed positional argument in function definition error_message:str, timeout:float = 5, - poll_requency:float = 0.5 + poll_frequency:float = 0.5 ) -> None: """ - :param timeout: timespan in seconds until when the condition must become `True`, default is 5 seconds - :param poll_requency: sleep interval between calls in seconds, default is 0.5 seconds - :raises AssertionError: if condition did not come `True` within given timespan + Ensure a condition is true, retrying until timeout. + + :param condition: The condition to check (bool, value, or callable returning bool) + :param error_message: The error message to raise if the condition is not met + :param timeout: maximum time to wait in seconds, default is 5 seconds + :param poll_frequency: sleep interval between calls in seconds, default is 0.5 seconds + :raises AssertionError: if the condition is not met within the timeout """ if not isinstance(condition, Callable): # type: ignore[arg-type] # https://github.com/python/mypy/issues/6864 if condition: @@ -31,15 +35,15 @@ def ensure( if timeout < 0: raise AssertionError("[timeout] must be >= 0") - if poll_requency < 0: - raise AssertionError("[poll_requency] must be >= 0") + if poll_frequency < 0: + raise AssertionError("[poll_frequency] must be >= 0") start_at = time.time() while not condition(): # type: ignore[operator] elapsed = time.time() - start_at if elapsed >= timeout: raise AssertionError(_(error_message)) - time.sleep(poll_requency) + time.sleep(poll_frequency) def get_attr(obj:Mapping[str, Any] | Any, key:str, default:Any | None = None) -> Any: diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py index 43a53f5..4bf8784 100644 --- a/tests/unit/test_files.py +++ b/tests/unit/test_files.py @@ -54,9 +54,9 @@ class TestFiles: """Test abspath function with a nonexistent file/directory as relative_to.""" nonexistent_path = "nonexistent/path" - # Test with a relative path + # Test with a relative path; should still yield an absolute path result = abspath("test/path", nonexistent_path) - expected = os.path.normpath(os.path.join(nonexistent_path, "test/path")) + expected = os.path.normpath(os.path.join(os.path.abspath(nonexistent_path), "test/path")) assert result == expected # Test with an absolute path diff --git a/tests/unit/test_utils_misc.py b/tests/unit/test_utils_misc.py index 2d17b12..b900f7c 100644 --- a/tests/unit/test_utils_misc.py +++ b/tests/unit/test_utils_misc.py @@ -1,29 +1,135 @@ # SPDX-FileCopyrightText: © Sebastian Thomschke and contributors # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import asyncio +import decimal +import sys +from datetime import datetime, timedelta, timezone + import pytest from kleinanzeigen_bot.utils import misc -def test_ensure() -> None: - misc.ensure(True, "TRUE") - misc.ensure("Some Value", "TRUE") - misc.ensure(123, "TRUE") - misc.ensure(-123, "TRUE") - misc.ensure(lambda: True, "TRUE") +def test_now_returns_utc_datetime() -> None: + dt = misc.now() + assert dt.tzinfo is not None + assert dt.tzinfo.utcoffset(dt) == timedelta(0) - with pytest.raises(AssertionError): - misc.ensure(False, "FALSE") - with pytest.raises(AssertionError): - misc.ensure(0, "FALSE") +def test_is_frozen_default() -> None: + assert misc.is_frozen() is False - with pytest.raises(AssertionError): - misc.ensure("", "FALSE") - with pytest.raises(AssertionError): - misc.ensure(None, "FALSE") +def test_is_frozen_true(monkeypatch:pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "frozen", True, raising = False) + assert misc.is_frozen() is True + +def test_ainput_is_coroutine() -> None: + assert asyncio.iscoroutinefunction(misc.ainput) + + +def test_parse_decimal_valid_inputs() -> None: + assert misc.parse_decimal(5) == decimal.Decimal("5") + assert misc.parse_decimal(5.5) == decimal.Decimal("5.5") + assert misc.parse_decimal("5.5") == decimal.Decimal("5.5") + assert misc.parse_decimal("5,5") == decimal.Decimal("5.5") + assert misc.parse_decimal("1.005,5") == decimal.Decimal("1005.5") + assert misc.parse_decimal("1,005.5") == decimal.Decimal("1005.5") + + +def test_parse_decimal_invalid_input() -> None: + with pytest.raises(decimal.DecimalException): + misc.parse_decimal("not_a_number") + + +def test_parse_datetime_none_returns_none() -> None: + assert misc.parse_datetime(None) is None + + +def test_parse_datetime_from_datetime() -> None: + dt = datetime(2020, 1, 1, 0, 0, tzinfo = timezone.utc) + assert misc.parse_datetime(dt, add_timezone_if_missing = False) == dt + + +def test_parse_datetime_from_string() -> None: + dt_str = "2020-01-01T00:00:00" + result = misc.parse_datetime(dt_str, add_timezone_if_missing = False) + assert result == datetime(2020, 1, 1, 0, 0) # noqa: DTZ001 + + +def test_parse_duration_various_inputs() -> None: + assert misc.parse_duration("1h 30m") == timedelta(hours = 1, minutes = 30) + assert misc.parse_duration("2d 4h 15m 10s") == timedelta(days = 2, hours = 4, minutes = 15, seconds = 10) + assert misc.parse_duration("45m") == timedelta(minutes = 45) + assert misc.parse_duration("3d") == timedelta(days = 3) + assert misc.parse_duration("5h 5h") == timedelta(hours = 10) + assert misc.parse_duration("invalid input") == timedelta(0) + + +def test_format_timedelta_examples() -> None: + assert misc.format_timedelta(timedelta(seconds = 90)) == "1 minute, 30 seconds" + assert misc.format_timedelta(timedelta(hours = 1)) == "1 hour" + assert misc.format_timedelta(timedelta(days = 2, hours = 5)) == "2 days, 5 hours" + assert misc.format_timedelta(timedelta(0)) == "0 seconds" + + +class Dummy: + def __init__(self, contact:object) -> None: + self.contact = contact + + +def test_get_attr_object_and_dict() -> None: + assert misc.get_attr(Dummy({"email": "user@example.com"}), "contact.email") == "user@example.com" + assert misc.get_attr(Dummy({"email": "user@example.com"}), "contact.foo") is None + assert misc.get_attr(Dummy({"email": None}), "contact.email", default = "n/a") == "n/a" + assert misc.get_attr(Dummy(None), "contact.email", default = "n/a") == "n/a" + assert misc.get_attr({"contact": {"email": "data@example.com"}}, "contact.email") == "data@example.com" + assert misc.get_attr({"contact": {"email": "user@example.com"}}, "contact.foo") is None + assert misc.get_attr({"contact": {"email": None}}, "contact.email", default = "n/a") == "n/a" + assert misc.get_attr({}, "contact.email", default = "none") == "none" + + +def test_ensure_negative_timeout() -> None: + with pytest.raises(AssertionError, match = r"\[timeout\] must be >= 0"): + misc.ensure(lambda: True, "Should fail", timeout = -1) + + +def test_ensure_negative_poll_frequency() -> None: + with pytest.raises(AssertionError, match = r"\[poll_frequency\] must be >= 0"): + misc.ensure(lambda: True, "Should fail", poll_frequency = -1) + + +def test_ensure_callable_condition_becomes_true(monkeypatch:pytest.MonkeyPatch) -> None: + # Should return before timeout if condition becomes True + state = {"called": 0} + + def cond() -> bool: + state["called"] += 1 + return state["called"] > 2 + misc.ensure(cond, "Should not fail", timeout = 1, poll_frequency = 0.01) + + +def test_ensure_callable_condition_timeout() -> None: + # Should raise AssertionError after timeout if condition never True with pytest.raises(AssertionError): - misc.ensure(lambda: False, "FALSE", timeout = 2) + misc.ensure(lambda: False, "Timeout fail", timeout = 0.05, poll_frequency = 0.01) + + +def test_ensure_non_callable_truthy_and_falsy() -> None: + # Truthy values should not raise + misc.ensure(True, "Should not fail for True") + misc.ensure("Some Value", "Should not fail for non-empty string") + misc.ensure(123, "Should not fail for positive int") + misc.ensure(-123, "Should not fail for negative int") + + # Falsy values should raise AssertionError + with pytest.raises(AssertionError): + misc.ensure(False, "Should fail for False") + with pytest.raises(AssertionError): + misc.ensure(0, "Should fail for 0") + with pytest.raises(AssertionError): + misc.ensure("", "Should fail for empty string") + with pytest.raises(AssertionError): + misc.ensure(None, "Should fail for None")