pyflashgui 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.
- pyflashgui/__init__.py +6 -0
- pyflashgui/__main__.py +334 -0
- pyflashgui/backends/__init__.py +40 -0
- pyflashgui/backends/base.py +272 -0
- pyflashgui/backends/jlink_backend.py +596 -0
- pyflashgui/backends/pyocd_backend.py +271 -0
- pyflashgui/backends/simulated.py +240 -0
- pyflashgui/core/__init__.py +2 -0
- pyflashgui/core/checkout.py +134 -0
- pyflashgui/core/compare.py +157 -0
- pyflashgui/core/context.py +37 -0
- pyflashgui/core/events.py +71 -0
- pyflashgui/core/excepthook.py +110 -0
- pyflashgui/core/flasher.py +419 -0
- pyflashgui/core/images.py +279 -0
- pyflashgui/core/installation.py +137 -0
- pyflashgui/core/layout.py +72 -0
- pyflashgui/core/memory_map.py +234 -0
- pyflashgui/core/packages.py +104 -0
- pyflashgui/core/paths.py +49 -0
- pyflashgui/core/settings.py +44 -0
- pyflashgui/core/shortcut.py +154 -0
- pyflashgui/core/slow_start.py +101 -0
- pyflashgui/core/timing.py +312 -0
- pyflashgui/core/updates.py +195 -0
- pyflashgui/core/worker.py +71 -0
- pyflashgui/core/workspaces.py +250 -0
- pyflashgui/help/__init__.py +129 -0
- pyflashgui/help/about.md +21 -0
- pyflashgui/help/changelog.md +46 -0
- pyflashgui/help/files.md +60 -0
- pyflashgui/help/main-window.png +0 -0
- pyflashgui/help/manual.md +44 -0
- pyflashgui/help/memory-map.md +28 -0
- pyflashgui/help/memory.md +59 -0
- pyflashgui/help/probe.md +71 -0
- pyflashgui/resources/__init__.py +10 -0
- pyflashgui/resources/pyflashgui.ico +0 -0
- pyflashgui/resources/pyflashgui.png +0 -0
- pyflashgui/resources/pyflashgui.svg +32 -0
- pyflashgui/resources/pyflashgui_small.svg +20 -0
- pyflashgui/ui/__init__.py +2 -0
- pyflashgui/ui/confirm.py +312 -0
- pyflashgui/ui/detached.py +101 -0
- pyflashgui/ui/erase_dialog.py +141 -0
- pyflashgui/ui/event_colours.py +66 -0
- pyflashgui/ui/files_view.py +716 -0
- pyflashgui/ui/folders.py +176 -0
- pyflashgui/ui/fonts.py +75 -0
- pyflashgui/ui/help_menu.py +587 -0
- pyflashgui/ui/main_window.py +754 -0
- pyflashgui/ui/map_view.py +215 -0
- pyflashgui/ui/memory_view.py +724 -0
- pyflashgui/ui/messages.py +78 -0
- pyflashgui/ui/pane_bar.py +92 -0
- pyflashgui/ui/panes.py +1068 -0
- pyflashgui/ui/persist.py +132 -0
- pyflashgui/ui/probe_bar.py +315 -0
- pyflashgui/ui/read_dialog.py +161 -0
- pyflashgui/ui/session.py +53 -0
- pyflashgui/ui/theme.py +85 -0
- pyflashgui-0.1.0.dist-info/METADATA +80 -0
- pyflashgui-0.1.0.dist-info/RECORD +68 -0
- pyflashgui-0.1.0.dist-info/WHEEL +5 -0
- pyflashgui-0.1.0.dist-info/entry_points.txt +2 -0
- pyflashgui-0.1.0.dist-info/licenses/LICENSE +202 -0
- pyflashgui-0.1.0.dist-info/licenses/NOTICE +30 -0
- pyflashgui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""What was read from a target against what a file says should be there.
|
|
4
|
+
|
|
5
|
+
The same question Verify asks, the other way about: Verify reads what the
|
|
6
|
+
files cover and says whether it matches, and this takes what has already been
|
|
7
|
+
read -- any stretch of memory, from any time -- and says where a file agrees
|
|
8
|
+
with it and where it does not.
|
|
9
|
+
|
|
10
|
+
A file and a read rarely cover the same addresses. Only where they overlap is
|
|
11
|
+
there anything to compare, and what each has that the other does not is
|
|
12
|
+
counted and said rather than called a difference: flash the file does not
|
|
13
|
+
mention is not flash the file says is wrong.
|
|
14
|
+
|
|
15
|
+
Nothing here touches a probe or Qt.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from pyflashgui.core.images import Image, Segment
|
|
24
|
+
|
|
25
|
+
SAME, DIFFERENT = 0, 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Comparison:
|
|
30
|
+
"""Where a file and a read agree, byte by byte over the read."""
|
|
31
|
+
|
|
32
|
+
#: The file's name, for saying what was compared with.
|
|
33
|
+
name: str
|
|
34
|
+
#: Where the read starts.
|
|
35
|
+
address: int
|
|
36
|
+
#: A byte for each byte read: DIFFERENT where the file has another value
|
|
37
|
+
#: there, SAME where it has the same or says nothing.
|
|
38
|
+
differs: bytes
|
|
39
|
+
#: A byte for each byte read: 1 where the file has a value for it.
|
|
40
|
+
covered: bytes
|
|
41
|
+
#: The file's value for each byte read, where it has one.
|
|
42
|
+
file: bytes
|
|
43
|
+
#: Bytes the file has at addresses that were not read.
|
|
44
|
+
outside: int
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def compared(self) -> int:
|
|
48
|
+
"""Bytes that both have, which is all that could be compared."""
|
|
49
|
+
return self.covered.count(1)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def different(self) -> int:
|
|
53
|
+
return self.differs.count(DIFFERENT)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def unmentioned(self) -> int:
|
|
57
|
+
"""Bytes read that the file says nothing about."""
|
|
58
|
+
return len(self.covered) - self.compared
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def first(self) -> int | None:
|
|
62
|
+
"""The offset of the first byte that differs, or None when none does."""
|
|
63
|
+
at = self.differs.find(DIFFERENT)
|
|
64
|
+
return at if at >= 0 else None
|
|
65
|
+
|
|
66
|
+
def run_from(self, offset: int) -> int:
|
|
67
|
+
"""How many bytes in a row differ, starting at this one."""
|
|
68
|
+
end = self.differs.find(SAME, offset)
|
|
69
|
+
return (len(self.differs) if end < 0 else end) - offset
|
|
70
|
+
|
|
71
|
+
def next_run(self, after: int | None) -> int | None:
|
|
72
|
+
"""Where the next stretch of differing bytes starts, going round at the end.
|
|
73
|
+
|
|
74
|
+
``after`` is where the last one shown starts, or None for the first
|
|
75
|
+
of all. None back when nothing differs.
|
|
76
|
+
"""
|
|
77
|
+
if self.first is None:
|
|
78
|
+
return None
|
|
79
|
+
if after is None:
|
|
80
|
+
return self.first
|
|
81
|
+
end = self.differs.find(SAME, after)
|
|
82
|
+
at = -1 if end < 0 else self.differs.find(DIFFERENT, end)
|
|
83
|
+
return at if at >= 0 else self.first
|
|
84
|
+
|
|
85
|
+
def previous_run(self, before: int | None) -> int | None:
|
|
86
|
+
"""Where the stretch of differing bytes before this one starts, going round."""
|
|
87
|
+
if self.first is None:
|
|
88
|
+
return None
|
|
89
|
+
last = self.differs.rfind(DIFFERENT, 0, before) if before else -1
|
|
90
|
+
if last < 0:
|
|
91
|
+
last = self.differs.rfind(DIFFERENT)
|
|
92
|
+
# Back from its last byte to its first: the byte after the one
|
|
93
|
+
# before it that does not differ.
|
|
94
|
+
return self.differs.rfind(SAME, 0, last) + 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Any byte but zero becomes DIFFERENT: what two bytes exclusive-or to is
|
|
98
|
+
#: zero only where they are the same.
|
|
99
|
+
_ANY_BIT = bytes([SAME]) + bytes([DIFFERENT]) * 255
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unlike(one: bytes, other: bytes) -> bytes:
|
|
103
|
+
"""A byte for each pair: DIFFERENT where the two are not the same.
|
|
104
|
+
|
|
105
|
+
As two whole numbers, exclusive-ored: a megabyte compared a byte at a
|
|
106
|
+
time in Python is a second of a window that does not answer.
|
|
107
|
+
"""
|
|
108
|
+
mixed = int.from_bytes(one, "big") ^ int.from_bytes(other, "big")
|
|
109
|
+
return mixed.to_bytes(len(one), "big").translate(_ANY_BIT)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def compare(address: int, data: bytes, image: Image) -> Comparison:
|
|
113
|
+
"""Lay a file over what was read from ``address``, and mark where they differ."""
|
|
114
|
+
file = bytearray(len(data))
|
|
115
|
+
covered = bytearray(len(data))
|
|
116
|
+
differs = bytearray(len(data))
|
|
117
|
+
outside = 0
|
|
118
|
+
end = address + len(data)
|
|
119
|
+
for segment in image.segments:
|
|
120
|
+
start = max(segment.address, address)
|
|
121
|
+
stop = min(segment.end, end)
|
|
122
|
+
overlap = max(0, stop - start)
|
|
123
|
+
outside += len(segment) - overlap
|
|
124
|
+
if overlap:
|
|
125
|
+
at, within = start - address, start - segment.address
|
|
126
|
+
wanted = segment.data[within : within + overlap]
|
|
127
|
+
file[at : at + overlap] = wanted
|
|
128
|
+
covered[at : at + overlap] = b"\x01" * overlap
|
|
129
|
+
differs[at : at + overlap] = _unlike(wanted, data[at : at + overlap])
|
|
130
|
+
return Comparison(
|
|
131
|
+
name=image.name,
|
|
132
|
+
address=address,
|
|
133
|
+
differs=bytes(differs),
|
|
134
|
+
covered=bytes(covered),
|
|
135
|
+
file=bytes(file),
|
|
136
|
+
outside=outside,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compare_all(segments: Sequence[Segment], image: Image) -> Comparison:
|
|
141
|
+
"""Lay a file over several runs of memory, one after another.
|
|
142
|
+
|
|
143
|
+
For what is shown in pieces, as a file with gaps in it is. The marks
|
|
144
|
+
are for the pieces end to end, in the order given, and ``address`` is
|
|
145
|
+
where the first starts: where a later byte is, is for whoever holds the
|
|
146
|
+
pieces to say.
|
|
147
|
+
"""
|
|
148
|
+
made = [compare(segment.address, segment.data, image) for segment in segments]
|
|
149
|
+
covered = b"".join(one.covered for one in made)
|
|
150
|
+
return Comparison(
|
|
151
|
+
name=image.name,
|
|
152
|
+
address=segments[0].address if segments else 0,
|
|
153
|
+
differs=b"".join(one.differs for one in made),
|
|
154
|
+
covered=covered,
|
|
155
|
+
file=b"".join(one.file for one in made),
|
|
156
|
+
outside=image.size - covered.count(1),
|
|
157
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""What the panes are handed: where things live, and where to say things.
|
|
4
|
+
|
|
5
|
+
Keep this small and stable: everything a pane needs to remember something or
|
|
6
|
+
to tell the user something should be reachable from ``ctx``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pyflashgui.core import paths, timing, workspaces
|
|
14
|
+
from pyflashgui.core.events import EventLog
|
|
15
|
+
from pyflashgui.core.layout import Layout
|
|
16
|
+
from pyflashgui.core.settings import Settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Context:
|
|
20
|
+
def __init__(self, events: EventLog | None = None) -> None:
|
|
21
|
+
# Marked in three because the whole of it is folders being made and
|
|
22
|
+
# two files being read, which costs nothing on a local disc and a
|
|
23
|
+
# surprising amount on a profile that is redirected to a network
|
|
24
|
+
# share or watched by a scanner.
|
|
25
|
+
self.user_dir: Path = paths.user_dir()
|
|
26
|
+
timing.mark("user folder")
|
|
27
|
+
#: Which product is being worked on. Everything below belongs to it.
|
|
28
|
+
self.workspace: str = workspaces.active()
|
|
29
|
+
self.workspace_dir: Path = workspaces.active_dir()
|
|
30
|
+
timing.mark("workspace folders")
|
|
31
|
+
self.settings = Settings(workspaces.settings_path())
|
|
32
|
+
#: The dock arrangement, in the workspace folder rather than in
|
|
33
|
+
#: QSettings, so that the workspace is one thing that can be copied.
|
|
34
|
+
self.layout = Layout(workspaces.layout_path())
|
|
35
|
+
timing.mark("settings and layout files")
|
|
36
|
+
#: Everything said to the user goes through here, with a level.
|
|
37
|
+
self.events = events if events is not None else EventLog()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Everything the application has to say, and how much it matters.
|
|
4
|
+
|
|
5
|
+
Every line that reaches the Event Log comes through here, with a level. That
|
|
6
|
+
one change buys two things.
|
|
7
|
+
|
|
8
|
+
The first is that a failure cannot be lost. The Event Log is a pane like any
|
|
9
|
+
other and can be closed, and closing it must not mean "throw away anything
|
|
10
|
+
you were going to tell me" -- press Program with no probe connected, and the
|
|
11
|
+
button would simply do nothing. So a warning or an error opens the pane; a
|
|
12
|
+
plain note does not, so closing it still means what it should mean, which is
|
|
13
|
+
"stop chattering at me".
|
|
14
|
+
|
|
15
|
+
The second is that the appearance of a line is decided in one place rather
|
|
16
|
+
than at every call site, so how warnings and errors look is a change to the
|
|
17
|
+
sink and nowhere else.
|
|
18
|
+
|
|
19
|
+
Levels are strings rather than an enum on purpose: they are passed about as
|
|
20
|
+
plain text, and nothing has to be imported to name one.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from PySide6.QtCore import QObject, Signal
|
|
26
|
+
|
|
27
|
+
INFORMATION = "information"
|
|
28
|
+
WARNING = "warning"
|
|
29
|
+
ERROR = "error"
|
|
30
|
+
#: Something that was wrong is right again, or something worked: a board
|
|
31
|
+
#: verified, a probe came back. Not an escalation of anything -- it is the one
|
|
32
|
+
#: piece of news a log of failures otherwise never carries, and reading back
|
|
33
|
+
#: through red to find out whether a fault ever cleared is a poor way to
|
|
34
|
+
#: spend a morning.
|
|
35
|
+
GOOD = "good"
|
|
36
|
+
|
|
37
|
+
#: In the order they escalate, with GOOD off to the side. Anything
|
|
38
|
+
#: unrecognised is treated as information: a mistyped level must not be able
|
|
39
|
+
#: to make the pane spring open, nor to silently swallow the message.
|
|
40
|
+
LEVELS = (INFORMATION, GOOD, WARNING, ERROR)
|
|
41
|
+
|
|
42
|
+
#: The levels that mean something needs looking at.
|
|
43
|
+
PROBLEMS = (WARNING, ERROR)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EventLog(QObject):
|
|
47
|
+
"""The one way in. What listens is somebody else's business."""
|
|
48
|
+
|
|
49
|
+
posted = Signal(str, str) # message, level
|
|
50
|
+
|
|
51
|
+
def post(self, message: str, level: str = INFORMATION) -> None:
|
|
52
|
+
# Emitted rather than called: messages arrive from worker threads --
|
|
53
|
+
# a flash in progress, the exception hook -- and a Qt widget may
|
|
54
|
+
# only be touched from the GUI thread. A queued signal is the crossing.
|
|
55
|
+
self.posted.emit(str(message), level if level in LEVELS else INFORMATION)
|
|
56
|
+
|
|
57
|
+
def information(self, message: str) -> None:
|
|
58
|
+
"""Something happened. Worth recording, not worth interrupting for."""
|
|
59
|
+
self.post(message, INFORMATION)
|
|
60
|
+
|
|
61
|
+
def warning(self, message: str) -> None:
|
|
62
|
+
"""What you asked for did not happen, or did not happen as asked."""
|
|
63
|
+
self.post(message, WARNING)
|
|
64
|
+
|
|
65
|
+
def error(self, message: str) -> None:
|
|
66
|
+
"""Something went wrong that nobody asked for."""
|
|
67
|
+
self.post(message, ERROR)
|
|
68
|
+
|
|
69
|
+
def good(self, message: str) -> None:
|
|
70
|
+
"""Something that was wrong is right again."""
|
|
71
|
+
self.post(message, GOOD)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Put unhandled exceptions in the Event Log instead of nowhere.
|
|
4
|
+
|
|
5
|
+
pyflashgui is started with ``pythonw.exe``, which has no console. An exception
|
|
6
|
+
raised inside a Qt slot does not propagate anywhere useful: PySide prints the
|
|
7
|
+
traceback to a standard error stream that does not exist, the slot simply
|
|
8
|
+
does not finish, and the window carries on looking perfectly healthy.
|
|
9
|
+
|
|
10
|
+
A stale call to a widget method -- ``.text()`` on what had become a combo box
|
|
11
|
+
-- is enough: nothing is logged, no dialog appears, and whatever the slot was
|
|
12
|
+
doing is left half done.
|
|
13
|
+
|
|
14
|
+
So the hook routes tracebacks to the Event Log, where somebody can see them.
|
|
15
|
+
Delivery goes through a signal because an exception can be raised on any
|
|
16
|
+
thread and Qt widgets may only be touched from the GUI one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import traceback
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from PySide6.QtCore import QObject, Qt, QtMsgType, Signal, Slot, qInstallMessageHandler
|
|
27
|
+
|
|
28
|
+
from pyflashgui.core.events import ERROR, INFORMATION
|
|
29
|
+
|
|
30
|
+
#: Said of a Python traceback and of nothing else. Qt complains through the
|
|
31
|
+
#: same handler about things that are often not pyflashgui's doing at all -- a
|
|
32
|
+
#: platform plugin, a window manager -- and telling somebody those are bugs in
|
|
33
|
+
#: this application is both wrong and unhelpful.
|
|
34
|
+
BUG = "Unhandled error (this is a bug in pyflashgui; the action it interrupted did not finish):"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExceptionLogger(QObject):
|
|
38
|
+
"""Sends unhandled exceptions to a sink on the GUI thread."""
|
|
39
|
+
|
|
40
|
+
caught = Signal(str, str) # message, level
|
|
41
|
+
|
|
42
|
+
def __init__(self, sink: Callable[[str, str], None]) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self._sink = sink
|
|
45
|
+
self._previous = None
|
|
46
|
+
self._previous_thread = None
|
|
47
|
+
self._previous_qt = None
|
|
48
|
+
self._seen: set[str] = set()
|
|
49
|
+
# Queued: emitted from a worker thread, delivered on the GUI thread.
|
|
50
|
+
self.caught.connect(self._deliver, Qt.QueuedConnection)
|
|
51
|
+
|
|
52
|
+
def install(self) -> None:
|
|
53
|
+
self._previous = sys.excepthook
|
|
54
|
+
sys.excepthook = self._hook
|
|
55
|
+
# Worker threads have their own hook, and protocol work happens on
|
|
56
|
+
# them: an SDO read that dies must not die quietly either.
|
|
57
|
+
self._previous_thread = threading.excepthook
|
|
58
|
+
threading.excepthook = self._thread_hook
|
|
59
|
+
# Qt complains through its own channel, and those complaints are
|
|
60
|
+
# often the explanation -- a signal connected to nothing, a widget
|
|
61
|
+
# touched off the GUI thread.
|
|
62
|
+
self._previous_qt = qInstallMessageHandler(self._qt_hook)
|
|
63
|
+
|
|
64
|
+
def remove(self) -> None:
|
|
65
|
+
if self._previous is not None:
|
|
66
|
+
sys.excepthook = self._previous
|
|
67
|
+
self._previous = None
|
|
68
|
+
if self._previous_thread is not None:
|
|
69
|
+
threading.excepthook = self._previous_thread
|
|
70
|
+
self._previous_thread = None
|
|
71
|
+
qInstallMessageHandler(self._previous_qt)
|
|
72
|
+
self._previous_qt = None
|
|
73
|
+
|
|
74
|
+
def _thread_hook(self, args) -> None:
|
|
75
|
+
text = "".join(
|
|
76
|
+
traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
77
|
+
).rstrip()
|
|
78
|
+
name = getattr(args.thread, "name", "?")
|
|
79
|
+
self.caught.emit(f"{BUG}\non thread {name}:\n{text}", ERROR)
|
|
80
|
+
if self._previous_thread is not None:
|
|
81
|
+
self._previous_thread(args)
|
|
82
|
+
|
|
83
|
+
def _qt_hook(self, mode, context, message) -> None:
|
|
84
|
+
# Critical and fatal are worth interrupting for. A Qt warning is
|
|
85
|
+
# usually the explanation of something else rather than the something
|
|
86
|
+
# else -- "unable to set geometry", "this plugin does not support
|
|
87
|
+
# propagateSizeHints" -- so it is recorded and left there, or the
|
|
88
|
+
# Event Log would spring open at every start on some machines.
|
|
89
|
+
if mode in (QtMsgType.QtCriticalMsg, QtMsgType.QtFatalMsg):
|
|
90
|
+
self.caught.emit(f"Qt: {message}", ERROR)
|
|
91
|
+
elif mode == QtMsgType.QtWarningMsg:
|
|
92
|
+
self.caught.emit(f"Qt: {message}", INFORMATION)
|
|
93
|
+
if self._previous_qt is not None:
|
|
94
|
+
self._previous_qt(mode, context, message)
|
|
95
|
+
|
|
96
|
+
def _hook(self, exc_type, exc, tb) -> None:
|
|
97
|
+
text = "".join(traceback.format_exception(exc_type, exc, tb)).rstrip()
|
|
98
|
+
self.caught.emit(f"{BUG}\n{text}", ERROR)
|
|
99
|
+
if self._previous is not None:
|
|
100
|
+
self._previous(exc_type, exc, tb) # keep stderr working when there is one
|
|
101
|
+
|
|
102
|
+
@Slot(str, str)
|
|
103
|
+
def _deliver(self, text: str, level: str) -> None:
|
|
104
|
+
# A fault inside a repeating timer would otherwise fill the log with
|
|
105
|
+
# the same traceback fifty times a second.
|
|
106
|
+
key = text.rsplit("\n", 1)[-1]
|
|
107
|
+
if key in self._seen:
|
|
108
|
+
return
|
|
109
|
+
self._seen.add(key)
|
|
110
|
+
self._sink(text, level)
|