Passed
Push — main ( b312d0...29adcf )
by Douglas
01:37
created

callback()   B

Complexity

Conditions 6

Size

Total Lines 15
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 14
nop 4
dl 0
loc 15
rs 8.6666
c 0
b 0
f 0
1
import contextlib
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
import logging
3
import os
4
import subprocess  # nosec
5
import textwrap
6
import warnings
7
from copy import copy
8
from enum import Enum
0 ignored issues
show
Unused Code introduced by
Unused Enum imported from enum
Loading history...
9
from pathlib import PurePath
10
from queue import Queue
11
from threading import Thread
12
from typing import Callable, Generator, Optional, Sequence
13
14
from pocketutils.core.input_output import DevNull
15
from pocketutils.tools.base_tools import BaseTools
16
17
logger = logging.getLogger("pocketutils")
18
19
20
@contextlib.contextmanager
21
def null_context(cls):
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
Unused Code introduced by
The argument cls seems to be unused.
Loading history...
22
    yield
23
24
25
class CallTools(BaseTools):
0 ignored issues
show
introduced by
Missing class docstring
Loading history...
26
    @classmethod
27
    @contextlib.contextmanager
28
    def silenced(
29
        cls, no_stdout: bool = True, no_stderr: bool = True
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
30
    ) -> Generator[None, None, None]:
31
        """
32
        Context manager that suppresses stdout and stderr.
33
        """
34
        # noinspection PyTypeChecker
35
        with contextlib.redirect_stdout(DevNull()) if no_stdout else cls.null_context():
36
            # noinspection PyTypeChecker
37
            with contextlib.redirect_stderr(DevNull()) if no_stderr else cls.null_context():
38
                yield
39
40
    @classmethod
41
    def call_cmd(cls, *cmd: str, **kwargs) -> subprocess.CompletedProcess:
42
        """
43
        Calls subprocess.run with capture_output=True.
44
        Logs a debug statement with the command beforehand.
45
            cmd: A sequence to call
46
            kwargs: Passed to subprocess.run
47
        """
48
        warnings.warn("call_cmd will be removed; use subprocess.check_output instead")
49
        logger.debug("Calling '{}'".format(" ".join(cmd)))
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
50
        return subprocess.run(*[str(c) for c in cmd], capture_output=True, check=True, **kwargs)
51
52
    @classmethod
53
    def call_cmd_utf(
54
        cls, *cmd: str, log: logging.Logger = logger, **kwargs
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
55
    ) -> subprocess.CompletedProcess:
56
        """
57
        Like ``call_cmd`` for utf-8 only.
58
        Set ``text=True`` and ``encoding=utf8``,
59
        and strips stdout and stderr of start/end whitespace before returning.
60
        Can also log formatted stdout and stderr on failure.
61
        Otherwise, logs the output, unformatted and unstripped, as DEBUG
62
63
        See Also:
64
            ``subprocess.check_output``, which only returns stdout
65
        """
66
        log.debug("Calling '{}'".format(" ".join(cmd)))
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
67
        kwargs = copy(kwargs)
68
        if "cwd" in kwargs and isinstance(kwargs["path"], PurePath):
69
            kwargs["cwd"] = str(kwargs["cwd"])
70
        try:
71
            x = subprocess.run(
0 ignored issues
show
Coding Style Naming introduced by
Variable name "x" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
72
                *[str(c) for c in cmd],
73
                capture_output=True,
74
                check=True,
75
                text=True,
76
                encoding="utf8",
77
                **kwargs,
78
            )
79
            log.debug(f"stdout: '{x.stdout}'")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
80
            log.debug(f"stderr: '{x.stderr}'")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
81
            x.stdout = x.stdout.strip()
82
            x.stderr = x.stderr.strip()
83
            return x
84
        except subprocess.CalledProcessError as e:
0 ignored issues
show
Coding Style Naming introduced by
Variable name "e" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
85
            cls.log_called_process_error(e, log_fn=log.error)
86
            raise
87
88
    @classmethod
89
    def log_called_process_error(
0 ignored issues
show
Coding Style Naming introduced by
Argument name "e" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
90
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
91
        e: subprocess.CalledProcessError,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
92
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
93
        log_fn: Callable[[str], None],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
94
    ) -> None:
95
        """
96
        Outputs some formatted text describing the error with its full stdout and stderr.
97
98
        Args:
99
            e: The error
100
            log_fn: For example, ``logger.warning``
101
        """
102
        log_fn(f'Failed on command: {" ".join(e.cmd)}')
103
        out = None
104
        if e.stdout is not None:
105
            out = e.stdout.decode(encoding="utf8") if isinstance(e.stdout, bytes) else e.stdout
106
        log_fn("《no stdout》" if out is None else f"stdout: {out}")
107
        err = None
108
        if e.stderr is not None:
109
            err = e.stderr.decode(encoding="utf8") if isinstance(e.stderr, bytes) else e.stderr
110
        log_fn("《no stderr》" if err is None else f"stderr: {err}")
111
112
    @classmethod
113
    def stream_cmd_call(
114
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
115
        cmd: Sequence[str],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
116
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
117
        callback: Callable[[bool, bytes], None] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
118
        timeout_secs: Optional[float] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
119
        **kwargs,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
120
    ) -> None:
121
        """
122
        Processes stdout and stderr on separate threads.
123
        Streamed -- can avoid filling a stdout or stderr buffer.
124
        Calls an external command, waits, and throws a
125
        ExternalCommandFailed for nonzero exit codes.
126
127
        Args:
128
            cmd: The command args
129
            callback: A function that processes (is_stderr, piped line).
130
                      If ``None``, uses :meth:`smart_log`.
131
            timeout_secs: Max seconds to wait for the next line
132
            kwargs: Passed to ``subprocess.Popen``; do not pass ``stdout`` or ``stderr``.
133
134
        Raises:
135
            CalledProcessError: If the exit code is nonzero
136
        """
137
        if callback is None:
138
            callback = cls.smart_log
139
        cmd = [str(p) for p in cmd]
140
        logger.debug("Streaming '{}'".format(" ".join(cmd)))
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
141
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "p" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
142
        try:
143
            q = Queue()
0 ignored issues
show
Coding Style Naming introduced by
Variable name "q" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
144
            Thread(target=cls._reader, args=[False, p.stdout, q]).start()
145
            Thread(target=cls._reader, args=[True, p.stderr, q]).start()
146
            for _ in range(2):
147
                for source, line in iter(q.get, None):
148
                    callback(source, line)
149
            exit_code = p.wait(timeout=timeout_secs)
150
        finally:
151
            p.kill()
152
        if exit_code != 0:
153
            raise subprocess.CalledProcessError(
154
                exit_code, " ".join(cmd), "<<unknown>>", "<<unknown>>"
155
            )
156
157
    @classmethod
158
    def smart_log(
159
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
160
        is_stderr: bool,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
161
        line: bytes,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
162
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
163
        prefix: str = "",
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
164
        log: logging.Logger = logger,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
165
    ) -> None:
166
        """
167
        Maps (is_stderr, piped line) pairs to logging statements.
168
        The data must be utf8-encoded.
169
        If the line starts with ``warning:`` (case-insensitive), uses ``log.warning``.
170
        The same is true for any other method attached to ``log``.
171
        Falls back to DEBUG if no valid prefix is found.
172
        This is useful if you wrote an external application (e.g. in C)
173
        and want those logging statements mapped into your calling Python code.
174
        """
175
        line = line.decode("utf-8")
176
        try:
177
            fn = getattr(log, line.split(":")[0].lower())
0 ignored issues
show
Coding Style Naming introduced by
Variable name "fn" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
178
        except AttributeError:
179
            fn = log.debug
0 ignored issues
show
Coding Style Naming introduced by
Variable name "fn" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
180
        source = "stderr" if is_stderr else "stdout"
181
        fn(f"{fn.__name__.upper()} [{source}]: {line}")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
182
        if line.lower().startswith("FATAL:"):
183
            logger.fatal(prefix + line)
184
        elif line.startswith("ERROR:"):
185
            logger.error(prefix + line)
186
        elif line.startswith("WARNING:"):
187
            logger.warning(prefix + line)
188
        elif line.startswith("INFO:"):
189
            logger.info(prefix + line)
190
        elif line.startswith("DEBUG:"):
191
            logger.debug(prefix + line)
192
        else:
193
            logger.debug(prefix + line)
194
195
    @classmethod
196
    def _disp(cls, out, ell, name):
197
        out = out.strip()
198
        if "\n" in out:
199
            ell(name + ":\n<<=====\n" + out + "\n=====>>")
200
        elif len(out) > 0:
201
            ell(name + ": <<===== " + out + " =====>>")
202
        else:
203
            ell(name + ": <no output>")
204
205
    @classmethod
206
    def _log(cls, out, err, ell):
207
        cls._disp(out, ell, "stdout")
208
        cls._disp(err, ell, "stderr")
209
210
    @classmethod
211
    def _reader(cls, pipe_type, pipe, queue):
212
        try:
213
            with pipe:
214
                for line in iter(pipe.readline, b""):
215
                    queue.put((pipe_type, line))
216
        finally:
217
            queue.put(None)
218
219
    @classmethod
220
    def _wrap(cls, s: str, ell: int) -> str:
0 ignored issues
show
Coding Style Naming introduced by
Argument name "s" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
221
        wrapped = textwrap.wrap(s.strip(), ell - 4)
222
        return textwrap.indent(os.linesep.join(wrapped), "    ")
223
224
225
__all__ = ["CallTools"]
226