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,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()]
|
pyflashgui/help/about.md
ADDED
|
@@ -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`.
|