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.
Files changed (68) hide show
  1. pyflashgui/__init__.py +6 -0
  2. pyflashgui/__main__.py +334 -0
  3. pyflashgui/backends/__init__.py +40 -0
  4. pyflashgui/backends/base.py +272 -0
  5. pyflashgui/backends/jlink_backend.py +596 -0
  6. pyflashgui/backends/pyocd_backend.py +271 -0
  7. pyflashgui/backends/simulated.py +240 -0
  8. pyflashgui/core/__init__.py +2 -0
  9. pyflashgui/core/checkout.py +134 -0
  10. pyflashgui/core/compare.py +157 -0
  11. pyflashgui/core/context.py +37 -0
  12. pyflashgui/core/events.py +71 -0
  13. pyflashgui/core/excepthook.py +110 -0
  14. pyflashgui/core/flasher.py +419 -0
  15. pyflashgui/core/images.py +279 -0
  16. pyflashgui/core/installation.py +137 -0
  17. pyflashgui/core/layout.py +72 -0
  18. pyflashgui/core/memory_map.py +234 -0
  19. pyflashgui/core/packages.py +104 -0
  20. pyflashgui/core/paths.py +49 -0
  21. pyflashgui/core/settings.py +44 -0
  22. pyflashgui/core/shortcut.py +154 -0
  23. pyflashgui/core/slow_start.py +101 -0
  24. pyflashgui/core/timing.py +312 -0
  25. pyflashgui/core/updates.py +195 -0
  26. pyflashgui/core/worker.py +71 -0
  27. pyflashgui/core/workspaces.py +250 -0
  28. pyflashgui/help/__init__.py +129 -0
  29. pyflashgui/help/about.md +21 -0
  30. pyflashgui/help/changelog.md +46 -0
  31. pyflashgui/help/files.md +60 -0
  32. pyflashgui/help/main-window.png +0 -0
  33. pyflashgui/help/manual.md +44 -0
  34. pyflashgui/help/memory-map.md +28 -0
  35. pyflashgui/help/memory.md +59 -0
  36. pyflashgui/help/probe.md +71 -0
  37. pyflashgui/resources/__init__.py +10 -0
  38. pyflashgui/resources/pyflashgui.ico +0 -0
  39. pyflashgui/resources/pyflashgui.png +0 -0
  40. pyflashgui/resources/pyflashgui.svg +32 -0
  41. pyflashgui/resources/pyflashgui_small.svg +20 -0
  42. pyflashgui/ui/__init__.py +2 -0
  43. pyflashgui/ui/confirm.py +312 -0
  44. pyflashgui/ui/detached.py +101 -0
  45. pyflashgui/ui/erase_dialog.py +141 -0
  46. pyflashgui/ui/event_colours.py +66 -0
  47. pyflashgui/ui/files_view.py +716 -0
  48. pyflashgui/ui/folders.py +176 -0
  49. pyflashgui/ui/fonts.py +75 -0
  50. pyflashgui/ui/help_menu.py +587 -0
  51. pyflashgui/ui/main_window.py +754 -0
  52. pyflashgui/ui/map_view.py +215 -0
  53. pyflashgui/ui/memory_view.py +724 -0
  54. pyflashgui/ui/messages.py +78 -0
  55. pyflashgui/ui/pane_bar.py +92 -0
  56. pyflashgui/ui/panes.py +1068 -0
  57. pyflashgui/ui/persist.py +132 -0
  58. pyflashgui/ui/probe_bar.py +315 -0
  59. pyflashgui/ui/read_dialog.py +161 -0
  60. pyflashgui/ui/session.py +53 -0
  61. pyflashgui/ui/theme.py +85 -0
  62. pyflashgui-0.1.0.dist-info/METADATA +80 -0
  63. pyflashgui-0.1.0.dist-info/RECORD +68 -0
  64. pyflashgui-0.1.0.dist-info/WHEEL +5 -0
  65. pyflashgui-0.1.0.dist-info/entry_points.txt +2 -0
  66. pyflashgui-0.1.0.dist-info/licenses/LICENSE +202 -0
  67. pyflashgui-0.1.0.dist-info/licenses/NOTICE +30 -0
  68. 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