summaryrefslogtreecommitdiff
path: root/qutebrowser/utils/debug.py
blob: 0fa74f4e7d4b21cc5b1862c284b771bcc1f7a1dd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.

"""Utilities used for debugging."""

import re
import inspect
import logging
import functools
import datetime
import types
from typing import (
    Any, Callable, List, Mapping, MutableSequence, Optional, Sequence, Type, Union)

from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject, pyqtBoundSignal

from qutebrowser.utils import log, utils, qtutils, objreg
from qutebrowser.misc import objects
from qutebrowser.qt import sip


def log_events(klass: Type[QObject]) -> Type[QObject]:
    """Class decorator to log Qt events."""
    old_event = klass.event

    @functools.wraps(old_event)
    def new_event(self: Any, e: QEvent) -> bool:
        """Wrapper for event() which logs events."""
        log.misc.debug("Event in {}: {}".format(utils.qualname(klass),
                                                qenum_key(QEvent, e.type())))
        return old_event(self, e)

    klass.event = new_event  # type: ignore[assignment]
    return klass


def log_signals(obj: QObject) -> QObject:
    """Log all signals of an object or class.

    Can be used as class decorator.
    """
    def log_slot(obj: QObject, signal: pyqtBoundSignal, *args: Any) -> None:
        """Slot connected to a signal to log it."""
        dbg = dbg_signal(signal, args)
        try:
            r = repr(obj)
        except RuntimeError:  # pragma: no cover
            r = '<deleted>'
        log.signals.debug("Signal in {}: {}".format(r, dbg))

    def connect_log_slot(obj: QObject) -> None:
        """Helper function to connect all signals to a logging slot."""
        metaobj = obj.metaObject()
        for i in range(metaobj.methodCount()):
            meta_method = metaobj.method(i)
            qtutils.ensure_valid(meta_method)
            if meta_method.methodType() == QMetaMethod.Signal:
                name = meta_method.name().data().decode('ascii')
                if name != 'destroyed':
                    signal = getattr(obj, name)
                    try:
                        signal.connect(functools.partial(
                            log_slot, obj, signal))
                    except TypeError:  # pragma: no cover
                        pass

    if inspect.isclass(obj):
        old_init = obj.__init__

        @functools.wraps(old_init)
        def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
            """Wrapper for __init__() which logs signals."""
            old_init(self, *args, **kwargs)
            connect_log_slot(self)

        obj.__init__ = new_init
    else:
        connect_log_slot(obj)

    return obj


_EnumValueType = Union[sip.simplewrapper, int]


def qenum_key(base: Type[_EnumValueType],
              value: _EnumValueType,
              add_base: bool = False,
              klass: Type[_EnumValueType] = None) -> str:
    """Convert a Qt Enum value to its key as a string.

    Args:
        base: The object the enum is in, e.g. QFrame.
        value: The value to get.
        add_base: Whether the base should be added to the printed name.
        klass: The enum class the value belongs to.
               If None, the class will be auto-guessed.

    Return:
        The key associated with the value as a string if it could be found.
        The original value as a string if not.
    """
    if klass is None:
        klass = value.__class__
        if klass == int:
            raise TypeError("Can't guess enum class of an int!")

    try:
        meta_obj = base.staticMetaObject  # type: ignore[union-attr]
        idx = meta_obj.indexOfEnumerator(klass.__name__)
        meta_enum = meta_obj.enumerator(idx)
        ret = meta_enum.valueToKey(int(value))  # type: ignore[arg-type]
    except AttributeError:
        ret = None

    if ret is None:
        for name, obj in vars(base).items():
            if isinstance(obj, klass) and obj == value:
                ret = name
                break
        else:
            ret = '0x{:04x}'.format(int(value))  # type: ignore[arg-type]

    if add_base and hasattr(base, '__name__'):
        return '.'.join([base.__name__, ret])
    else:
        return ret


def qflags_key(base: Type[_EnumValueType],
               value: _EnumValueType,
               add_base: bool = False,
               klass: Type[_EnumValueType] = None) -> str:
    """Convert a Qt QFlags value to its keys as string.

    Note: Passing a combined value (such as Qt.AlignCenter) will get the names
    for the individual bits (e.g. Qt.AlignVCenter | Qt.AlignHCenter). FIXME

    https://github.com/qutebrowser/qutebrowser/issues/42

    Args:
        base: The object the flags are in, e.g. QtCore.Qt
        value: The value to get.
        add_base: Whether the base should be added to the printed names.
        klass: The flags class the value belongs to.
               If None, the class will be auto-guessed.

    Return:
        The keys associated with the flags as a '|' separated string if they
        could be found. Hex values as a string if not.
    """
    if klass is None:
        # We have to store klass here because it will be lost when iterating
        # over the bits.
        klass = value.__class__
        if klass == int:
            raise TypeError("Can't guess enum class of an int!")

    if not value:
        return qenum_key(base, value, add_base, klass)

    bits = []
    names = []
    mask = 0x01
    value = int(value)  # type: ignore[arg-type]
    while mask <= value:
        if value & mask:
            bits.append(mask)
        mask <<= 1
    for bit in bits:
        # We have to re-convert to an enum type here or we'll sometimes get an
        # empty string back.
        enum_value = klass(bit)  # type: ignore[call-arg]
        names.append(qenum_key(base, enum_value, add_base))
    return '|'.join(names)


def signal_name(sig: pyqtBoundSignal) -> str:
    """Get a cleaned up name of a signal.

    Unfortunately, the way to get the name of a signal differs based on
    bound vs. unbound signals.

    Here, we try to get the name from .signal or .signatures, or if all else
    fails, extract it from the repr().

    Args:
        sig: A bound signal.

    Return:
        The cleaned up signal name.
    """
    if hasattr(sig, 'signal'):
        # Bound signal
        # Examples:
        # sig.signal == '2signal1'
        # sig.signal == '2signal2(QString,QString)'
        m = re.fullmatch(r'[0-9]+(?P<name>.*)\(.*\)', sig.signal)
    else:
        # Unbound signal, PyQt >= 5.11
        # Examples:
        # sig.signatures == ('signal1()',)
        # sig.signatures == ('signal2(QString,QString)',)
        m = re.fullmatch(r'(?P<name>.*)\(.*\)',
                         sig.signatures[0])  # type: ignore[attr-defined]

    assert m is not None, sig
    return m.group('name')


def format_args(args: Sequence[Any] = None, kwargs: Mapping[str, Any] = None) -> str:
    """Format a list of arguments/kwargs to a function-call like string."""
    if args is not None:
        arglist = [utils.compact_text(repr(arg), 200) for arg in args]
    else:
        arglist = []
    if kwargs is not None:
        for k, v in kwargs.items():
            arglist.append('{}={}'.format(k, utils.compact_text(repr(v), 200)))
    return ', '.join(arglist)


def dbg_signal(sig: pyqtBoundSignal, args: Any) -> str:
    """Get a string representation of a signal for debugging.

    Args:
        sig: A bound signal.
        args: The arguments as list of strings.

    Return:
        A human-readable string representation of signal/args.
    """
    return '{}({})'.format(signal_name(sig), format_args(args))


def format_call(func: Callable[..., Any],
                args: Sequence[Any] = None,
                kwargs: Mapping[str, Any] = None,
                full: bool = True) -> str:
    """Get a string representation of a function calls with the given args.

    Args:
        func: The callable to print.
        args: A list of positional arguments.
        kwargs: A dict of named arguments.
        full: Whether to print the full name

    Return:
        A string with the function call.
    """
    if full:
        name = utils.qualname(func)
    else:
        name = func.__name__
    return '{}({})'.format(name, format_args(args, kwargs))


class log_time:  # noqa: N801,N806 pylint: disable=invalid-name

    """Log the time an operation takes.

    Usable as context manager or as decorator.
    """

    def __init__(self, logger: Union[logging.Logger, str],
                 action: str = 'operation') -> None:
        """Constructor.

        Args:
            logger: The logging.Logger to use for logging, or a logger name.
            action: A description of what's being done.
        """
        if isinstance(logger, str):
            self._logger = logging.getLogger(logger)
        else:
            self._logger = logger
        self._started: Optional[datetime.datetime] = None
        self._action = action

    def __enter__(self) -> None:
        self._started = datetime.datetime.now()

    def __exit__(self,
                 _exc_type: Optional[Type[BaseException]],
                 _exc_val: Optional[BaseException],
                 _exc_tb: Optional[types.TracebackType]) -> None:
        assert self._started is not None
        finished = datetime.datetime.now()
        delta = (finished - self._started).total_seconds()
        self._logger.debug("{} took {} seconds.".format(
            self._action.capitalize(), delta))

    def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
        @functools.wraps(func)
        def wrapped(*args: Any, **kwargs: Any) -> Any:
            """Call the original function."""
            with self:
                return func(*args, **kwargs)

        return wrapped


def _get_widgets() -> Sequence[str]:
    """Get a string list of all widgets."""
    widgets = objects.qapp.allWidgets()
    widgets.sort(key=repr)
    return [repr(w) for w in widgets]


def _get_pyqt_objects(lines: MutableSequence[str],
                      obj: QObject,
                      depth: int = 0) -> None:
    """Recursive method for get_all_objects to get Qt objects."""
    for kid in obj.findChildren(QObject, '', Qt.FindDirectChildrenOnly):
        lines.append('    ' * depth + repr(kid))
        _get_pyqt_objects(lines, kid, depth + 1)


def get_all_objects(start_obj: QObject = None) -> str:
    """Get all children of an object recursively as a string."""
    output = ['']
    widget_lines = _get_widgets()
    widget_lines = ['    ' + e for e in widget_lines]
    widget_lines.insert(0, "Qt widgets - {} objects:".format(
        len(widget_lines)))
    output += widget_lines

    if start_obj is None:
        start_obj = objects.qapp

    pyqt_lines: List[str] = []
    _get_pyqt_objects(pyqt_lines, start_obj)
    pyqt_lines = ['    ' + e for e in pyqt_lines]
    pyqt_lines.insert(0, 'Qt objects - {} objects:'.format(len(pyqt_lines)))

    output += ['']
    output += pyqt_lines
    output += objreg.dump_objects()
    return '\n'.join(output)