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.
- ls_logging-0.1.0/.gitignore +14 -0
- ls_logging-0.1.0/LICENSE +21 -0
- ls_logging-0.1.0/PKG-INFO +12 -0
- ls_logging-0.1.0/examples/journal_check.py +44 -0
- ls_logging-0.1.0/ls_logging/__init__.py +6 -0
- ls_logging-0.1.0/ls_logging/_django.py +45 -0
- ls_logging-0.1.0/ls_logging/_output.py +52 -0
- ls_logging-0.1.0/ls_logging/_setup.py +109 -0
- ls_logging-0.1.0/ls_logging/py.typed +0 -0
- ls_logging-0.1.0/pyproject.toml +80 -0
- ls_logging-0.1.0/tests/conftest.py +65 -0
- ls_logging-0.1.0/tests/test_django.py +51 -0
- ls_logging-0.1.0/tests/test_import.py +6 -0
- ls_logging-0.1.0/tests/test_output.py +67 -0
- ls_logging-0.1.0/tests/test_setup.py +211 -0
ls_logging-0.1.0/LICENSE
ADDED
|
@@ -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,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,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"]
|