ls-logging 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
ls_logging/_django.py ADDED
@@ -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
+ }
ls_logging/_output.py ADDED
@@ -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
ls_logging/_setup.py ADDED
@@ -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
ls_logging/py.typed ADDED
File without changes
@@ -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,9 @@
1
+ ls_logging/__init__.py,sha256=mYyk4TFP0CPHhXeZ1QLhmnHH61gRVh2prXZrNCUyRgU,189
2
+ ls_logging/_django.py,sha256=smZyaiaPg_8Sz4EjciZslZGGiAcfx0GCwsPN96qWewI,2068
3
+ ls_logging/_output.py,sha256=67TPeCtryIMmyYwcZsdKcxcBIcMJl6X1P-ZSDB8Jghg,2108
4
+ ls_logging/_setup.py,sha256=nTcqdwh5U56X55an-YMhJy65XpS0t4piKhXX9kamj2c,5236
5
+ ls_logging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ ls_logging-0.1.0.dist-info/METADATA,sha256=bt1NMHfqnX3dqucXyO2puZbiofxD_OHOL1mjqtLNp9A,344
7
+ ls_logging-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ ls_logging-0.1.0.dist-info/licenses/LICENSE,sha256=pAyjtZTnBzDV8HMMaz3QbwL82-pfO39qH_3nreod1XY,1070
9
+ ls_logging-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.