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,195 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Asking GitHub whether there is a newer release.
4
+
5
+ This runs only when the user picks Help > Check for updates. pyflashgui makes
6
+ no network connection of its own accord, and this one sends nothing about the
7
+ machine it is on: it is an ordinary unauthenticated GET of a public API URL,
8
+ and nothing is downloaded or installed -- a newer version just offers to open
9
+ the releases page in a browser.
10
+
11
+ Only the standard library is used, so no dependency is added for a feature
12
+ that is used once in a while.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+
24
+ REPO = "davhodg/pyflashgui"
25
+ API = f"https://api.github.com/repos/{REPO}"
26
+ RELEASES_API = f"{API}/releases/latest"
27
+ RELEASES_PAGE = f"https://github.com/{REPO}/releases"
28
+ PROJECT_PAGE = f"https://github.com/{REPO}"
29
+ README_PAGE = f"{PROJECT_PAGE}#readme"
30
+ COMMITS_PAGE = f"{PROJECT_PAGE}/commits"
31
+ PYPI_API = "https://pypi.org/pypi/pyflashgui/json"
32
+ PYPI_PAGE = "https://pypi.org/project/pyflashgui/"
33
+ #: What a checkout on a branch GitHub has never heard of is compared against.
34
+ DEFAULT_BRANCH = "master"
35
+ SHORT = 7
36
+ TIMEOUT_S = 6.0
37
+
38
+
39
+ def parse_version(text: str) -> tuple[int, ...]:
40
+ """The numeric parts of a version, so "v1.2.0" and "1.2" compare sensibly.
41
+
42
+ Anything that is not a number is ignored rather than guessed at: a release
43
+ candidate suffix is not worth a version grammar of our own.
44
+ """
45
+ return tuple(int(part) for part in re.findall(r"\d+", text)) or (0,)
46
+
47
+
48
+ def is_newer(candidate: str, current: str) -> bool:
49
+ """Whether ``candidate`` is a later version than ``current``.
50
+
51
+ A development build comes *before* the release it is numbered for:
52
+ 0.1.0.dev5 is on its way to 0.1.0, so 0.1.0 is newer, although its
53
+ digits alone would say the opposite.
54
+ """
55
+
56
+ def key(text: str, length: int) -> tuple:
57
+ release, dev, number = text.lower().partition("dev")
58
+ parts = parse_version(release)
59
+ padded = parts + (0,) * (length - len(parts)) # 1.2 is not newer than 1.2.0
60
+ # A release sorts after every dev build of itself.
61
+ return padded, (0, *parse_version(number)) if dev else (1,)
62
+
63
+ length = max(len(parse_version(v.lower().partition("dev")[0])) for v in (candidate, current))
64
+ return key(candidate, length) > key(current, length)
65
+
66
+
67
+ @dataclass
68
+ class Release:
69
+ version: str
70
+ url: str
71
+ #: True for a version on PyPI, which is updated with pip rather than by
72
+ #: downloading anything.
73
+ pip: bool = False
74
+
75
+
76
+ def _get(url: str, timeout: float) -> tuple[dict | None, int, str]:
77
+ """(payload, 200, "") or (None, status, problem): a 404 is left to the caller,
78
+ since what it means depends on what was asked.
79
+
80
+ Every failure is reported as text rather than raised: not being able to
81
+ reach GitHub is an ordinary thing to happen, not an error in pyflashgui.
82
+ """
83
+ request = urllib.request.Request(
84
+ url, headers={"Accept": "application/vnd.github+json", "User-Agent": "pyflashgui"}
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(request, timeout=timeout) as response:
88
+ return json.load(response), 200, ""
89
+ except urllib.error.HTTPError as exc:
90
+ if exc.code == 403:
91
+ return None, 403, "GitHub declined the request (its rate limit). Try again later."
92
+ return None, exc.code, f"GitHub returned {exc.code} {exc.reason}."
93
+ except urllib.error.URLError as exc:
94
+ return None, 0, f"Could not reach GitHub: {exc.reason}"
95
+ except (TimeoutError, OSError) as exc:
96
+ return None, 0, f"Could not reach GitHub: {exc}"
97
+ except json.JSONDecodeError:
98
+ return None, 0, "GitHub's reply could not be read."
99
+
100
+
101
+ def latest_release(timeout: float = TIMEOUT_S) -> tuple[Release | None, str]:
102
+ """Return (release, problem). Exactly one of the two is meaningful."""
103
+ payload, status, problem = _get(RELEASES_API, timeout)
104
+ if status == 404:
105
+ # Also what a private repository looks like from outside.
106
+ return None, "No releases have been published yet."
107
+ if payload is None:
108
+ return None, problem
109
+ tag = str(payload.get("tag_name") or "").strip()
110
+ if not tag:
111
+ return None, "GitHub reported a release with no version."
112
+ return Release(version=tag.lstrip("vV"), url=payload.get("html_url") or RELEASES_PAGE), ""
113
+
114
+
115
+ def latest_on_pypi(timeout: float = TIMEOUT_S) -> tuple[Release | None, str]:
116
+ """Return (release, problem) for the newest version on PyPI.
117
+
118
+ For a pip installation: the release on GitHub is the Windows installer,
119
+ which is the wrong download for somebody who installed with pip.
120
+ """
121
+ payload, status, problem = _get(PYPI_API, timeout)
122
+ if status == 404:
123
+ return None, "pyflashgui is not on PyPI."
124
+ if payload is None:
125
+ return None, problem
126
+ version = str((payload.get("info") or {}).get("version") or "").strip()
127
+ if not version:
128
+ return None, "PyPI's reply could not be read."
129
+ return Release(version=version, url=f"{PYPI_PAGE}{version}/", pip=True), ""
130
+
131
+
132
+ @dataclass
133
+ class Standing:
134
+ """Where a source checkout is against the same branch on GitHub."""
135
+
136
+ branch: str
137
+ here: str # this checkout's commit, short
138
+ latest: str # GitHub's newest commit on the branch, short
139
+ behind: int = 0 # commits GitHub has that this checkout does not
140
+ ahead: int = 0 # commits here that GitHub does not have
141
+ #: False when GitHub has never seen this checkout's commit: one made here
142
+ #: and not pushed, or one from a fork.
143
+ known: bool = True
144
+
145
+ @property
146
+ def url(self) -> str:
147
+ return f"{COMMITS_PAGE}/{self.branch}"
148
+
149
+
150
+ def checkout_standing(
151
+ branch: str | None, commit: str, timeout: float = TIMEOUT_S
152
+ ) -> tuple[Standing | None, str]:
153
+ """Return (standing, problem) for a checkout on this branch and commit.
154
+
155
+ A release says nothing to somebody running what they pulled: between two
156
+ releases every commit calls itself the same version. What they want to
157
+ know is whether there is more to pull.
158
+ """
159
+ branch = branch or DEFAULT_BRANCH
160
+ here = commit[:SHORT]
161
+
162
+ def tip(name: str) -> tuple[dict | None, int, str]:
163
+ return _get(f"{API}/commits/{urllib.parse.quote(name)}", timeout)
164
+
165
+ payload, status, problem = tip(branch)
166
+ if status in (404, 422) and branch != DEFAULT_BRANCH:
167
+ # A branch made here, which GitHub has not got: the main one is what
168
+ # there is to compare with.
169
+ branch = DEFAULT_BRANCH
170
+ payload, status, problem = tip(branch)
171
+ if payload is None:
172
+ return None, problem
173
+ latest = str(payload.get("sha") or "")
174
+ if not latest:
175
+ return None, "GitHub's reply could not be read."
176
+ if latest == commit:
177
+ return Standing(branch, here, latest[:SHORT]), ""
178
+
179
+ compared, status, problem = _get(f"{API}/compare/{commit}...{latest}", timeout)
180
+ if status in (404, 422):
181
+ return Standing(branch, here, latest[:SHORT], known=False), ""
182
+ if compared is None:
183
+ return None, problem
184
+ # Compared from here to GitHub's tip: what it is ahead by is what this
185
+ # checkout is behind by, and the other way about.
186
+ return (
187
+ Standing(
188
+ branch,
189
+ here,
190
+ latest[:SHORT],
191
+ behind=int(compared.get("ahead_by") or 0),
192
+ ahead=int(compared.get("behind_by") or 0),
193
+ ),
194
+ "",
195
+ )
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """A queue-fed worker thread for blocking probe calls (connect, erase, program).
4
+
5
+ Each job is a plain function; its result or exception is delivered back on the
6
+ GUI thread through ``done``, so callers never block the window. One worker
7
+ per probe keeps what is asked of it sequential, which is what a probe wants
8
+ anyway.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import queue
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from PySide6.QtCore import QObject, QThread, Signal, Slot
18
+
19
+ Callback = Callable[[Any, str | None], None]
20
+
21
+
22
+ class Worker(QThread):
23
+ done = Signal(object) # (callback, result, error)
24
+
25
+ def __init__(self, parent: QObject | None = None) -> None:
26
+ super().__init__(parent)
27
+ self._jobs: queue.Queue = queue.Queue()
28
+ self.done.connect(self._deliver)
29
+ #: Until it is stopped. Stopping twice must not disconnect twice,
30
+ #: which PySide complains about at length.
31
+ self._delivering = True
32
+
33
+ def submit(self, fn: Callable[[], Any], callback: Callback) -> None:
34
+ # Started on first use: a QThread that is running when Qt destroys it
35
+ # aborts the process, so an owner that is created and dropped without
36
+ # ever asking for anything must not leave a thread behind.
37
+ if not self.isRunning():
38
+ self.start()
39
+ self._jobs.put((fn, callback))
40
+
41
+ def run(self) -> None:
42
+ while (job := self._jobs.get()) is not None:
43
+ fn, callback = job
44
+ try:
45
+ self.done.emit((callback, fn(), None))
46
+ except Exception as exc: # report, never die
47
+ self.done.emit((callback, None, f"{type(exc).__name__}: {exc}"))
48
+
49
+ @Slot(object)
50
+ def _deliver(self, packet: tuple) -> None:
51
+ callback, result, error = packet
52
+ callback(result, error)
53
+
54
+ def stop(self) -> None:
55
+ """Finish the queue and stop delivering results.
56
+
57
+ The ``done`` connection is dropped first: a queued result arriving
58
+ after the owner has been torn down would be delivered to a dead
59
+ object, which Qt punishes with an access violation rather than an
60
+ exception.
61
+ """
62
+ if self._delivering:
63
+ self._delivering = False
64
+ try:
65
+ self.done.disconnect(self._deliver)
66
+ except (RuntimeError, TypeError): # the C++ side has already gone
67
+ pass
68
+ if not self.isRunning():
69
+ return
70
+ self._jobs.put(None)
71
+ self.wait(2000)
@@ -0,0 +1,250 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Named workspaces: one per product, and one called ``default`` nobody need meet.
4
+
5
+ A workspace is everything about *what you are working on*: the target, the
6
+ probe and its speed, the files to program and the dock layout. One folder, so
7
+ it can be copied, backed up or sent to someone else whole. What stays outside
8
+ it is everything about *this machine*: where the window sits on the screen.
9
+
10
+ The design constraint is that somebody with one product must never have to
11
+ know the word exists. On first run there is a workspace called ``default``,
12
+ created silently, and pyflashgui behaves exactly as it would with no
13
+ workspaces at all. The name appears in the title bar only when it is *not* ``default``.
14
+
15
+ There is no Save, and therefore no unsaved changes: a workspace saves
16
+ continuously, which is what ``settings.json`` has always done. The whole
17
+ surface is *Save as...* (fork what is on screen into a new name), *Switch
18
+ to*, and *Manage*.
19
+
20
+ One is active at a time, and that is not a simplification. The most
21
+ load-bearing thing a workspace holds is which probe and which target, and a
22
+ probe is connected to one target: two workspaces open at once would either
23
+ share the probe, in which case they are not independent, or each want it for
24
+ a different target, which is a contradiction rather than a feature.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import shutil
32
+ from pathlib import Path
33
+
34
+ from pyflashgui.core import paths
35
+
36
+ #: The one every installation has. Created silently, never deleted, and never
37
+ #: named in the window title -- somebody who only ever has this one should not
38
+ #: be able to tell that workspaces were built.
39
+ DEFAULT = "default"
40
+
41
+ #: Under the user folder: the workspaces themselves, and the note saying which
42
+ #: of them is in use. The pointer is the only thing here that is not inside a
43
+ #: workspace, because it is the one question asked before there is one.
44
+ ROOT = "workspaces"
45
+ POINTER = "workspaces.json"
46
+
47
+ MAX_NAME = 64
48
+ #: A workspace name is a folder name, so it has to survive being one. Letters,
49
+ #: digits, space, dot, dash and underscore, starting with a letter or a digit.
50
+ _ALLOWED = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]*$")
51
+
52
+ #: The workspace ``--workspace`` named, for this run only. Kept out of the
53
+ #: pointer so that a shortcut made for one product does not change what
54
+ #: pyflashgui opens the next time it is started without one.
55
+ _this_run = ""
56
+ #: Windows refuses these as filenames whatever the extension, and has since
57
+ #: DOS. A workspace called "con" would be created, apparently, and then be
58
+ #: unopenable.
59
+ _RESERVED = frozenset(
60
+ {"con", "prn", "aux", "nul"}
61
+ | {f"com{i}" for i in range(1, 10)}
62
+ | {f"lpt{i}" for i in range(1, 10)}
63
+ )
64
+
65
+
66
+ # --- where things are -------------------------------------------------------------------
67
+ def root() -> Path:
68
+ return paths.user_dir() / ROOT
69
+
70
+
71
+ def dir_for(name: str) -> Path:
72
+ return root() / name
73
+
74
+
75
+ def _pointer() -> Path:
76
+ return paths.user_dir() / POINTER
77
+
78
+
79
+ def _ensure() -> None:
80
+ """Make sure there is somewhere to work."""
81
+ paths.made(dir_for(DEFAULT))
82
+
83
+
84
+ def names() -> list[str]:
85
+ """Every workspace, ``default`` first and the rest by name."""
86
+ _ensure()
87
+ found = sorted(p.name for p in root().iterdir() if p.is_dir())
88
+ return [DEFAULT, *(n for n in found if n != DEFAULT)]
89
+
90
+
91
+ def exists(name: str) -> bool:
92
+ return bool(name) and dir_for(name).is_dir()
93
+
94
+
95
+ def active() -> str:
96
+ """Which workspace is in use. ``default`` when nothing says otherwise."""
97
+ _ensure()
98
+ if _this_run and exists(_this_run):
99
+ return _this_run
100
+ try:
101
+ chosen = json.loads(_pointer().read_text(encoding="utf-8")).get("active")
102
+ except (OSError, ValueError, AttributeError):
103
+ return DEFAULT
104
+ # A workspace deleted by hand, or a pointer typed wrong, is not a reason
105
+ # to start with nothing: fall back to the one that is always there.
106
+ return chosen if isinstance(chosen, str) and exists(chosen) else DEFAULT
107
+
108
+
109
+ def use(name: str) -> None:
110
+ """Work in this workspace this time, leaving the one opened next time alone."""
111
+ global _this_run
112
+ _this_run = name
113
+
114
+
115
+ def set_active(name: str) -> None:
116
+ use("") # a workspace chosen in the window is chosen for good
117
+ _pointer().write_text(json.dumps({"active": name}, indent=2), encoding="utf-8")
118
+
119
+
120
+ def active_dir() -> Path:
121
+ _ensure()
122
+ return _made(dir_for(active()))
123
+
124
+
125
+ def settings_path() -> Path:
126
+ return active_dir() / "settings.json"
127
+
128
+
129
+ def layout_path() -> Path:
130
+ return active_dir() / "layout.json"
131
+
132
+
133
+ def _made(path: Path) -> Path:
134
+ return paths.made(path)
135
+
136
+
137
+ # --- naming one -------------------------------------------------------------------------
138
+ def clean(name: str) -> str:
139
+ """The name as it will actually be used.
140
+
141
+ Only the space around it, which nobody typed on purpose. Everything else
142
+ is left alone and refused by name below rather than quietly repaired:
143
+ turning "board 1/2" into "board 12" would leave somebody looking for a
144
+ workspace that is not called what they called it.
145
+ """
146
+ return name.strip()
147
+
148
+
149
+ def why_not(name: str) -> str:
150
+ """Why this name cannot be used, or "" if it can."""
151
+ name = clean(name)
152
+ if reason := _not_a_folder_name(name):
153
+ return reason
154
+ if exists(name):
155
+ return f"There is already a workspace called {name}."
156
+ return ""
157
+
158
+
159
+ def _not_a_folder_name(name: str) -> str:
160
+ """Why this could never be a workspace, taken or not. "" if it could."""
161
+ if not name:
162
+ return "A workspace needs a name."
163
+ if len(name) > MAX_NAME:
164
+ return f"Names are at most {MAX_NAME} characters."
165
+ if not _ALLOWED.match(name) or name.endswith((" ", ".")):
166
+ return (
167
+ "A workspace name is also a folder name: letters, digits, spaces, "
168
+ "dots, dashes and underscores, starting with a letter or a digit."
169
+ )
170
+ if name.lower() in _RESERVED:
171
+ return f"Windows will not accept a folder called {name}."
172
+ return ""
173
+
174
+
175
+ def next_free(base: str = "workspace") -> str:
176
+ """A name nobody has used, as close to ``base`` as it can be.
177
+
178
+ Offered, never imposed: it is what a name dialog opens on, so the easy
179
+ answer is one that will be accepted. A ``base`` that could never be a
180
+ folder name -- a downloaded file called "board (1)", say -- is not tidied
181
+ into something that looks like it; the suggestion falls back to plain
182
+ "workspace" and the person can type what they meant.
183
+ """
184
+ base = clean(base)
185
+ if _not_a_folder_name(base):
186
+ base = "workspace"
187
+ if not exists(base):
188
+ return base
189
+ number = 2
190
+ while True:
191
+ suffix = f" {number}"
192
+ # Shortened to fit rather than refused for length, which would never end.
193
+ candidate = base[: MAX_NAME - len(suffix)].rstrip(" .") + suffix
194
+ if why_not(candidate) == "":
195
+ return candidate
196
+ number += 1
197
+
198
+
199
+ # --- making, renaming, removing -----------------------------------------------------------
200
+ def create(name: str, copy_from: str | None = None) -> Path:
201
+ """A new workspace, optionally forked from an existing one.
202
+
203
+ Save as... forks, because what somebody means by it is "keep what I have
204
+ and start calling it something else". An empty new one would throw away
205
+ the arrangement they were looking at when they asked.
206
+ """
207
+ if (reason := why_not(name)) != "":
208
+ raise ValueError(reason)
209
+ name = clean(name)
210
+ target = dir_for(name)
211
+ if copy_from and exists(copy_from):
212
+ shutil.copytree(dir_for(copy_from), target)
213
+ else:
214
+ target.mkdir(parents=True)
215
+ return target
216
+
217
+
218
+ def rename(old: str, new: str) -> None:
219
+ if old == DEFAULT:
220
+ raise ValueError(f"{DEFAULT} cannot be renamed: it is the one that is always there.")
221
+ if not exists(old):
222
+ raise ValueError(f"There is no workspace called {old}.")
223
+ if (reason := why_not(new)) != "":
224
+ raise ValueError(reason)
225
+ # Asked before the folder moves. Afterwards the pointer names something
226
+ # that is no longer there, so active() has already fallen back to default
227
+ # and the rename would quietly send somebody somewhere else.
228
+ was_active = active() == old
229
+ new = clean(new)
230
+ dir_for(old).rename(dir_for(new))
231
+ if was_active and _this_run == old:
232
+ use(new)
233
+ elif was_active:
234
+ set_active(new)
235
+
236
+
237
+ def delete(name: str) -> None:
238
+ """Remove a workspace and everything in it.
239
+
240
+ Not the active one: pulling the settings and layout out from under
241
+ a running window is a worse answer than asking somebody to switch away
242
+ first, which is one click and leaves them somewhere they chose.
243
+ """
244
+ if name == DEFAULT:
245
+ raise ValueError(f"{DEFAULT} cannot be deleted: it is the one that is always there.")
246
+ if name == active():
247
+ raise ValueError(f"{name} is the workspace in use. Switch to another one first.")
248
+ if not exists(name):
249
+ raise ValueError(f"There is no workspace called {name}.")
250
+ shutil.rmtree(dir_for(name))
@@ -0,0 +1,129 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """The manual, shipped with the application: a page per topic.
4
+
5
+ Inside the package rather than beside the README so that it survives a
6
+ ``pip install`` and lands in the frozen build. A Help menu that needs the
7
+ internet is worth nothing on a bench or in a workshop, which is where
8
+ somebody is most likely to want it.
9
+
10
+ Markdown, because it is the same text the repository serves on the web and Qt
11
+ renders it directly -- ``QTextBrowser.setMarkdown`` with the GitHub dialect,
12
+ so the tables come out as tables. No web engine: the GPL-only Qt modules are
13
+ deliberately not installed, and a manual is not worth a web engine.
14
+
15
+ **A page per pane, rather than one long file.** Nobody reads a manual; people
16
+ look one thing up in it, and a single document answers that by making them
17
+ scroll past every topic they did not ask about. The pages are joined by
18
+ ordinary relative Markdown links, so the same files read as pages on the
19
+ repository's web view -- there is no navigation of ours to keep working in two
20
+ places.
21
+
22
+ The files are flat in this folder rather than in a ``pages/`` subdirectory,
23
+ because the package data is declared as one glob and a subdirectory is exactly
24
+ the kind of thing that ships in a wheel one release and not the next. The
25
+ pictures the pages show sit beside them for the same reason, and the README
26
+ uses the same files, so there is one copy of each.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from importlib import resources
32
+
33
+ #: The front page, and the only name anything outside this module needs.
34
+ MANUAL = "manual.md"
35
+
36
+ #: Every page, the front one first. Listed rather than discovered so that a
37
+ #: build that lost one fails with a name in it: ``--selftest`` checks this list
38
+ #: against what actually shipped, and a manual quietly missing a page
39
+ #: would otherwise be found by a user.
40
+ PAGES = (
41
+ MANUAL,
42
+ "probe.md",
43
+ "files.md",
44
+ "memory-map.md",
45
+ "memory.md",
46
+ "about.md",
47
+ "changelog.md",
48
+ )
49
+
50
+
51
+ def page_text(name: str) -> str:
52
+ """One page as Markdown, or "" if this build did not ship it.
53
+
54
+ Empty rather than raising: a missing page should cost the reader that page,
55
+ not the application. ``pyflashgui --selftest`` fails on it instead, so a
56
+ build that dropped one is caught where it can still be fixed.
57
+
58
+ Only the pages named above are served. The name arrives from a link inside
59
+ a document, and a document is a thing that can be edited: reading whatever
60
+ path it asked for would turn a manual page into a way to open files.
61
+ """
62
+ if name not in PAGES:
63
+ return ""
64
+ try:
65
+ return resources.files(__name__).joinpath(name).read_text(encoding="utf-8")
66
+ except (OSError, ModuleNotFoundError):
67
+ return ""
68
+
69
+
70
+ #: What a picture in the manual may be.
71
+ IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif")
72
+
73
+
74
+ def image_bytes(name: str) -> bytes:
75
+ """A picture a page shows, or b"" if it is not one this build shipped.
76
+
77
+ The same rule as ``page_text``, for the same reason: the name comes from a
78
+ document. Only a plain file name with an image suffix is read, from this
79
+ package and nowhere else -- no folders, and nothing that is not a picture.
80
+ """
81
+ if (
82
+ not name
83
+ or "/" in name
84
+ or "\\" in name
85
+ or name.startswith(".")
86
+ or not name.lower().endswith(IMAGE_SUFFIXES)
87
+ ):
88
+ return b""
89
+ try:
90
+ return resources.files(__name__).joinpath(name).read_bytes()
91
+ except (OSError, ModuleNotFoundError):
92
+ return b""
93
+
94
+
95
+ def images_in(text: str) -> list[str]:
96
+ """Every picture a page shows, by the name it asks for."""
97
+ import re
98
+
99
+ return re.findall(r"!\[[^\]]*\]\(([^)\s]+)", text)
100
+
101
+
102
+ def missing_images() -> list[str]:
103
+ """The pictures some page shows that this build cannot find."""
104
+ return sorted(
105
+ {name for page in PAGES for name in images_in(page_text(page)) if not image_bytes(name)}
106
+ )
107
+
108
+
109
+ def manual_text() -> str:
110
+ """The front page: the contents, and the way in to every other page."""
111
+ return page_text(MANUAL)
112
+
113
+
114
+ def title_of(name: str) -> str:
115
+ """What a page calls itself, taken from its first heading."""
116
+ for line in page_text(name).splitlines():
117
+ if line.startswith("# "):
118
+ return line[2:].strip()
119
+ return name
120
+
121
+
122
+ def all_text() -> str:
123
+ """Every page, joined. For asking whether the manual covers something."""
124
+ return "\n\n".join(page_text(name) for name in PAGES)
125
+
126
+
127
+ def missing_pages() -> list[str]:
128
+ """The pages that ought to have shipped and did not."""
129
+ return [name for name in PAGES if not page_text(name).strip()]
@@ -0,0 +1,21 @@
1
+ [← Contents](manual.md)
2
+
3
+ # About, licences and updates
4
+
5
+ **Help > Documentation** opens this manual. It ships inside pyflashgui; *Back* and *Contents* get you around it.
6
+
7
+ **Help > Diagnostics...** writes a report of the versions of pyflashgui, Python and every package it runs on, and any panes out on their own, to the Event Log and copies it to the clipboard -- ready to paste into a bug report.
8
+
9
+ The report names the version, and -- when pyflashgui is being run from a source checkout rather than an installed build -- the branch or tag and the commit it is on, read from `.git` without running git. Between two releases every build calls itself the same version, so that line is what tells one pull from another. It does not say whether any file has been edited since: that needs git itself, and the launcher runs without a console for it to use.
10
+
11
+ **Help > Check for updates...** asks the question that fits how pyflashgui was installed. The Windows installer's build asks GitHub whether there is a newer release, and offers to open the releases page. A pip installation asks PyPI for its newest version, and says the pip command that updates it. Run from a source folder, it asks about commits instead, since a release is behind everything pulled since: how many newer commits GitHub has on the branch this folder is on, and it offers to open the list of them. A branch GitHub does not have is compared with master.
12
+
13
+ **Help > Licences...** shows pyflashgui's own Apache-2.0 licence, the NOTICE attributions and the full third-party licence text, all shipped with the application.
14
+
15
+ **Help > About pyflashgui** shows the version alongside Python, every package pyflashgui runs on and the platform, in a form you can copy. It also says where it is running from: the Python it started with, that Python's environment, the folder the pyflashgui package was loaded from, and how it got there -- pip, an editable install of a source folder, or the Windows installer. On a computer with more than one Python that answers which one it is.
16
+
17
+ ## If pyflashgui will not start
18
+
19
+ Started from the source folder, `pyflashgui.cmd` and `pyflashgui.sh` install anything missing before they start pyflashgui, and a library that is still missing is reported in a window rather than as nothing happening. On Linux, a library Qt needs to draw the window is named, with the package it comes in, before Qt is started: Qt itself says only that it could not load the "xcb" plugin. `python -m pyflashgui --selftest` imports everything a build is most likely to be missing, checks that pyOCD can find its probe drivers and targets, and exits with 0 if it is all there, which is how the build checks itself.
20
+
21
+ **If pyflashgui is slow to start**, the diagnostics report says where the seconds went: starting is measured on every run, step by step and package by package, so the breakdown is already there when somebody thinks to ask. A start that takes longer than usual says so in the Event Log and points to *Help > Diagnostics*. The first start after an update is the usual one: Python compiles every changed file again, and that start says how many it compiled. To have the whole report in the Event Log after every start, and in `startup-timing.txt` in pyflashgui's own folder, start pyflashgui with `--timing` -- `pyflashgui.cmd --timing`, or `pyflashgui.sh --timing` -- or set `PYFLASHGUI_TIMING=1`.