File utils1 #1
@@ -19,6 +19,15 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
utils = "utils:main"
|
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]
|
[build-system]
|
||||||
requires = ["uv_build>=0.12.20,<0.13.0"]
|
requires = ["uv_build>=0.12.20,<0.13.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class LoggingSettings(BaseSettingsConfig):
|
|||||||
LOG_FILE_PATH: str = "logs/monitoring_backend.log"
|
LOG_FILE_PATH: str = "logs/monitoring_backend.log"
|
||||||
LOG_BACKUP_COUNT: int = Field(default=30, ge=0)
|
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()
|
self.setup_logging()
|
||||||
|
|
||||||
def setup_logging(self) -> None:
|
def setup_logging(self) -> None:
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
def readlines(path: str) -> list[str]:
|
def readlines(path: str) -> list[str]:
|
||||||
with open(path, "r") as fid:
|
with open(path, "r") as fid:
|
||||||
lines = fid.readlines()
|
lines = fid.readlines()
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def read(path: str) -> str:
|
def read(path: str) -> str:
|
||||||
return "".join(readlines(path))
|
return "".join(readlines(path))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 unittest import mock
|
||||||
|
|
||||||
from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog
|
from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog
|
||||||
|
|
||||||
|
|
||||||
def test_get_dir_path_from_dialog_returns_selected_path():
|
def test_get_dir_path_from_dialog_returns_selected_path():
|
||||||
with mock.patch("tkinter.Tk"), mock.patch(
|
with (
|
||||||
|
mock.patch("tkinter.Tk"),
|
||||||
|
mock.patch(
|
||||||
"tkinter.filedialog.askdirectory", return_value="/tmp/somewhere"
|
"tkinter.filedialog.askdirectory", return_value="/tmp/somewhere"
|
||||||
) as dialog:
|
) as dialog,
|
||||||
|
):
|
||||||
result = get_dir_path_from_dialog(initialdir="/tmp", title="Pick")
|
result = get_dir_path_from_dialog(initialdir="/tmp", title="Pick")
|
||||||
assert result == "/tmp/somewhere"
|
assert result == "/tmp/somewhere"
|
||||||
dialog.assert_called_once_with(initialdir="/tmp", title="Pick")
|
dialog.assert_called_once_with(initialdir="/tmp", title="Pick")
|
||||||
|
|
||||||
|
|
||||||
def test_get_dir_path_from_dialog_returns_empty_string_on_cancel():
|
def test_get_dir_path_from_dialog_returns_empty_string_on_cancel():
|
||||||
with mock.patch("tkinter.Tk"), mock.patch(
|
with (
|
||||||
"tkinter.filedialog.askdirectory", return_value=""
|
mock.patch("tkinter.Tk"),
|
||||||
|
mock.patch("tkinter.filedialog.askdirectory", return_value=""),
|
||||||
):
|
):
|
||||||
assert get_dir_path_from_dialog() == ""
|
assert get_dir_path_from_dialog() == ""
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_path_from_dialog_returns_selected_path():
|
def test_get_file_path_from_dialog_returns_selected_path():
|
||||||
with mock.patch("tkinter.Tk"), mock.patch(
|
with (
|
||||||
|
mock.patch("tkinter.Tk"),
|
||||||
|
mock.patch(
|
||||||
"tkinter.filedialog.askopenfilename", return_value="/tmp/file.txt"
|
"tkinter.filedialog.askopenfilename", return_value="/tmp/file.txt"
|
||||||
) as dialog:
|
) as dialog,
|
||||||
|
):
|
||||||
result = get_file_path_from_dialog(filetypes=[("Text", "*.txt")])
|
result = get_file_path_from_dialog(filetypes=[("Text", "*.txt")])
|
||||||
assert result == "/tmp/file.txt"
|
assert result == "/tmp/file.txt"
|
||||||
dialog.assert_called_once_with(filetypes=[("Text", "*.txt")])
|
dialog.assert_called_once_with(filetypes=[("Text", "*.txt")])
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_path_from_dialog_returns_empty_string_on_cancel():
|
def test_get_file_path_from_dialog_returns_empty_string_on_cancel():
|
||||||
with mock.patch("tkinter.Tk"), mock.patch(
|
with (
|
||||||
"tkinter.filedialog.askopenfilename", return_value=""
|
mock.patch("tkinter.Tk"),
|
||||||
|
mock.patch("tkinter.filedialog.askopenfilename", return_value=""),
|
||||||
):
|
):
|
||||||
assert get_file_path_from_dialog() == ""
|
assert get_file_path_from_dialog() == ""
|
||||||
|
|||||||
@@ -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)
|
||||||
+27
-10
@@ -16,7 +16,7 @@ SECONDS_PER_DAY = 24 * 60 * 60
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def restore_root_logger() -> Generator[None, None, None]:
|
def restore_root_logger() -> Generator[None]:
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
old_handlers = root.handlers[:]
|
old_handlers = root.handlers[:]
|
||||||
old_level = root.level
|
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)
|
LoggingSettings(LOG_LEVEL="NOT_A_LEVEL", LOG_TO_FILE=False)
|
||||||
|
|
||||||
assert logging.getLogger().level == logging.INFO
|
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:
|
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
|
handlers = logging.getLogger().handlers
|
||||||
assert any(type(handler) is logging.StreamHandler for handler in 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 len(file_handlers) == 1
|
||||||
assert log_file.parent.exists()
|
assert log_file.parent.exists()
|
||||||
assert isinstance(file_handlers[0], TimedRotatingFileHandler)
|
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"
|
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"
|
log_file = tmp_path / "app.log"
|
||||||
LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=7)
|
LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=7)
|
||||||
|
|
||||||
file_handler = next(
|
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
|
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")
|
logging.getLogger("test.logging_output").info("record is written")
|
||||||
|
|
||||||
file_handler = next(
|
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()
|
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))
|
LoggingSettings(LOG_FILE_PATH=str(log_file))
|
||||||
logger = logging.getLogger("test.logging_rotation")
|
logger = logging.getLogger("test.logging_rotation")
|
||||||
file_handler = next(
|
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")
|
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)
|
LoggingSettings(LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=2)
|
||||||
logger = logging.getLogger("test.logging_retention")
|
logger = logging.getLogger("test.logging_retention")
|
||||||
file_handler = next(
|
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())
|
base_timestamp = int(time.time())
|
||||||
|
|
||||||
@@ -146,7 +160,8 @@ def test_removes_old_rotated_files_after_backup_limit(
|
|||||||
file_handler.flush()
|
file_handler.flush()
|
||||||
rollover_timestamp = base_timestamp + day * SECONDS_PER_DAY
|
rollover_timestamp = base_timestamp + day * SECONDS_PER_DAY
|
||||||
file_handler.rolloverAt = rollover_timestamp + 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()
|
file_handler.doRollover()
|
||||||
|
|
||||||
assert len(list(tmp_path.glob("app.*.log"))) == 2
|
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))
|
settings = LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file))
|
||||||
|
|
||||||
first_file_handler = next(
|
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)
|
first_handler_count = len(logging.getLogger().handlers)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from utils.misc import read, readlines
|
from utils.misc import read, readlines
|
||||||
@@ -12,6 +11,7 @@ with a second line
|
|||||||
and a third line
|
and a third line
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def test_readlines_str():
|
def test_readlines_str():
|
||||||
result = readlines(TEST_FILE_PATH)
|
result = readlines(TEST_FILE_PATH)
|
||||||
assert result
|
assert result
|
||||||
@@ -19,6 +19,7 @@ def test_readlines_str():
|
|||||||
assert all(isinstance(element, str) for element in result)
|
assert all(isinstance(element, str) for element in result)
|
||||||
assert result[1].strip() == "with a second line"
|
assert result[1].strip() == "with a second line"
|
||||||
|
|
||||||
|
|
||||||
def test_path_str():
|
def test_path_str():
|
||||||
result = read(TEST_FILE_PATH)
|
result = read(TEST_FILE_PATH)
|
||||||
assert result
|
assert result
|
||||||
|
|||||||
Reference in New Issue
Block a user