ls-logging 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ *.key
2
+ *.crt
3
+ *.pem
4
+ .superpowers/
5
+ .claude/
6
+ .cursor/
7
+ .vscode/
8
+ docs/superpowers/plans/
9
+ __pycache__/
10
+ .pytest_cache/
11
+ .venv*/
12
+ rust/target/
13
+ dist/
14
+ .uv-cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Borisov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: ls-logging
3
+ Version: 0.1.0
4
+ Summary: Логи служб lightmaster в journald
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.9
8
+ Provides-Extra: dev
9
+ Requires-Dist: django; extra == 'dev'
10
+ Requires-Dist: mypy; extra == 'dev'
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,44 @@
1
+ """Ручная проверка вывода в терминале и под journald.
2
+
3
+ Программа завершается необработанным исключением. Так проверяется `sys.excepthook`.
4
+ """
5
+ import logging
6
+ import threading
7
+ import warnings
8
+
9
+ import ls_logging
10
+
11
+
12
+ def _fail_in_thread() -> None:
13
+ raise RuntimeError("падение потока")
14
+
15
+
16
+ def main() -> None:
17
+ """Пишет сообщения всех уровней, ошибки и падения потока и процесса."""
18
+ ls_logging.setup_logging("ls_logging_check", "DEBUG")
19
+ logger = logging.getLogger("ls_logging_check")
20
+
21
+ logger.debug("проверка debug")
22
+ logger.info("проверка info")
23
+ logger.warning("проверка warning")
24
+ logger.error("проверка error")
25
+ logger.critical("проверка critical")
26
+
27
+ try:
28
+ 1 / 0 # noqa: B018
29
+ except ZeroDivisionError:
30
+ logger.exception("ошибка с трассировкой")
31
+
32
+ logger.error("длинное " * 6700)
33
+
34
+ warnings.warn("проверка warnings", stacklevel=1)
35
+
36
+ worker = threading.Thread(target=_fail_in_thread, name="worker-1")
37
+ worker.start()
38
+ worker.join()
39
+
40
+ raise RuntimeError("падение процесса")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,6 @@
1
+ """Логи служб lightmaster в journald."""
2
+ from ls_logging._django import django_logging
3
+ from ls_logging._setup import setup_logging
4
+
5
+
6
+ __all__ = ["django_logging", "setup_logging"]
@@ -0,0 +1,45 @@
1
+ """Настройки логирования для `LOGGING` в `settings.py` Django."""
2
+ from typing import Any, Dict, Union
3
+
4
+ from ls_logging._setup import resolve_level
5
+
6
+
7
+ def django_logging(service: str, level: Union[str, int]) -> Dict[str, Any]:
8
+ """Собирает словарь для `LOGGING` в `settings.py` Django.
9
+
10
+ Словарь ставит на корневой логгер один обработчик. Если `JOURNAL_STREAM`
11
+ совпадает с устройством и inode stderr, обработчик пишет в `/dev/log`
12
+ под именем `service`. Иначе он пишет в stderr со временем в начале строки.
13
+ Логгер `django` остается без своих обработчиков.
14
+ Поэтому обработчики `console` и `mail_admins` из настроек Django по умолчанию не работают.
15
+ Словарь не вызывает `captureWarnings` и не ставит `sys.excepthook` и `threading.excepthook`.
16
+
17
+ Args:
18
+ service: Имя службы, под которым записи видны в journald.
19
+ level: Уровень корневого логгера: число, имя уровня в любом регистре
20
+ или `Enum` со строковым значением. Имя `WARN` понимается как
21
+ `WARNING`.
22
+
23
+ Returns:
24
+ Словарь в формате `logging.config.dictConfig`.
25
+
26
+ Raises:
27
+ ValueError: Если уровень не распознан.
28
+ """
29
+ return {
30
+ "version": 1,
31
+ "disable_existing_loggers": False,
32
+ "handlers": {
33
+ "ls_logging": {
34
+ "()": "ls_logging._output.build_handler",
35
+ "service": service,
36
+ },
37
+ },
38
+ "root": {
39
+ "handlers": ["ls_logging"],
40
+ "level": resolve_level(level),
41
+ },
42
+ "loggers": {
43
+ "django": {"handlers": [], "level": "NOTSET", "propagate": True},
44
+ },
45
+ }
@@ -0,0 +1,52 @@
1
+ """Выбор вывода логов: journald через syslog или stderr."""
2
+ import logging
3
+ import logging.handlers
4
+ import os
5
+ import sys
6
+
7
+
8
+ SYSLOG_ADDRESS = "/dev/log"
9
+ JOURNAL_FORMAT = "[%(levelname)s] %(name)s: %(message)s"
10
+ TERMINAL_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
11
+
12
+
13
+ def stderr_is_journal() -> bool:
14
+ """Проверяет, что stderr процесса подключен к journald.
15
+
16
+ Returns:
17
+ True, если `JOURNAL_STREAM` задана и ее `устройство:inode` совпадают
18
+ с `st_dev` и `st_ino` дескриптора 2, иначе False. Испорченное значение
19
+ переменной и недоступный дескриптор тоже дают False.
20
+ """
21
+ value = os.environ.get("JOURNAL_STREAM")
22
+ if value is None:
23
+ return False
24
+ try:
25
+ device, inode = value.split(":")
26
+ expected = (int(device), int(inode))
27
+ st = os.fstat(2)
28
+ except (ValueError, OSError):
29
+ return False
30
+ return expected == (st.st_dev, st.st_ino)
31
+
32
+
33
+ def build_handler(service: str) -> logging.Handler:
34
+ """Создает обработчик логов под текущее окружение с готовым форматтером.
35
+
36
+ Под journald уровень сообщения доходит до журнала как его приоритет.
37
+ Иначе сообщения пишутся в `sys.stderr` со временем.
38
+
39
+ Args:
40
+ service: Имя службы, под которым записи видны в journald.
41
+
42
+ Returns:
43
+ Обработчик `SysLogHandler` под journald, иначе `StreamHandler`.
44
+ """
45
+ if stderr_is_journal():
46
+ handler: logging.Handler = logging.handlers.SysLogHandler(address=SYSLOG_ADDRESS)
47
+ handler.ident = f"{service}: " # type: ignore[attr-defined]
48
+ handler.setFormatter(logging.Formatter(JOURNAL_FORMAT))
49
+ return handler
50
+ handler = logging.StreamHandler(sys.stderr)
51
+ handler.setFormatter(logging.Formatter(TERMINAL_FORMAT))
52
+ return handler
@@ -0,0 +1,109 @@
1
+ """Настройка логирования службы и обработчики необработанных исключений."""
2
+ import logging
3
+ import sys
4
+ import threading
5
+ from enum import Enum
6
+ from types import TracebackType
7
+ from typing import Optional, Tuple, Type, Union, cast
8
+
9
+ from ls_logging._output import build_handler
10
+
11
+
12
+ _logger = logging.getLogger("ls_logging")
13
+
14
+
15
+ def resolve_level(level: Union[str, int]) -> int:
16
+ """Переводит уровень логирования в число.
17
+
18
+ Args:
19
+ level: Число, имя уровня в любом регистре или `Enum` со значением-именем.
20
+ Имя `WARN` понимается как `WARNING`.
21
+
22
+ Returns:
23
+ Числовой уровень модуля `logging`.
24
+
25
+ Raises:
26
+ ValueError: Если уровень не распознан. Исходное значение входит в текст ошибки.
27
+ """
28
+ value = level.value if isinstance(level, Enum) else level
29
+ resolved = logging.getLevelName(value.upper()) if isinstance(value, str) else value
30
+ if not isinstance(resolved, int):
31
+ raise ValueError(f"Unknown log level: {level!r}")
32
+ return resolved
33
+
34
+
35
+ def _log_critical(
36
+ exc_info: Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
37
+ msg: str,
38
+ *args: object,
39
+ ) -> None:
40
+ # dictConfig и fileConfig с настройками по умолчанию отключают уже созданные логгеры.
41
+ # Падение службы не должно пропасть из-за такого чужого вызова.
42
+ _logger.disabled = False
43
+ if _logger.isEnabledFor(logging.CRITICAL):
44
+ _logger.critical(msg, *args, exc_info=exc_info)
45
+ else:
46
+ # Уровень запрещен через logging.disable, трассировку печатает стандартный обработчик.
47
+ # threading.__excepthook__ появился только в Python 3.10.
48
+ sys.__excepthook__(*exc_info)
49
+
50
+
51
+ def _log_uncaught(
52
+ exc_type: Type[BaseException],
53
+ exc_value: BaseException,
54
+ exc_traceback: Optional[TracebackType],
55
+ ) -> None:
56
+ if issubclass(exc_type, KeyboardInterrupt):
57
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
58
+ return
59
+ _log_critical((exc_type, exc_value, exc_traceback), "Uncaught exception")
60
+
61
+
62
+ def _log_uncaught_in_thread(args: "threading.ExceptHookArgs") -> None:
63
+ if issubclass(args.exc_type, SystemExit):
64
+ return
65
+ name = args.thread.name if args.thread is not None else "<unknown>"
66
+ # threading передает exc_value всегда, хотя в типах он Optional.
67
+ exc_value = cast(BaseException, args.exc_value)
68
+ _log_critical(
69
+ (args.exc_type, exc_value, args.exc_traceback),
70
+ "Uncaught exception in thread %s",
71
+ name,
72
+ )
73
+
74
+
75
+ def setup_logging(service: str, level: Union[str, int]) -> None:
76
+ """Настраивает логирование службы под journald или терминал.
77
+
78
+ Заменяет все обработчики корневого логгера одним новым. Старые обработчики
79
+ не закрываются. Если `JOURNAL_STREAM` совпадает с устройством и inode
80
+ stderr, записи уходят в `/dev/log` под именем `service`. Иначе они идут
81
+ в stderr со временем в начале строки. Предупреждения `warnings` тоже
82
+ попадают в логирование.
83
+
84
+ Ставит `sys.excepthook` и `threading.excepthook`. Они пишут необработанное
85
+ исключение с трассировкой уровнем CRITICAL в логгер `ls_logging`. Если
86
+ другая настройка логирования отключила этот логгер, обработчики снова
87
+ включают его. Если уровень CRITICAL запрещен через `logging.disable`,
88
+ трассировку печатает `sys.__excepthook__`. `KeyboardInterrupt` передается
89
+ в `sys.__excepthook__`. `SystemExit` в потоке пропускается. Если службе
90
+ нужен свой `threading.excepthook`, она ставит его после вызова.
91
+
92
+ Args:
93
+ service: Имя службы, под которым записи видны в journald.
94
+ level: Уровень корневого логгера: число, имя уровня в любом регистре
95
+ или `Enum` со строковым значением. Имя `WARN` понимается как
96
+ `WARNING`.
97
+
98
+ Raises:
99
+ ValueError: Если уровень не распознан. Настройка при этом не меняется.
100
+ """
101
+ numeric_level = resolve_level(level)
102
+ root = logging.getLogger()
103
+ for handler in list(root.handlers):
104
+ root.removeHandler(handler)
105
+ root.addHandler(build_handler(service))
106
+ root.setLevel(numeric_level)
107
+ logging.captureWarnings(True)
108
+ sys.excepthook = _log_uncaught
109
+ threading.excepthook = _log_uncaught_in_thread
File without changes
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "ls-logging"
3
+ version = "0.1.0"
4
+ description = "Логи служб lightmaster в journald"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ requires-python = ">=3.9"
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = [
12
+ "pytest",
13
+ "ruff",
14
+ "mypy",
15
+ "django",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["ls_logging"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ filterwarnings = [
28
+ "error::ResourceWarning",
29
+ "error::pytest.PytestUnraisableExceptionWarning",
30
+ ]
31
+
32
+ [tool.ruff]
33
+ line-length = 120
34
+ target-version = "py39"
35
+
36
+ [tool.ruff.lint]
37
+ select = [
38
+ "F", # pyflakes
39
+ "E", # pycodestyle errors
40
+ "W", # pycodestyle warnings
41
+ "I", # isort
42
+ "N", # pep8-naming
43
+ "B", # flake8-bugbear
44
+ "BLE", # flake8-blind-except
45
+ "C4", # flake8-comprehensions
46
+ "T10", # flake8-debugger
47
+ "A", # flake8-builtins
48
+ "ARG", # flake8-unused-arguments
49
+ "C90", # mccabe
50
+ "EM", # flake8-errmsg
51
+ "EXE", # flake8-executable
52
+ "ICN", # flake8-import-conventions
53
+ "PIE", # flake8-pie
54
+ "PT", # flake8-pytest-style
55
+ "SIM", # flake8-simplify
56
+ "TID", # flake8-tidy-imports
57
+ "ERA", # flake8-eradicate
58
+ "PL", # pylint
59
+ "COM", # flake8-commas
60
+ "T20", # flake8-print
61
+ "FIX", # flake8-fixme
62
+ ]
63
+ ignore = ["A002", "A003", "EM101", "EM102", "N818", "RET504", "PLW0603", "PLR2004"]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-first-party = ["ls_logging"]
67
+ force-single-line = false
68
+ lines-after-imports = 2
69
+ order-by-type = true
70
+
71
+ [tool.ruff.lint.pydocstyle]
72
+ convention = "google"
73
+
74
+ [tool.mypy]
75
+ python_version = "3.9"
76
+ strict = true
77
+
78
+ [[tool.mypy.overrides]]
79
+ module = ["django.*"]
80
+ ignore_missing_imports = true
@@ -0,0 +1,65 @@
1
+ """Общие фикстуры тестов."""
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import socket
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ from pathlib import Path
10
+ from typing import Iterator
11
+
12
+ import pytest
13
+
14
+ from ls_logging import _output
15
+
16
+
17
+ @pytest.fixture
18
+ def journal_env(monkeypatch: pytest.MonkeyPatch) -> None:
19
+ """Выставляет `JOURNAL_STREAM` так, будто stderr подключен к journald."""
20
+ st = os.fstat(2)
21
+ monkeypatch.setenv("JOURNAL_STREAM", f"{st.st_dev}:{st.st_ino}")
22
+
23
+
24
+ @pytest.fixture
25
+ def syslog_socket(monkeypatch: pytest.MonkeyPatch) -> Iterator[socket.socket]:
26
+ """Подставляет вместо `/dev/log` датаграммный сокет во временном каталоге."""
27
+ directory = tempfile.mkdtemp(dir="/tmp") # noqa: S108
28
+ path = str(Path(directory) / "log")
29
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
30
+ sock.bind(path)
31
+ sock.settimeout(5)
32
+ monkeypatch.setattr(_output, "SYSLOG_ADDRESS", path)
33
+ try:
34
+ yield sock
35
+ finally:
36
+ sock.close()
37
+ shutil.rmtree(directory, ignore_errors=True)
38
+
39
+
40
+ @pytest.fixture
41
+ def restore_logging() -> Iterator[None]:
42
+ """Возвращает глобальное состояние логирования после теста.
43
+
44
+ Сохраняет обработчики и уровень корневого логгера, `sys.excepthook`
45
+ и `threading.excepthook`. После теста закрывает обработчики, добавленные
46
+ за время теста, и возвращает сохраненное состояние.
47
+ """
48
+ root = logging.getLogger()
49
+ handlers = list(root.handlers)
50
+ level = root.level
51
+ excepthook = sys.excepthook
52
+ thread_excepthook = threading.excepthook
53
+ try:
54
+ yield
55
+ finally:
56
+ for handler in list(root.handlers):
57
+ root.removeHandler(handler)
58
+ if handler not in handlers:
59
+ handler.close()
60
+ for handler in handlers:
61
+ root.addHandler(handler)
62
+ root.setLevel(level)
63
+ sys.excepthook = excepthook
64
+ threading.excepthook = thread_excepthook
65
+ logging.captureWarnings(False)
@@ -0,0 +1,51 @@
1
+ """Тесты `django_logging` на настоящем пути настройки Django."""
2
+ import logging
3
+ import socket
4
+
5
+ import pytest
6
+
7
+ from ls_logging import django_logging
8
+
9
+
10
+ django = pytest.importorskip("django")
11
+
12
+ from django.conf import settings # noqa: E402
13
+ from django.utils.log import configure_logging # noqa: E402
14
+
15
+
16
+ pytestmark = pytest.mark.usefixtures("restore_logging")
17
+
18
+ if not settings.configured:
19
+ settings.configure(DEBUG=True)
20
+
21
+
22
+ def test_django_error_printed_once(
23
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
24
+ ) -> None:
25
+ monkeypatch.delenv("JOURNAL_STREAM", raising=False)
26
+ configure_logging("logging.config.dictConfig", django_logging("lm_backend", "INFO"))
27
+ logging.getLogger("django.request").error("Internal Server Error: /api")
28
+ err = capsys.readouterr().err
29
+ assert err.count("Internal Server Error: /api") == 1
30
+ assert "[ERROR] django.request: Internal Server Error: /api" in err
31
+
32
+
33
+ def test_django_root_has_one_handler_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
34
+ monkeypatch.delenv("JOURNAL_STREAM", raising=False)
35
+ configure_logging("logging.config.dictConfig", django_logging("lm_backend", "WARNING"))
36
+ root = logging.getLogger()
37
+ assert len(root.handlers) == 1
38
+ assert root.level == logging.WARNING
39
+ assert logging.getLogger("django").handlers == []
40
+
41
+
42
+ @pytest.mark.usefixtures("journal_env")
43
+ def test_django_journal_uses_syslog(syslog_socket: socket.socket) -> None:
44
+ configure_logging("logging.config.dictConfig", django_logging("lm_backend", "INFO"))
45
+ logging.getLogger("django.request").error("x")
46
+ assert syslog_socket.recv(65536).startswith(b"<11>lm_backend: [ERROR] django.request: x")
47
+
48
+
49
+ def test_unknown_level_raises() -> None:
50
+ with pytest.raises(ValueError, match="NOPE"):
51
+ django_logging("lm_backend", "NOPE")
@@ -0,0 +1,6 @@
1
+ """Тест импорта пакета."""
2
+ import ls_logging
3
+
4
+
5
+ def test_package_imports() -> None:
6
+ assert ls_logging.__name__ == "ls_logging"
@@ -0,0 +1,67 @@
1
+ """Тесты выбора вывода и обработчика Python."""
2
+ import logging
3
+ import logging.handlers
4
+ import os
5
+ import re
6
+
7
+ import pytest
8
+
9
+ from ls_logging._output import build_handler, stderr_is_journal
10
+
11
+
12
+ @pytest.mark.usefixtures("journal_env")
13
+ def test_journal_when_stream_matches_stderr() -> None:
14
+ assert stderr_is_journal() is True
15
+
16
+
17
+ def test_not_journal_without_variable(monkeypatch: pytest.MonkeyPatch) -> None:
18
+ monkeypatch.delenv("JOURNAL_STREAM", raising=False)
19
+ assert stderr_is_journal() is False
20
+
21
+
22
+ def test_not_journal_when_inode_differs(monkeypatch: pytest.MonkeyPatch) -> None:
23
+ monkeypatch.setenv("JOURNAL_STREAM", "1:2")
24
+ assert stderr_is_journal() is False
25
+
26
+
27
+ @pytest.mark.parametrize("value", ["", "abc", "1:", "1:2:3"])
28
+ def test_not_journal_when_variable_is_malformed(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
29
+ monkeypatch.setenv("JOURNAL_STREAM", value)
30
+ assert stderr_is_journal() is False
31
+
32
+
33
+ @pytest.mark.usefixtures("journal_env")
34
+ def test_not_journal_when_stderr_unusable(monkeypatch: pytest.MonkeyPatch) -> None:
35
+ real_fstat = os.fstat
36
+
37
+ def broken_fstat(fd: int) -> os.stat_result:
38
+ if fd != 2: # pytest сам вызывает fstat для своих дескрипторов
39
+ return real_fstat(fd)
40
+ msg = "bad fd"
41
+ raise OSError(msg)
42
+
43
+ monkeypatch.setattr(os, "fstat", broken_fstat)
44
+ assert stderr_is_journal() is False
45
+
46
+
47
+ def test_terminal_handler_writes_stderr_with_time(
48
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
49
+ ) -> None:
50
+ monkeypatch.delenv("JOURNAL_STREAM", raising=False)
51
+ handler = build_handler("lm_test")
52
+ assert type(handler) is logging.StreamHandler
53
+ handler.handle(
54
+ logging.makeLogRecord({"name": "svc.mod", "levelno": logging.ERROR, "levelname": "ERROR", "msg": "boom"}),
55
+ )
56
+ line = capsys.readouterr().err
57
+ assert re.fullmatch(r"\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d{3} \[ERROR\] svc\.mod: boom\n", line)
58
+
59
+
60
+ @pytest.mark.usefixtures("journal_env", "syslog_socket")
61
+ def test_journal_handler_is_syslog_with_ident() -> None:
62
+ handler = build_handler("lm_test")
63
+ try:
64
+ assert isinstance(handler, logging.handlers.SysLogHandler)
65
+ assert handler.ident == "lm_test: "
66
+ finally:
67
+ handler.close()
@@ -0,0 +1,211 @@
1
+ """Тесты `setup_logging` и обработчиков необработанных исключений."""
2
+ import logging
3
+ import socket
4
+ import sys
5
+ import threading
6
+ import warnings
7
+ from enum import Enum
8
+ from typing import Union
9
+
10
+ import pytest
11
+
12
+ from ls_logging import setup_logging
13
+
14
+
15
+ pytestmark = pytest.mark.usefixtures("restore_logging")
16
+
17
+
18
+ class LoggingLevel(str, Enum):
19
+ INFO = "INFO"
20
+ WARNING = "WARNING"
21
+ WARN = "WARNING" # noqa: PIE796
22
+
23
+
24
+ @pytest.mark.parametrize(
25
+ ("level", "expected"),
26
+ [
27
+ ("INFO", logging.INFO),
28
+ ("warning", logging.WARNING),
29
+ ("WARN", logging.WARNING),
30
+ (10, logging.DEBUG),
31
+ (LoggingLevel.INFO, logging.INFO),
32
+ (LoggingLevel.WARN, logging.WARNING),
33
+ ],
34
+ )
35
+ def test_level_is_resolved(level: Union[str, int], expected: int) -> None:
36
+ setup_logging("lm_test", level)
37
+ assert logging.getLogger().level == expected
38
+
39
+
40
+ def test_unknown_level_raises() -> None:
41
+ with pytest.raises(ValueError, match="NOPE"):
42
+ setup_logging("lm_test", "NOPE")
43
+
44
+
45
+ def test_unknown_level_keeps_configuration() -> None:
46
+ setup_logging("lm_test", "WARNING")
47
+ root = logging.getLogger()
48
+ handlers = list(root.handlers)
49
+ with pytest.raises(ValueError, match="NOPE"):
50
+ setup_logging("lm_test", "NOPE")
51
+ assert root.handlers == handlers
52
+ assert root.level == logging.WARNING
53
+
54
+
55
+ def test_second_call_keeps_one_handler() -> None:
56
+ setup_logging("lm_test", "INFO")
57
+ setup_logging("lm_test", "INFO")
58
+ assert len(logging.getLogger().handlers) == 1
59
+
60
+
61
+ def test_replaces_basic_config_handler(capsys: pytest.CaptureFixture[str]) -> None:
62
+ logging.basicConfig(level=logging.INFO, force=True)
63
+ setup_logging("lm_test", "INFO")
64
+ assert len(logging.getLogger().handlers) == 1
65
+ logging.getLogger("svc").error("once")
66
+ assert capsys.readouterr().err.count("once") == 1
67
+
68
+
69
+ def test_warnings_go_to_logging(capsys: pytest.CaptureFixture[str]) -> None:
70
+ setup_logging("lm_test", "INFO")
71
+ with warnings.catch_warnings():
72
+ warnings.simplefilter("always")
73
+ warnings.warn("old api", DeprecationWarning, stacklevel=1)
74
+ assert "[WARNING] py.warnings:" in capsys.readouterr().err
75
+
76
+
77
+ def test_uncaught_exception_logged_critical(capsys: pytest.CaptureFixture[str]) -> None:
78
+ setup_logging("lm_test", "INFO")
79
+ try:
80
+ raise ValueError("bad")
81
+ except ValueError as exc:
82
+ sys.excepthook(type(exc), exc, exc.__traceback__)
83
+ err = capsys.readouterr().err
84
+ assert "[CRITICAL] ls_logging: Uncaught exception" in err
85
+ assert "Traceback" in err
86
+ assert "ValueError: bad" in err
87
+
88
+
89
+ def test_uncaught_exception_logged_when_logger_disabled(
90
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
91
+ ) -> None:
92
+ setup_logging("lm_test", "INFO")
93
+ # Так логгер отключают dictConfig и fileConfig с настройками по умолчанию.
94
+ monkeypatch.setattr(logging.getLogger("ls_logging"), "disabled", True)
95
+ try:
96
+ raise ValueError("bad")
97
+ except ValueError as exc:
98
+ sys.excepthook(type(exc), exc, exc.__traceback__)
99
+ err = capsys.readouterr().err
100
+ assert "[CRITICAL] ls_logging: Uncaught exception" in err
101
+ assert "ValueError: bad" in err
102
+
103
+
104
+ def test_uncaught_exception_printed_when_critical_disabled(
105
+ capsys: pytest.CaptureFixture[str],
106
+ ) -> None:
107
+ setup_logging("lm_test", "INFO")
108
+ logging.disable(logging.CRITICAL)
109
+ try:
110
+ try:
111
+ raise ValueError("bad")
112
+ except ValueError as exc:
113
+ sys.excepthook(type(exc), exc, exc.__traceback__)
114
+ finally:
115
+ logging.disable(logging.NOTSET)
116
+ err = capsys.readouterr().err
117
+ assert "CRITICAL" not in err
118
+ assert "Traceback" in err
119
+ assert "ValueError: bad" in err
120
+
121
+
122
+ def test_keyboard_interrupt_goes_to_default_hook(
123
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
124
+ ) -> None:
125
+ calls = []
126
+ monkeypatch.setattr(sys, "__excepthook__", lambda *args: calls.append(args[0]))
127
+ setup_logging("lm_test", "INFO")
128
+ sys.excepthook(KeyboardInterrupt, KeyboardInterrupt(), None)
129
+ assert calls == [KeyboardInterrupt]
130
+ assert "CRITICAL" not in capsys.readouterr().err
131
+
132
+
133
+ def test_thread_exception_logged_critical(capsys: pytest.CaptureFixture[str]) -> None:
134
+ setup_logging("lm_test", "INFO")
135
+
136
+ def boom() -> None:
137
+ raise RuntimeError("thread died")
138
+
139
+ thread = threading.Thread(target=boom, name="worker-1")
140
+ thread.start()
141
+ thread.join()
142
+ err = capsys.readouterr().err
143
+ assert "[CRITICAL] ls_logging: Uncaught exception in thread worker-1" in err
144
+ assert "RuntimeError: thread died" in err
145
+
146
+
147
+ def test_thread_exception_logged_when_logger_disabled(
148
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
149
+ ) -> None:
150
+ setup_logging("lm_test", "INFO")
151
+ monkeypatch.setattr(logging.getLogger("ls_logging"), "disabled", True)
152
+
153
+ def boom() -> None:
154
+ raise RuntimeError("thread died")
155
+
156
+ thread = threading.Thread(target=boom, name="worker-1")
157
+ thread.start()
158
+ thread.join()
159
+ err = capsys.readouterr().err
160
+ assert "[CRITICAL] ls_logging: Uncaught exception in thread worker-1" in err
161
+
162
+
163
+ def test_thread_exception_printed_when_critical_disabled(
164
+ capsys: pytest.CaptureFixture[str],
165
+ ) -> None:
166
+ setup_logging("lm_test", "INFO")
167
+
168
+ def boom() -> None:
169
+ raise RuntimeError("thread died")
170
+
171
+ logging.disable(logging.CRITICAL)
172
+ try:
173
+ thread = threading.Thread(target=boom)
174
+ thread.start()
175
+ thread.join()
176
+ finally:
177
+ logging.disable(logging.NOTSET)
178
+ err = capsys.readouterr().err
179
+ assert "CRITICAL" not in err
180
+ assert "RuntimeError: thread died" in err
181
+
182
+
183
+ def test_thread_system_exit_is_silent(capsys: pytest.CaptureFixture[str]) -> None:
184
+ setup_logging("lm_test", "INFO")
185
+ thread = threading.Thread(target=sys.exit)
186
+ thread.start()
187
+ thread.join()
188
+ assert capsys.readouterr().err == ""
189
+
190
+
191
+ @pytest.mark.usefixtures("journal_env")
192
+ def test_journal_record_is_one_datagram_with_priority(syslog_socket: socket.socket) -> None:
193
+ setup_logging("lm_test", "INFO")
194
+ try:
195
+ _ = 1 / 0
196
+ except ZeroDivisionError:
197
+ logging.getLogger("svc.mod").exception("Ошибка датчика")
198
+ data = syslog_socket.recv(65536)
199
+ assert data.startswith("<11>lm_test: [ERROR] svc.mod: Ошибка датчика\n".encode("utf-8"))
200
+ assert b"Traceback" in data
201
+ assert b"ZeroDivisionError" in data
202
+
203
+
204
+ @pytest.mark.usefixtures("journal_env")
205
+ def test_journal_priorities(syslog_socket: socket.socket) -> None:
206
+ setup_logging("lm_test", "DEBUG")
207
+ log = logging.getLogger("t")
208
+ for emit in (log.critical, log.error, log.warning, log.info, log.debug):
209
+ emit("m")
210
+ prefixes = [syslog_socket.recv(65536).split(b">")[0] for _ in range(5)]
211
+ assert prefixes == [b"<10", b"<11", b"<12", b"<14", b"<15"]