Passed
Push — main ( 2b775d...83a9fb )
by Douglas
04:59 queued 02:43
created

MandosLogging.get_log_suffix()   A

Complexity

Conditions 2

Size

Total Lines 15
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 12
nop 2
dl 0
loc 15
rs 9.8
c 0
b 0
f 0
1
import logging
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
import sys
3
from pathlib import Path
4
from typing import Optional, Union
5
6
import regex
0 ignored issues
show
introduced by
Unable to import 'regex'
Loading history...
7
from loguru import logger
0 ignored issues
show
introduced by
Unable to import 'loguru'
Loading history...
8
9
from loguru._logger import Logger
0 ignored issues
show
introduced by
Unable to import 'loguru._logger'
Loading history...
10
from pocketutils.core.exceptions import BadCommandError, XValueError
0 ignored issues
show
Unused Code introduced by
Unused BadCommandError imported from pocketutils.core.exceptions
Loading history...
introduced by
Unable to import 'pocketutils.core.exceptions'
Loading history...
11
from typeddfs import FileFormat
0 ignored issues
show
introduced by
Unable to import 'typeddfs'
Loading history...
Unused Code introduced by
Unused FileFormat imported from typeddfs
Loading history...
12
from typeddfs.file_formats import CompressionFormat
0 ignored issues
show
introduced by
Unable to import 'typeddfs.file_formats'
Loading history...
13
14
_LOGGER_ARG_PATTERN = regex.compile(r"(?:([a-zA-Z]+):)?(.*)", flags=regex.V1)
15
16
17
class InterceptHandler(logging.Handler):
18
    """
19
    Redirects standard logging to loguru.
20
    """
21
22
    def emit(self, record):
23
        # Get corresponding Loguru level if it exists
24
        try:
25
            level = logger.level(record.levelname).name
26
        except ValueError:
27
            level = record.levelno
28
        # Find caller from where originated the logged message
29
        frame, depth = logging.currentframe(), 2
30
        while frame.f_code.co_filename == logging.__file__:
31
            frame = frame.f_back
32
            depth += 1
33
        logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
34
35
36
def _notice(__message: str, *args, **kwargs):
37
    return logger.log("NOTICE", __message, *args, **kwargs)
38
39
40
def _caution(__message: str, *args, **kwargs):
41
    return logger.log("CAUTION", __message, *args, **kwargs)
42
43
44
class MyLogger(Logger):
45
    """
46
    A wrapper that has a fake notice() method to trick static analysis.
47
    """
48
49
    def notice(self, __message: str, *args, **kwargs):
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
50
        raise NotImplementedError()  # not real
51
52
    def caution(self, __message: str, *args, **kwargs):
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
53
        raise NotImplementedError()  # not real
54
55
56
class MandosLogging:
0 ignored issues
show
introduced by
Missing class docstring
Loading history...
57
    # this is required for mandos to run
58
59
    DEFAULT_LEVEL: str = "NOTICE"
60
61
    @classmethod
62
    def init(cls) -> None:
63
        """
64
        Sets an initial configuration.
65
        """
66
        # we warn the user about this in the docs!
67
        sys.stderr.reconfigure(encoding="utf-8")
68
        sys.stdout.reconfigure(encoding="utf-8")
69
        sys.stdin.reconfigure(encoding="utf-8")
70
        cls._init()
71
        cls.redirect_std_logging()
72
        cls.set_main_level(MandosLogging.DEFAULT_LEVEL)
73
74
    @classmethod
75
    def _init(cls) -> None:
76
        try:
77
            logger.level("NOTICE", no=35)
78
        except TypeError:
79
            # this happens if it's already been added (e.g. from an outside library)
80
            # if we don't have it set after this, we'll find out soon enough
81
            logger.debug("Could not add 'NOTICE' loguru level. Did you already set it?")
82
        try:
83
            logger.level("CAUTION", no=25)
84
        except TypeError:
85
            logger.debug("Could not add 'CAUTION' loguru level. Did you already set it?")
86
        logger.notice = _notice
87
        logger.caution = _caution
88
89
    @classmethod
90
    def redirect_std_logging(cls, level: int = 10) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
91
        # 10 b/c we're really never going to want trace output
92
        logging.basicConfig(handlers=[InterceptHandler()], level=level, encoding="utf-8")
93
94
    @classmethod
95
    def set_main_level(cls, level: str) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
96
        logger.remove()
97
        logger.add(sys.stderr, level=level)
98
99
    @classmethod
100
    def disable_main(cls) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
101
        logger.remove()
102
103
    @classmethod
104
    def add_path_logger(cls, path: Path, level: str) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
105
        cls.get_log_suffix(path)
106
        compressions = [
107
            ".xz",
108
            ".lzma",
109
            ".gz",
110
            ".zip",
111
            ".bz2",
112
            ".tar",
113
            ".tar.gz",
114
            ".tar.bz2",
115
            ".tar.xz",
116
        ]
117
        compressions = {c: c.lstrip(".") for c in compressions}
118
        serialize = path.with_suffix("").name.endswith(".json")
119
        compression = compressions.get(path.suffix)
120
        logger.add(
121
            str(path),
122
            level=level,
123
            compression=compression,
124
            serialize=serialize,
125
            backtrace=True,
126
            diagnose=True,
127
            enqueue=True,
128
            encoding="utf-8",
129
        )
130
131
    @classmethod
132
    def get_log_suffix(cls, path: Path) -> str:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
133
        valid_log_suffixes = {
134
            *{f".log{c.suffix}" for c in CompressionFormat.list()},
135
            *{f".txt{c.suffix}" for c in CompressionFormat.list()},
136
            *{f".json{c.suffix}" for c in CompressionFormat.list()},
137
        }
138
        # there's no overlap, right? pretty sure
139
        matches = {s for s in valid_log_suffixes if path.name.endswith(s)}
140
        if len(matches) == 0:
141
            raise XValueError(
142
                f"{path} is not a valid logging path; use one of {', '.join(valid_log_suffixes)}"
143
            )
144
        suffix = next(iter(matches))
145
        return suffix
146
147
148
class MandosSetup:
0 ignored issues
show
introduced by
Missing class docstring
Loading history...
149
150
    LEVELS = ["off", "error", "notice", "warning", "caution", "info", "debug"]
151
    ALIASES = dict(none="off", no="off", verbose="info", quiet="error")
152
153
    def __call__(
154
        self,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
155
        log: Union[None, str, Path] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
156
        level: Optional[str] = MandosLogging.DEFAULT_LEVEL,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
157
        skip: bool = False,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
158
    ) -> None:
159
        """
160
        This function controls all aspects of the logging as set via command-line.
161
162
        Args:
163
            level: The level for stderr
164
            log: If set, the path to a file. Can be prefixed with ``:level:`` to set the level
165
                  (e.g. ``:INFO:mandos-run.log.gz``). Can serialize to JSON if .json is used
166
                  instead of .log or .txt.
167
        """
168
        if skip:
169
            return
170
        if level is None or len(str(level)) == 0:
171
            level = MandosLogging.DEFAULT_LEVEL
172
        level = MandosSetup.ALIASES.get(level.lower(), level).upper()
173
        if level.lower() not in MandosSetup.LEVELS:
174
            _permitted = ", ".join([*MandosSetup.LEVELS, *MandosSetup.ALIASES.keys()])
175
            raise XValueError(f"{level.lower()} not a permitted log level (allowed: {_permitted}")
176
        if level == "OFF":
177
            MandosLogging.disable_main()
178
        else:
179
            MandosLogging.set_main_level(level)
180
        if log is not None or len(str(log)) == 0:
181
            match = _LOGGER_ARG_PATTERN.match(str(log))
182
            path_level = "DEBUG" if match.group(1) is None else match.group(1)
183
            path = Path(match.group(2))
184
            MandosLogging.add_path_logger(path, path_level)
185
            logger.info(f"Added logger to {path} at level {path_level}")
186
        logger.info(f"Set log level to {level}")
187
188
189
# weird as hell, but it works
190
# noinspection PyTypeChecker
191
logger: MyLogger = logger
192
MandosLogging.init()
193
194
195
MANDOS_SETUP = MandosSetup()
196