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,154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""A Start menu or applications menu entry for a source folder.
|
|
4
|
+
|
|
5
|
+
The installer makes its own. A clone has only the launcher, and the entry made
|
|
6
|
+
here points at that rather than at the Python in ``.venv``: the launcher is
|
|
7
|
+
what notices a dependency added since the last pull, so starting from the menu
|
|
8
|
+
picks up an update exactly as double-clicking the launcher does.
|
|
9
|
+
|
|
10
|
+
Offered by the launcher at the end of its first-run setup, which runs
|
|
11
|
+
``python -m pyflashgui.core.shortcut``, and at any time from Tools > Settings.
|
|
12
|
+
Standard library only, since the launcher calls it before anything else of
|
|
13
|
+
pyflashgui's has been started.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
#: Plain, since it starts whatever was last pulled. The installer's entries
|
|
24
|
+
#: carry the version they install, which is how the two are told apart.
|
|
25
|
+
NAME = "pyflashgui"
|
|
26
|
+
#: What each system calls the menu, for the Tools entry and the messages.
|
|
27
|
+
MENU = {"win32": "Start menu", "linux": "applications menu"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def root() -> Path:
|
|
31
|
+
"""The folder the pyflashgui package sits in: the clone, for a source folder."""
|
|
32
|
+
return Path(__file__).resolve().parent.parent.parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def launcher(platform: str | None = None) -> Path | None:
|
|
36
|
+
"""The launcher to point the entry at, or None where there is no entry to make.
|
|
37
|
+
|
|
38
|
+
None for a frozen build, which the installer looks after, for a pip
|
|
39
|
+
installation, which has no launcher beside it, and on macOS, whose menu
|
|
40
|
+
is not made of files like these.
|
|
41
|
+
"""
|
|
42
|
+
platform = sys.platform if platform is None else platform
|
|
43
|
+
if getattr(sys, "frozen", False) or platform not in MENU:
|
|
44
|
+
return None
|
|
45
|
+
script = root() / ("pyflashgui.cmd" if platform == "win32" else "pyflashgui.sh")
|
|
46
|
+
return script if script.is_file() else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def menu_name(platform: str | None = None) -> str:
|
|
50
|
+
return MENU.get(sys.platform if platform is None else platform, "menu")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def entry_path(platform: str | None = None, environ=None) -> Path:
|
|
54
|
+
"""Where the entry goes: the user's own menu, so no administrator is asked."""
|
|
55
|
+
platform = sys.platform if platform is None else platform
|
|
56
|
+
environ = os.environ if environ is None else environ
|
|
57
|
+
if platform == "win32":
|
|
58
|
+
programs = Path(environ["APPDATA"]) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
|
59
|
+
return programs / f"{NAME}.lnk"
|
|
60
|
+
data = environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
61
|
+
return Path(data) / "applications" / f"{NAME}.desktop"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _desktop_quoted(path: Path) -> str:
|
|
65
|
+
"""A path as an Exec argument: quoted, with the characters the spec reserves escaped."""
|
|
66
|
+
text = str(path)
|
|
67
|
+
for reserved in ("\\", '"', "`", "$"):
|
|
68
|
+
text = text.replace(reserved, "\\" + reserved)
|
|
69
|
+
return f'"{text}"'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def desktop_entry(script: Path) -> str:
|
|
73
|
+
"""The .desktop file for a launcher.
|
|
74
|
+
|
|
75
|
+
Run in a terminal: the first start after a pull may install libraries, and
|
|
76
|
+
anything that goes wrong is said there, as it is when started by hand.
|
|
77
|
+
"""
|
|
78
|
+
folder = script.parent
|
|
79
|
+
return (
|
|
80
|
+
"[Desktop Entry]\n"
|
|
81
|
+
"Type=Application\n"
|
|
82
|
+
f"Name={NAME}\n"
|
|
83
|
+
"Comment=Flash programming tool\n"
|
|
84
|
+
f"Exec={_desktop_quoted(script)}\n"
|
|
85
|
+
f"Path={folder}\n"
|
|
86
|
+
f"Icon={folder / 'pyflashgui' / 'resources' / 'pyflashgui.png'}\n"
|
|
87
|
+
"Terminal=true\n"
|
|
88
|
+
"Categories=Development;Electronics;\n"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _windows_link(link: Path, script: Path) -> None:
|
|
93
|
+
"""Make the .lnk through the shell's own COM object, by way of PowerShell.
|
|
94
|
+
|
|
95
|
+
The paths go in the environment rather than the command line, so no
|
|
96
|
+
folder name can be mistaken for PowerShell syntax.
|
|
97
|
+
"""
|
|
98
|
+
folder = script.parent
|
|
99
|
+
command = (
|
|
100
|
+
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:PYFLASHGUI_LINK);"
|
|
101
|
+
"$s.TargetPath = $env:PYFLASHGUI_TARGET;"
|
|
102
|
+
"$s.WorkingDirectory = $env:PYFLASHGUI_FOLDER;"
|
|
103
|
+
"$s.IconLocation = $env:PYFLASHGUI_ICON;"
|
|
104
|
+
"$s.Description = 'Flash programming tool, started from ' + $env:PYFLASHGUI_FOLDER;"
|
|
105
|
+
"$s.Save()"
|
|
106
|
+
)
|
|
107
|
+
environ = dict(
|
|
108
|
+
os.environ,
|
|
109
|
+
PYFLASHGUI_LINK=str(link),
|
|
110
|
+
PYFLASHGUI_TARGET=str(script),
|
|
111
|
+
PYFLASHGUI_FOLDER=str(folder),
|
|
112
|
+
PYFLASHGUI_ICON=str(folder / "pyflashgui" / "resources" / "pyflashgui.ico"),
|
|
113
|
+
)
|
|
114
|
+
done = subprocess.run(
|
|
115
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
|
|
116
|
+
env=environ,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
# pyflashgui runs under pythonw, and a console would flash up otherwise.
|
|
120
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
121
|
+
)
|
|
122
|
+
if done.returncode != 0 or not link.is_file():
|
|
123
|
+
raise OSError(done.stderr.strip() or "PowerShell could not make the shortcut.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create(platform: str | None = None, environ=None) -> Path:
|
|
127
|
+
"""Make or replace the entry, and say where it is. OSError if it cannot be."""
|
|
128
|
+
platform = sys.platform if platform is None else platform
|
|
129
|
+
script = launcher(platform)
|
|
130
|
+
if script is None:
|
|
131
|
+
raise OSError("Only a source folder with its launcher needs a menu entry made.")
|
|
132
|
+
path = entry_path(platform, environ)
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
if platform == "win32":
|
|
135
|
+
_windows_link(path, script)
|
|
136
|
+
else:
|
|
137
|
+
path.write_text(desktop_entry(script), encoding="utf-8")
|
|
138
|
+
# Some desktops start only an entry that is marked executable.
|
|
139
|
+
path.chmod(0o755)
|
|
140
|
+
return path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> int:
|
|
144
|
+
try:
|
|
145
|
+
path = create()
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
print(f" Could not add pyflashgui to the {menu_name()}: {exc}")
|
|
148
|
+
return 1
|
|
149
|
+
print(f" Added pyflashgui to the {menu_name()}: {path}")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Two lines after starting, when there is something to say: what Python had to
|
|
4
|
+
compile, and what made a slow start slow.
|
|
5
|
+
|
|
6
|
+
**Compiling.** The first start after an update is much slower than the rest:
|
|
7
|
+
every changed file is compiled to bytecode again. Thirteen seconds where two is
|
|
8
|
+
normal, once, and then it cures itself -- but somebody watching a blank screen
|
|
9
|
+
has no way of knowing that, and "it has got slow" is what sticks. This is not a
|
|
10
|
+
guess about the clock: Python records where each module's compiled copy lives,
|
|
11
|
+
so counting the ones written during this start says exactly what happened. It
|
|
12
|
+
is said whenever any file was compiled, fast start or slow, as information.
|
|
13
|
+
|
|
14
|
+
**Slow.** A start can be slow with nothing compiled, too -- the first after the
|
|
15
|
+
computer started, with nothing in the disk cache, or with an antivirus checking
|
|
16
|
+
every file. So past ``SLOW_S`` the clock is enough on its own: the start says
|
|
17
|
+
how long it took and what took over a second -- the steps, and inside the one
|
|
18
|
+
the libraries load in, the packages -- and points to *Help > Diagnostics* for
|
|
19
|
+
the rest, rather than putting the whole report in the Event Log.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
#: A step or a package at least this long is named in the slow-start line.
|
|
28
|
+
OVER_S = 1.0
|
|
29
|
+
#: Seconds of work, the notice not counted, past which a start is worth
|
|
30
|
+
#: explaining whatever the cause. A normal start is two or three.
|
|
31
|
+
SLOW_S = 10.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_slow(took: float) -> bool:
|
|
35
|
+
return took >= SLOW_S
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compiled_this_start(since: float, modules=None) -> int:
|
|
39
|
+
"""How many imported modules were compiled during this start.
|
|
40
|
+
|
|
41
|
+
``since`` is the wall clock as the process began. A ``.pyc`` written
|
|
42
|
+
after that is one this start had to make, whether because the source
|
|
43
|
+
changed, the file was new, or the cache had been cleared.
|
|
44
|
+
"""
|
|
45
|
+
written = 0
|
|
46
|
+
for module in list((sys.modules if modules is None else modules).values()):
|
|
47
|
+
cached = getattr(module, "__cached__", None)
|
|
48
|
+
if not cached:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
if Path(cached).stat().st_mtime >= since:
|
|
52
|
+
written += 1
|
|
53
|
+
except OSError: # deleted since, or on a read-only installation
|
|
54
|
+
pass
|
|
55
|
+
return written
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compiled_note(since: float, modules=None) -> str | None:
|
|
59
|
+
"""The compiling line, or None when nothing was compiled."""
|
|
60
|
+
written = compiled_this_start(since, modules)
|
|
61
|
+
if not written:
|
|
62
|
+
return None
|
|
63
|
+
files = "file" if written == 1 else "files"
|
|
64
|
+
return f"Python compiled {written} changed {files}, which it only does after files change."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def message(
|
|
68
|
+
took: float,
|
|
69
|
+
steps: list[tuple[str, float]] | None = None,
|
|
70
|
+
packages: dict[str, float] | None = None,
|
|
71
|
+
) -> str | None:
|
|
72
|
+
"""The slow-start line, or None when the start was not slow."""
|
|
73
|
+
if not is_slow(took):
|
|
74
|
+
return None
|
|
75
|
+
said = f"Starting took {took:.1f} s."
|
|
76
|
+
if slow := culprits(steps or [], packages or {}):
|
|
77
|
+
said += f" Over a second: {slow}."
|
|
78
|
+
return said + " Help > Diagnostics has the full breakdown."
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def culprits(steps: list[tuple[str, float]], packages: dict[str, float]) -> str:
|
|
82
|
+
"""The steps over ``OVER_S``, longest first, with the slow packages beside
|
|
83
|
+
the libraries step -- "the libraries" on its own says nothing about which
|
|
84
|
+
one. Empty when no step was."""
|
|
85
|
+
from pyflashgui.core.timing import LIBRARIES_STEP
|
|
86
|
+
|
|
87
|
+
named = []
|
|
88
|
+
for label, seconds in sorted(steps, key=lambda step: step[1], reverse=True):
|
|
89
|
+
if seconds < OVER_S:
|
|
90
|
+
break
|
|
91
|
+
part = f"{label} {seconds:.1f} s"
|
|
92
|
+
if label == LIBRARIES_STEP:
|
|
93
|
+
slow = sorted(
|
|
94
|
+
((name, s) for name, s in packages.items() if s >= OVER_S),
|
|
95
|
+
key=lambda package: package[1],
|
|
96
|
+
reverse=True,
|
|
97
|
+
)
|
|
98
|
+
if slow:
|
|
99
|
+
part += " (" + ", ".join(f"{name} {s:.1f} s" for name, s in slow) + ")"
|
|
100
|
+
named.append(part)
|
|
101
|
+
return ", ".join(named)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Where the seconds go while pyflashgui starts.
|
|
4
|
+
|
|
5
|
+
Starting up is a chain of things that each look cheap: Qt, a workspace's
|
|
6
|
+
settings, the flashing libraries, asking every probe driver what is plugged
|
|
7
|
+
in. When it adds up to ten seconds, guessing which one is at fault costs more
|
|
8
|
+
than measuring it, and the answer is different on every machine --
|
|
9
|
+
a slow file system here, a driver that takes its time enumerating probes
|
|
10
|
+
somewhere else.
|
|
11
|
+
|
|
12
|
+
So everything is always measured: the marks between steps, and how long
|
|
13
|
+
each package took to import. Neither costs anything that can be measured,
|
|
14
|
+
and a start that turns out to have been slow cannot be measured after the
|
|
15
|
+
fact. The report is always in *Help > About*'s diagnostics, and goes to the
|
|
16
|
+
Event Log after a slow start; ``pyflashgui --timing``, which the launchers
|
|
17
|
+
pass through, or ``PYFLASHGUI_TIMING=1`` shows it after every start.
|
|
18
|
+
|
|
19
|
+
The report goes to the Event Log and to ``startup-timing.txt`` in pyflashgui's
|
|
20
|
+
own folder, because the launcher starts pythonw and there is no console to
|
|
21
|
+
print it to.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
FLAG = "--timing"
|
|
33
|
+
ENV = "PYFLASHGUI_TIMING"
|
|
34
|
+
#: Stamped by the launchers before they do anything, so the report can say
|
|
35
|
+
#: what happened before Python: the dependency check, and the interpreter
|
|
36
|
+
#: starting. A shell writes epoch seconds; cmd writes %TIME%, which is
|
|
37
|
+
#: HH:MM:SS.ss in whatever the machine's separators are.
|
|
38
|
+
LAUNCH_ENV = "PYFLASHGUI_LAUNCH_AT"
|
|
39
|
+
#: Stamped again by the launchers immediately before they start Python, so
|
|
40
|
+
#: that the script's own work and the interpreter starting are separate
|
|
41
|
+
#: numbers: one is fixed by doing less in the script, the other is not.
|
|
42
|
+
PYTHON_ENV = "PYFLASHGUI_PYTHON_AT"
|
|
43
|
+
#: Longer than this and the stamp is from an earlier run left in the
|
|
44
|
+
#: environment, not from the launch that is starting now.
|
|
45
|
+
SANE_LAUNCH_S = 600.0
|
|
46
|
+
REPORT_NAME = "startup-timing.txt"
|
|
47
|
+
#: The one step that is not work: the libraries load behind the start-up
|
|
48
|
+
#: notice and the window is built after it, so a person reading the notice
|
|
49
|
+
#: sits between two marks. It is shown, because it is part of the wait, and
|
|
50
|
+
#: left out of the total, because it is not something to make faster.
|
|
51
|
+
NOTICE_STEP = "waiting for the notice to be answered"
|
|
52
|
+
#: The step the libraries load in, which the package times break down.
|
|
53
|
+
LIBRARIES_STEP = "libraries imported"
|
|
54
|
+
|
|
55
|
+
#: The clock starts when this module is imported, which ``__main__`` does
|
|
56
|
+
#: before anything heavy. Whatever ran before that -- the interpreter itself,
|
|
57
|
+
#: and the launcher's dependency check -- is outside anything Python here can
|
|
58
|
+
#: see, so the report says so rather than pretending to a total.
|
|
59
|
+
_STARTED = time.perf_counter()
|
|
60
|
+
#: The wall clock at the same moment, for comparing against the launchers'
|
|
61
|
+
#: stamps. Taken here rather than when the report is written: the report
|
|
62
|
+
#: comes seconds later, and measuring against it charged the launcher with
|
|
63
|
+
#: everything that happened after it -- Qt, the libraries, the window, and
|
|
64
|
+
#: however long somebody took to read the notice.
|
|
65
|
+
_STARTED_WALL = time.time()
|
|
66
|
+
|
|
67
|
+
_marks: list[tuple[str, float]] = []
|
|
68
|
+
_on = FLAG in sys.argv or os.environ.get(ENV, "") not in ("", "0")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
#: Seconds spent executing each top-level package's modules. Exclusive of the
|
|
72
|
+
#: imports a module does itself, so the totals add up rather than counting
|
|
73
|
+
#: the same second under three names.
|
|
74
|
+
_imports: dict[str, float] = {}
|
|
75
|
+
#: The imports under way, one stack per thread: the libraries load on a
|
|
76
|
+
#: thread of their own while the GUI thread may be importing too, and one
|
|
77
|
+
#: shared stack would hand each thread's time to the other.
|
|
78
|
+
_local = threading.local()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: What the standard library's modules are counted under. Together, because
|
|
82
|
+
#: each one is small and "json" or "ctypes" on a line of its own reads as a
|
|
83
|
+
#: package somebody installed; kept, because it is still time spent starting.
|
|
84
|
+
STDLIB = "Python standard library"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _package(name: str) -> str:
|
|
88
|
+
"""The line a module's time goes on: its top-level package, or STDLIB."""
|
|
89
|
+
top = name.split(".")[0]
|
|
90
|
+
return STDLIB if top in sys.stdlib_module_names else top
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _depth() -> list[float]:
|
|
94
|
+
stack = getattr(_local, "stack", None)
|
|
95
|
+
if stack is None:
|
|
96
|
+
stack = _local.stack = []
|
|
97
|
+
return stack
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def enabled() -> bool:
|
|
101
|
+
return _on
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _TimedImports:
|
|
105
|
+
"""A finder that times what the real finders load.
|
|
106
|
+
|
|
107
|
+
Put in front of ``sys.meta_path``, it lets the normal machinery find a
|
|
108
|
+
module and then wraps the loader, so the cost lands under the package it
|
|
109
|
+
belongs to. Time spent importing something else is subtracted, so a
|
|
110
|
+
package is not blamed for the packages it imports.
|
|
111
|
+
|
|
112
|
+
Marked rather than recognised by its class: reloading this module makes a
|
|
113
|
+
new class, and a watcher that failed to recognise one from before would be
|
|
114
|
+
installed beside it, each asking the other for every module for ever.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
watcher = True
|
|
118
|
+
|
|
119
|
+
def find_spec(self, name, path=None, target=None):
|
|
120
|
+
for finder in sys.meta_path:
|
|
121
|
+
if getattr(finder, "watcher", False) or not hasattr(finder, "find_spec"):
|
|
122
|
+
continue
|
|
123
|
+
spec = finder.find_spec(name, path, target)
|
|
124
|
+
if spec is None or spec.loader is None:
|
|
125
|
+
continue
|
|
126
|
+
spec.loader = _TimedLoader(spec.loader, _package(name))
|
|
127
|
+
return spec
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _TimedLoader:
|
|
132
|
+
def __init__(self, loader, package: str) -> None:
|
|
133
|
+
self._loader = loader
|
|
134
|
+
self._package = package
|
|
135
|
+
|
|
136
|
+
def __getattr__(self, attribute): # create_module, is_package, get_data, ...
|
|
137
|
+
return getattr(self._loader, attribute)
|
|
138
|
+
|
|
139
|
+
def exec_module(self, module) -> None:
|
|
140
|
+
began = time.perf_counter()
|
|
141
|
+
stack = _depth()
|
|
142
|
+
stack.append(0.0)
|
|
143
|
+
try:
|
|
144
|
+
self._loader.exec_module(module)
|
|
145
|
+
finally:
|
|
146
|
+
spent_below = stack.pop()
|
|
147
|
+
mine = time.perf_counter() - began - spent_below
|
|
148
|
+
_imports[self._package] = _imports.get(self._package, 0.0) + mine
|
|
149
|
+
if stack: # tell whoever imported us not to count our time twice
|
|
150
|
+
stack[-1] += mine + spent_below
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def watch_imports() -> None:
|
|
154
|
+
"""Start timing imports: always, since it costs nothing measurable."""
|
|
155
|
+
if not any(getattr(finder, "watcher", False) for finder in sys.meta_path):
|
|
156
|
+
sys.meta_path.insert(0, _TimedImports())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def import_lines(most: int = 8) -> list[str]:
|
|
160
|
+
"""The packages that cost the most to import, largest first."""
|
|
161
|
+
if not _imports:
|
|
162
|
+
return []
|
|
163
|
+
worst = sorted(_imports.items(), key=lambda pair: pair[1], reverse=True)[:most]
|
|
164
|
+
lines = ["The packages that took longest to import:"]
|
|
165
|
+
lines += [f" {took:6.3f} {package}" for package, took in worst if took >= 0.001]
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _until_import(stamp: str) -> float | None:
|
|
170
|
+
"""Seconds from a launcher's stamp to this module being imported."""
|
|
171
|
+
try:
|
|
172
|
+
if ":" in stamp: # cmd's %TIME%, which is a time of day
|
|
173
|
+
hours, minutes, seconds = stamp.split(":")
|
|
174
|
+
began = int(hours) * 3600 + int(minutes) * 60 + float(seconds.replace(",", "."))
|
|
175
|
+
now = time.localtime(_STARTED_WALL)
|
|
176
|
+
since_midnight = now.tm_hour * 3600 + now.tm_min * 60 + now.tm_sec
|
|
177
|
+
since_midnight += _STARTED_WALL % 1
|
|
178
|
+
took = since_midnight - began
|
|
179
|
+
if took < 0: # started before midnight, running after it
|
|
180
|
+
took += 86400
|
|
181
|
+
else:
|
|
182
|
+
took = _STARTED_WALL - float(stamp.replace(",", "."))
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
return None
|
|
185
|
+
return took if 0 <= took <= SANE_LAUNCH_S else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def launcher_seconds() -> tuple[float | None, float | None]:
|
|
189
|
+
"""(the script's own work, starting Python), as far as either is known."""
|
|
190
|
+
began = _until_import(os.environ.get(LAUNCH_ENV, "").strip())
|
|
191
|
+
started_python = _until_import(os.environ.get(PYTHON_ENV, "").strip())
|
|
192
|
+
if began is None:
|
|
193
|
+
return None, started_python
|
|
194
|
+
if started_python is None:
|
|
195
|
+
return began, None
|
|
196
|
+
# The second stamp is later, so less time has passed since it.
|
|
197
|
+
return max(began - started_python, 0.0), started_python
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def mark(label: str) -> None:
|
|
201
|
+
"""Note that this step has just finished.
|
|
202
|
+
|
|
203
|
+
Always recorded, whether or not anybody asked for a report: it is a
|
|
204
|
+
string and a float appended to a list, and a start that turns out to
|
|
205
|
+
have been slow cannot be measured after the fact.
|
|
206
|
+
"""
|
|
207
|
+
_marks.append((label, time.perf_counter()))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def why_shown() -> str:
|
|
211
|
+
"""Why the report is in the Event Log, and how to stop it -- it is only
|
|
212
|
+
there when asked for, by the option or the environment variable.
|
|
213
|
+
|
|
214
|
+
Said every time, because ``--timing`` is exactly the kind of thing that
|
|
215
|
+
gets added to a shortcut for one investigation and forgotten, and then the
|
|
216
|
+
report turns up at every start with nothing to say where it came from.
|
|
217
|
+
"""
|
|
218
|
+
if FLAG in sys.argv:
|
|
219
|
+
return (
|
|
220
|
+
f"Shown because pyflashgui was started with the {FLAG} option. Leave it "
|
|
221
|
+
"out of the shortcut or command to stop this report."
|
|
222
|
+
)
|
|
223
|
+
return f"Shown because the {ENV} environment variable is set. Remove it to stop this report."
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def report_lines() -> list[str]:
|
|
227
|
+
"""Each step and what it cost, slowest last so the answer is visible."""
|
|
228
|
+
if not _marks:
|
|
229
|
+
return []
|
|
230
|
+
steps = []
|
|
231
|
+
previous = _STARTED
|
|
232
|
+
for label, at in _marks:
|
|
233
|
+
steps.append((label, at - previous))
|
|
234
|
+
previous = at
|
|
235
|
+
lines = [
|
|
236
|
+
"Startup timing (seconds), from the first import in __main__",
|
|
237
|
+
f"({NOTICE_STEP} is you, and is not in the total):",
|
|
238
|
+
]
|
|
239
|
+
# Before the clock above started, and often the largest part of the wait:
|
|
240
|
+
# the launcher's own checks, then the interpreter itself starting.
|
|
241
|
+
script, interpreter = launcher_seconds()
|
|
242
|
+
if script is not None:
|
|
243
|
+
lines.append(f" {script:6.3f} the launcher script (before the rest)")
|
|
244
|
+
if interpreter is not None:
|
|
245
|
+
lines.append(f" {interpreter:6.3f} starting Python (before the rest)")
|
|
246
|
+
lines += [f" {took:6.3f} {label}" for label, took in steps]
|
|
247
|
+
work = [step for step in steps if step[0] != NOTICE_STEP]
|
|
248
|
+
lines.append(f" {sum(took for _, took in work):6.3f} total, not counting the wait")
|
|
249
|
+
label, took = max(work or steps, key=lambda step: step[1])
|
|
250
|
+
lines.append(f"The longest step was {label}, at {took:.3f} s")
|
|
251
|
+
return lines + import_lines()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def started_at() -> float:
|
|
255
|
+
"""The wall clock as this module was imported, which is as near as
|
|
256
|
+
anything gets to when the process began."""
|
|
257
|
+
return _STARTED_WALL
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def step_times() -> list[tuple[str, float]]:
|
|
261
|
+
"""Each step and how long it took, the wait for the notice left out.
|
|
262
|
+
|
|
263
|
+
The launcher's own work and Python starting come first when the
|
|
264
|
+
launcher stamped them: a dependency check that installs something is a
|
|
265
|
+
slow start too, and nothing after Python starts would show it.
|
|
266
|
+
"""
|
|
267
|
+
out: list[tuple[str, float]] = []
|
|
268
|
+
script, interpreter = launcher_seconds()
|
|
269
|
+
if script is not None:
|
|
270
|
+
out.append(("the launcher script", script))
|
|
271
|
+
if interpreter is not None:
|
|
272
|
+
out.append(("starting Python", interpreter))
|
|
273
|
+
previous = _STARTED
|
|
274
|
+
for label, at in _marks:
|
|
275
|
+
if label != NOTICE_STEP:
|
|
276
|
+
out.append((label, at - previous))
|
|
277
|
+
previous = at
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def import_times() -> dict[str, float]:
|
|
282
|
+
"""Seconds spent importing each top-level package, so far."""
|
|
283
|
+
return dict(_imports)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def total_work() -> float:
|
|
287
|
+
"""How long starting took, not counting the wait for the notice.
|
|
288
|
+
|
|
289
|
+
Zero before anything has been marked, which is what a test or a script
|
|
290
|
+
that imported this module and never started a window will see.
|
|
291
|
+
"""
|
|
292
|
+
if not _marks:
|
|
293
|
+
return 0.0
|
|
294
|
+
previous, total = _STARTED, 0.0
|
|
295
|
+
for label, at in _marks:
|
|
296
|
+
if label != NOTICE_STEP:
|
|
297
|
+
total += at - previous
|
|
298
|
+
previous = at
|
|
299
|
+
return total
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def write_report(folder: Path) -> Path | None:
|
|
303
|
+
"""Leave the report where it can be found again and pasted into a report."""
|
|
304
|
+
lines = report_lines()
|
|
305
|
+
if not lines:
|
|
306
|
+
return None
|
|
307
|
+
path = Path(folder) / REPORT_NAME
|
|
308
|
+
try:
|
|
309
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
310
|
+
except OSError: # a read-only folder is not worth failing a start over
|
|
311
|
+
return None
|
|
312
|
+
return path
|