diff --git a/pyproject.toml b/pyproject.toml index 8786562..68d7bae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,15 @@ dependencies = [ [project.scripts] utils = "utils:main" +[tool.pytest.ini_options] +# Live tests open a real GUI dialog and are intended for local manual +# testing only; they are deselected by default (e.g. in the pipeline) +# and run explicitly with: uv run pytest -m live +addopts = "-m 'not live'" +markers = [ + "live: opens a real GUI dialog, intended for local manual testing", +] + [build-system] requires = ["uv_build>=0.12.20,<0.13.0"] build-backend = "uv_build" diff --git a/src/utils/logging_config.py b/src/utils/logging_config.py index 0b8a4f4..ca1c78e 100755 --- a/src/utils/logging_config.py +++ b/src/utils/logging_config.py @@ -27,7 +27,7 @@ class LoggingSettings(BaseSettingsConfig): LOG_FILE_PATH: str = "logs/monitoring_backend.log" LOG_BACKUP_COUNT: int = Field(default=30, ge=0) - def model_post_init(self, __context: Any) -> None: + def model_post_init(self, __context: Any, /) -> None: self.setup_logging() def setup_logging(self) -> None: diff --git a/src/utils/misc.py b/src/utils/misc.py index 87caca4..c40d127 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -1,8 +1,8 @@ - def readlines(path: str) -> list[str]: with open(path, "r") as fid: lines = fid.readlines() return lines + def read(path: str) -> str: return "".join(readlines(path)) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..e7b922d --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,31 @@ +"""Make the test suite runnable on machines without Tk system libraries. + +``utils.filedialogs`` imports tkinter at module level, and the CI runner +lacks the Tk shared libraries (``import tkinter`` fails on a missing +``libtk8.6.so``). Since pytest imports every test module at collection +time — including the deselected ``test_filedialogs_live.py`` — a minimal +stub is registered in ``sys.modules`` here, before any test module is +imported, whenever tkinter is not importable. + +The hermetic tests in ``test_filedialogs.py`` mock every tkinter +touchpoint anyway, so the stub is never actually used. +""" + +import sys +import types + +try: + import tkinter # noqa: F401 +except ImportError: + _tk_stub = types.ModuleType("tkinter") + _filedialog_stub = types.ModuleType("tkinter.filedialog") + + def _tk_unavailable(*args, **kwargs): + raise AssertionError("real tkinter was called without a display") + + _tk_stub.Tk = _tk_unavailable + _tk_stub.filedialog = _filedialog_stub + _filedialog_stub.askdirectory = _tk_unavailable + _filedialog_stub.askopenfilename = _tk_unavailable + sys.modules["tkinter"] = _tk_stub + sys.modules["tkinter.filedialog"] = _filedialog_stub diff --git a/test/test_filedialogs.py b/test/test_filedialogs.py index 857d52a..6c56e78 100644 --- a/test/test_filedialogs.py +++ b/test/test_filedialogs.py @@ -1,35 +1,55 @@ +"""Hermetic tests for ``utils.filedialogs``. + +These tests never open a real dialog: every tkinter touchpoint is mocked, +so they run headless and even on machines without Tk system libraries +(e.g. the CI runner, where ``import tkinter`` fails on a missing +``libtk8.6.so``). ``conftest.py`` registers a tkinter stub in that case, +so ``utils.filedialogs`` can be imported at all. + +For tests that open a real dialog, see ``test_filedialogs_live.py`` +(marker ``live``, excluded from the default run). +""" + from unittest import mock from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog def test_get_dir_path_from_dialog_returns_selected_path(): - with mock.patch("tkinter.Tk"), mock.patch( - "tkinter.filedialog.askdirectory", return_value="/tmp/somewhere" - ) as dialog: + with ( + mock.patch("tkinter.Tk"), + mock.patch( + "tkinter.filedialog.askdirectory", return_value="/tmp/somewhere" + ) as dialog, + ): result = get_dir_path_from_dialog(initialdir="/tmp", title="Pick") assert result == "/tmp/somewhere" dialog.assert_called_once_with(initialdir="/tmp", title="Pick") def test_get_dir_path_from_dialog_returns_empty_string_on_cancel(): - with mock.patch("tkinter.Tk"), mock.patch( - "tkinter.filedialog.askdirectory", return_value="" + with ( + mock.patch("tkinter.Tk"), + mock.patch("tkinter.filedialog.askdirectory", return_value=""), ): assert get_dir_path_from_dialog() == "" def test_get_file_path_from_dialog_returns_selected_path(): - with mock.patch("tkinter.Tk"), mock.patch( - "tkinter.filedialog.askopenfilename", return_value="/tmp/file.txt" - ) as dialog: + with ( + mock.patch("tkinter.Tk"), + mock.patch( + "tkinter.filedialog.askopenfilename", return_value="/tmp/file.txt" + ) as dialog, + ): result = get_file_path_from_dialog(filetypes=[("Text", "*.txt")]) assert result == "/tmp/file.txt" dialog.assert_called_once_with(filetypes=[("Text", "*.txt")]) def test_get_file_path_from_dialog_returns_empty_string_on_cancel(): - with mock.patch("tkinter.Tk"), mock.patch( - "tkinter.filedialog.askopenfilename", return_value="" + with ( + mock.patch("tkinter.Tk"), + mock.patch("tkinter.filedialog.askopenfilename", return_value=""), ): assert get_file_path_from_dialog() == "" diff --git a/test/test_filedialogs_live.py b/test/test_filedialogs_live.py new file mode 100644 index 0000000..86fba9b --- /dev/null +++ b/test/test_filedialogs_live.py @@ -0,0 +1,30 @@ +"""Live dialog tests for ``utils.filedialogs``. + +These tests open a real OS dialog and require a working display. +They are excluded from the default (and pipeline) runs by the +``live`` marker configured in ``pyproject.toml`` and only run when +explicitly requested, e.g.:: + + uv run pytest -m live + +Choose a path in the dialog (or cancel it) — either outcome passes, +as long as the dialog returns consistently. +""" + +import os + +import pytest + +from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog + + +@pytest.mark.live +def test_get_dir_path_from_dialog_live(): + result = get_dir_path_from_dialog(title="Test: pick a directory") + assert result == "" or os.path.isdir(result) + + +@pytest.mark.live +def test_get_file_path_from_dialog_live(): + result = get_file_path_from_dialog(title="Test: pick a file") + assert result == "" or os.path.isfile(result) diff --git a/test/test_logging_config.py b/test/test_logging_config.py index b28f059..d89abfb 100755 --- a/test/test_logging_config.py +++ b/test/test_logging_config.py @@ -16,7 +16,7 @@ SECONDS_PER_DAY = 24 * 60 * 60 @pytest.fixture -def restore_root_logger() -> Generator[None, None, None]: +def restore_root_logger() -> Generator[None]: root = logging.getLogger() old_handlers = root.handlers[:] old_level = root.level @@ -45,7 +45,9 @@ def test_invalid_log_level_falls_back_to_info(restore_root_logger: None) -> None LoggingSettings(LOG_LEVEL="NOT_A_LEVEL", LOG_TO_FILE=False) assert logging.getLogger().level == logging.INFO - log_error.assert_called_once_with("Invalid log level %r; falling back to INFO.", "NOT_A_LEVEL") + log_error.assert_called_once_with( + "Invalid log level %r; falling back to INFO.", "NOT_A_LEVEL" + ) def test_configures_console_handler_only(restore_root_logger: None) -> None: @@ -66,7 +68,9 @@ def test_configures_file_handler_and_creates_parent_dir( handlers = logging.getLogger().handlers assert any(type(handler) is logging.StreamHandler for handler in handlers) - file_handlers = [handler for handler in handlers if isinstance(handler, logging.FileHandler)] + file_handlers = [ + handler for handler in handlers if isinstance(handler, logging.FileHandler) + ] assert len(file_handlers) == 1 assert log_file.parent.exists() assert isinstance(file_handlers[0], TimedRotatingFileHandler) @@ -77,12 +81,16 @@ def test_configures_file_handler_and_creates_parent_dir( assert file_handlers[0].encoding.lower() == "utf-8" -def test_configures_custom_log_backup_count(tmp_path: Path, restore_root_logger: None) -> None: +def test_configures_custom_log_backup_count( + tmp_path: Path, restore_root_logger: None +) -> None: log_file = tmp_path / "app.log" LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=7) file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) assert file_handler.backupCount == 7 @@ -99,7 +107,9 @@ def test_writes_records_to_console_and_file( logging.getLogger("test.logging_output").info("record is written") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) file_handler.flush() @@ -112,7 +122,9 @@ def test_rotates_active_log_file(tmp_path: Path, restore_root_logger: None) -> N LoggingSettings(LOG_FILE_PATH=str(log_file)) logger = logging.getLogger("test.logging_rotation") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) logger.info("before rollover") @@ -137,7 +149,9 @@ def test_removes_old_rotated_files_after_backup_limit( LoggingSettings(LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=2) logger = logging.getLogger("test.logging_retention") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) base_timestamp = int(time.time()) @@ -146,7 +160,8 @@ def test_removes_old_rotated_files_after_backup_limit( file_handler.flush() rollover_timestamp = base_timestamp + day * SECONDS_PER_DAY file_handler.rolloverAt = rollover_timestamp + SECONDS_PER_DAY - monkeypatch.setattr(time, "time", lambda: rollover_timestamp) + # Bind the loop variable via a default argument to avoid late binding (B023). + monkeypatch.setattr(time, "time", lambda now=rollover_timestamp: now) file_handler.doRollover() assert len(list(tmp_path.glob("app.*.log"))) == 2 @@ -162,7 +177,9 @@ def test_setup_logging_is_idempotent(tmp_path: Path, restore_root_logger: None) settings = LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file)) first_file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, logging.FileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, logging.FileHandler) ) first_handler_count = len(logging.getLogger().handlers) diff --git a/test/test_misc.py b/test/test_misc.py index 2968c01..f167e75 100644 --- a/test/test_misc.py +++ b/test/test_misc.py @@ -1,4 +1,3 @@ - from pathlib import Path from utils.misc import read, readlines @@ -12,6 +11,7 @@ with a second line and a third line """ + def test_readlines_str(): result = readlines(TEST_FILE_PATH) assert result @@ -19,6 +19,7 @@ def test_readlines_str(): assert all(isinstance(element, str) for element in result) assert result[1].strip() == "with a second line" + def test_path_str(): result = read(TEST_FILE_PATH) assert result