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,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Where pyflashgui is running from: which Python, which environment, which copy.
|
|
4
|
+
|
|
5
|
+
For About and the diagnostics report. A machine with more than one Python --
|
|
6
|
+
one in Program Files, one in the user's folder, a virtual environment made
|
|
7
|
+
for testing -- can start pyflashgui from any of them, and the version numbers
|
|
8
|
+
alone say nothing about which. Worse, ``python -m pyflashgui`` run inside a
|
|
9
|
+
source folder imports the package from that folder whether or not the
|
|
10
|
+
environment has pyflashgui installed, and an installed copy in the same
|
|
11
|
+
environment is then silently not the one running.
|
|
12
|
+
|
|
13
|
+
Read from the interpreter and the package metadata, nothing imported or run.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import site
|
|
20
|
+
import sys
|
|
21
|
+
from importlib import metadata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import unquote, urlparse
|
|
24
|
+
|
|
25
|
+
import pyflashgui
|
|
26
|
+
|
|
27
|
+
#: The folder the running pyflashgui package was imported from.
|
|
28
|
+
PACKAGE = Path(pyflashgui.__file__).resolve().parent
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def in_virtual_environment() -> bool:
|
|
32
|
+
return sys.prefix != sys.base_prefix
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def site_folders() -> list[str]:
|
|
36
|
+
"""Where this environment installs packages, the user's own folder included."""
|
|
37
|
+
folders = list(site.getsitepackages())
|
|
38
|
+
if site.ENABLE_USER_SITE:
|
|
39
|
+
folders.append(site.getusersitepackages())
|
|
40
|
+
return folders
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def installed(folders: list[str] | None = None) -> metadata.Distribution | None:
|
|
44
|
+
"""pyflashgui as this environment has it installed, or None.
|
|
45
|
+
|
|
46
|
+
Looked for in the environment's own folders only. The whole of sys.path
|
|
47
|
+
would include the folder Python was started in, and a source folder has
|
|
48
|
+
setuptools' ``pyflashgui.egg-info`` lying in it from a build, which is not
|
|
49
|
+
an installation of anything.
|
|
50
|
+
"""
|
|
51
|
+
found = metadata.distributions(name="pyflashgui", path=folders or site_folders())
|
|
52
|
+
return next(iter(found), None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _editable_root(dist: metadata.Distribution) -> Path | None:
|
|
56
|
+
"""The source folder of an editable install, from pip's direct_url.json."""
|
|
57
|
+
try:
|
|
58
|
+
said = json.loads(dist.read_text("direct_url.json") or "{}")
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
if not said.get("dir_info", {}).get("editable"):
|
|
62
|
+
return None
|
|
63
|
+
url = urlparse(said.get("url", ""))
|
|
64
|
+
if url.scheme != "file":
|
|
65
|
+
return None
|
|
66
|
+
path = unquote(url.path)
|
|
67
|
+
# file:///C:/Git/pyflashgui parses to /C:/Git/pyflashgui on Windows.
|
|
68
|
+
if len(path) > 2 and path[0] == "/" and path[2] == ":":
|
|
69
|
+
path = path[1:]
|
|
70
|
+
return Path(url.netloc + path if url.netloc else path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: How the running copy got here: what origin() answers.
|
|
74
|
+
FROZEN = "frozen" # the Windows installer's build
|
|
75
|
+
PIP = "pip" # installed with pip, and the copy running
|
|
76
|
+
EDITABLE = "editable" # an editable install of a source folder, and the copy running
|
|
77
|
+
NOT_INSTALLED = "not installed" # nothing in this environment: a folder's copy
|
|
78
|
+
OTHER_PIP = "other pip" # a folder's copy, beside a pip install that is not running
|
|
79
|
+
OTHER_EDITABLE = "other editable" # a folder's copy, beside an editable install of another
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def origin(package: Path = PACKAGE) -> tuple[str, Path | None, str]:
|
|
83
|
+
"""(kind, the editable install's source folder, the installed version).
|
|
84
|
+
|
|
85
|
+
The case worth catching is the running copy not being the installed one:
|
|
86
|
+
started from inside a source folder, Python imports the folder, and the
|
|
87
|
+
environment's own pyflashgui -- or the absence of one -- is not what is
|
|
88
|
+
running.
|
|
89
|
+
"""
|
|
90
|
+
if getattr(sys, "frozen", False):
|
|
91
|
+
return FROZEN, None, ""
|
|
92
|
+
if (dist := installed()) is None:
|
|
93
|
+
return NOT_INSTALLED, None, ""
|
|
94
|
+
if (root := _editable_root(dist)) is not None:
|
|
95
|
+
return (EDITABLE if _same(root / "pyflashgui", package) else OTHER_EDITABLE), root, ""
|
|
96
|
+
if _same(Path(str(dist.locate_file("pyflashgui"))), package):
|
|
97
|
+
return PIP, None, dist.version
|
|
98
|
+
return OTHER_PIP, None, dist.version
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def how_installed(package: Path = PACKAGE) -> str:
|
|
102
|
+
"""How the running copy of pyflashgui got here, as a report would say it."""
|
|
103
|
+
kind, root, version = origin(package)
|
|
104
|
+
return {
|
|
105
|
+
FROZEN: "the Windows installer's build",
|
|
106
|
+
PIP: "pip",
|
|
107
|
+
EDITABLE: f"pip, editable, from {root}",
|
|
108
|
+
NOT_INSTALLED: "not installed in this environment: imported from the folder above",
|
|
109
|
+
OTHER_PIP: (
|
|
110
|
+
f"imported from the folder above, not the pip install ({version}) in this environment"
|
|
111
|
+
),
|
|
112
|
+
OTHER_EDITABLE: (
|
|
113
|
+
f"imported from the folder above, not the editable install of {root} "
|
|
114
|
+
"in this environment"
|
|
115
|
+
),
|
|
116
|
+
}[kind]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _same(a: Path, b: Path) -> bool:
|
|
120
|
+
try:
|
|
121
|
+
return a.resolve() == b.resolve()
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def report() -> list[tuple[str, str]]:
|
|
127
|
+
"""(label, value) lines: the interpreter, its environment, and the package."""
|
|
128
|
+
if getattr(sys, "frozen", False):
|
|
129
|
+
return [("Installed", f"{how_installed()}, in {Path(sys.executable).parent}")]
|
|
130
|
+
lines = [("Interpreter", sys.executable)]
|
|
131
|
+
if in_virtual_environment():
|
|
132
|
+
lines.append(("Environment", f"{sys.prefix} (made from {sys.base_prefix})"))
|
|
133
|
+
else:
|
|
134
|
+
lines.append(("Environment", f"{sys.prefix} (no virtual environment)"))
|
|
135
|
+
lines.append(("Package", str(PACKAGE)))
|
|
136
|
+
lines.append(("Installed", how_installed()))
|
|
137
|
+
return lines
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""The dock arrangement, kept with the workspace rather than in QSettings.
|
|
4
|
+
|
|
5
|
+
Qt hands out an opaque block of bytes for a window's dock layout and for a
|
|
6
|
+
splitter's position, and the obvious home for those is QSettings, which is
|
|
7
|
+
where they used to live. But QSettings is a per-machine store -- a registry
|
|
8
|
+
hive on Windows -- and a workspace has to be one folder: something that can be
|
|
9
|
+
copied to a backup, kept in version control, or sent to someone else whole. A layout left
|
|
10
|
+
behind in the registry would be the one part of a workspace that could not travel.
|
|
11
|
+
|
|
12
|
+
So the bytes go in a small JSON file beside the settings, base64 encoded
|
|
13
|
+
because that is what JSON can carry. Nobody is meant to read them; what
|
|
14
|
+
matters is that they are *in* the folder.
|
|
15
|
+
|
|
16
|
+
What stays in QSettings is where the window sits on the screen, which belongs
|
|
17
|
+
to this desk and these monitors rather than to the work: switching product
|
|
18
|
+
should rearrange the panes, not move the window to where it was when somebody
|
|
19
|
+
last used that product on a different machine.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import json
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Layout:
|
|
30
|
+
"""Opaque Qt state, by key, saved in one file with the workspace."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: Path) -> None:
|
|
33
|
+
self._path = path
|
|
34
|
+
self._data: dict[str, str] = {}
|
|
35
|
+
if path.exists():
|
|
36
|
+
try:
|
|
37
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
self._data = {k: v for k, v in loaded.items() if isinstance(v, str)}
|
|
39
|
+
except (OSError, ValueError, AttributeError):
|
|
40
|
+
self._data = {} # a truncated write, or a hand-edited file
|
|
41
|
+
|
|
42
|
+
def get(self, key: str) -> bytes | None:
|
|
43
|
+
"""The bytes stored under this key, or None if there are none.
|
|
44
|
+
|
|
45
|
+
None rather than empty bytes: Qt's restoreState treats an empty state
|
|
46
|
+
as a state, and answers it by leaving the window with no docks at all.
|
|
47
|
+
"""
|
|
48
|
+
encoded = self._data.get(key)
|
|
49
|
+
if not encoded:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return base64.b64decode(encoded)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def set(self, key: str, value: bytes | None) -> None:
|
|
57
|
+
if value is None or len(value) == 0:
|
|
58
|
+
self.remove(key)
|
|
59
|
+
return
|
|
60
|
+
self._data[key] = base64.b64encode(bytes(value)).decode("ascii")
|
|
61
|
+
self.save()
|
|
62
|
+
|
|
63
|
+
def remove(self, key: str) -> None:
|
|
64
|
+
if self._data.pop(key, None) is not None:
|
|
65
|
+
self.save()
|
|
66
|
+
|
|
67
|
+
def keys(self) -> list[str]:
|
|
68
|
+
return sorted(self._data)
|
|
69
|
+
|
|
70
|
+
def save(self) -> None:
|
|
71
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._path.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8")
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""What will be written where: firmware images laid over a target's memory.
|
|
4
|
+
|
|
5
|
+
Worked out before anything is sent, from the same bytes that will be sent.
|
|
6
|
+
A file linked for the wrong part, a binary given the wrong address and two
|
|
7
|
+
files that both claim the same sector are all visible here, where finding
|
|
8
|
+
them costs nothing, rather than part way through programming a board.
|
|
9
|
+
|
|
10
|
+
Nothing here touches a probe or Qt. It is handed images and regions and
|
|
11
|
+
answers with plain data, which is what makes it testable without either.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from pyflashgui.core.events import ERROR, WARNING
|
|
20
|
+
from pyflashgui.core.images import Image
|
|
21
|
+
|
|
22
|
+
FLASH = "flash"
|
|
23
|
+
RAM = "ram"
|
|
24
|
+
ROM = "rom"
|
|
25
|
+
OTHER = "other"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Region:
|
|
30
|
+
"""One stretch of a target's memory, and what kind of memory it is."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
start: int
|
|
34
|
+
length: int
|
|
35
|
+
kind: str # one of FLASH, RAM, ROM, OTHER
|
|
36
|
+
#: What is erased at a time, in bytes. 0 where nothing is: RAM, ROM.
|
|
37
|
+
sector_size: int = 0
|
|
38
|
+
#: What is programmed at a time, in bytes. 0 where it does not apply.
|
|
39
|
+
page_size: int = 0
|
|
40
|
+
#: The region this one is another address for, if it is one. A device may
|
|
41
|
+
#: show the same flash at several addresses and take writes at only one.
|
|
42
|
+
alias_of: str = ""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def end(self) -> int:
|
|
46
|
+
"""The address after the last byte."""
|
|
47
|
+
return self.start + self.length
|
|
48
|
+
|
|
49
|
+
def __contains__(self, address: int) -> bool:
|
|
50
|
+
return self.start <= address < self.end
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Piece:
|
|
55
|
+
"""Part of one file's data that falls in one region, or in none."""
|
|
56
|
+
|
|
57
|
+
source: str # the file's name
|
|
58
|
+
address: int
|
|
59
|
+
length: int
|
|
60
|
+
region: Region | None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def end(self) -> int:
|
|
64
|
+
return self.address + self.length
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Usage:
|
|
69
|
+
"""How much of one flash region the files take up."""
|
|
70
|
+
|
|
71
|
+
region: Region
|
|
72
|
+
used: int # bytes the files write
|
|
73
|
+
sectors: int # sectors those bytes touch, each of which is erased whole
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def erased(self) -> int:
|
|
77
|
+
"""Bytes erased to make room: every touched sector, all of it."""
|
|
78
|
+
return self.sectors * self.region.sector_size
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def fraction(self) -> float:
|
|
82
|
+
return self.used / self.region.length if self.region.length else 0.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Problem:
|
|
87
|
+
"""Something about the layout that needs looking at before programming."""
|
|
88
|
+
|
|
89
|
+
level: str # WARNING or ERROR, as the Event Log has them
|
|
90
|
+
message: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Layout:
|
|
95
|
+
"""Every piece of every file, where it lands, and what is wrong with that."""
|
|
96
|
+
|
|
97
|
+
pieces: list[Piece] = field(default_factory=list)
|
|
98
|
+
problems: list[Problem] = field(default_factory=list)
|
|
99
|
+
usage: list[Usage] = field(default_factory=list)
|
|
100
|
+
#: False when the target's memory is not known, so nothing could be
|
|
101
|
+
#: checked against it: the pieces are listed and none is called wrong.
|
|
102
|
+
checked: bool = True
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def programmable(self) -> bool:
|
|
106
|
+
"""Whether programming can go ahead: nothing here is an error."""
|
|
107
|
+
return not any(p.level == ERROR for p in self.problems)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def region_at(regions: Sequence[Region], address: int) -> Region | None:
|
|
111
|
+
for region in regions:
|
|
112
|
+
if address in region:
|
|
113
|
+
return region
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _split(source: str, address: int, length: int, regions: Sequence[Region]) -> list[Piece]:
|
|
118
|
+
"""One run of bytes, cut wherever it crosses from one region to another."""
|
|
119
|
+
pieces = []
|
|
120
|
+
end = address + length
|
|
121
|
+
while address < end:
|
|
122
|
+
region = region_at(regions, address)
|
|
123
|
+
if region is not None:
|
|
124
|
+
stop = min(end, region.end)
|
|
125
|
+
else:
|
|
126
|
+
# Up to the next region that starts inside the run, or all of it.
|
|
127
|
+
starts = [r.start for r in regions if address < r.start < end]
|
|
128
|
+
stop = min(starts, default=end)
|
|
129
|
+
pieces.append(Piece(source, address, stop - address, region))
|
|
130
|
+
address = stop
|
|
131
|
+
return pieces
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _span(address: int, end: int) -> str:
|
|
135
|
+
return f"{address:08X} to {end - 1:08X}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _bytes(count: int) -> str:
|
|
139
|
+
return "1 byte" if count == 1 else f"{count} bytes"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _overlaps(pieces: Iterable[Piece]) -> list[Problem]:
|
|
143
|
+
"""Where two runs of bytes claim the same addresses.
|
|
144
|
+
|
|
145
|
+
An error whichever files they are from: the second to be written wins, and
|
|
146
|
+
which is second is not something anybody chose.
|
|
147
|
+
"""
|
|
148
|
+
problems = []
|
|
149
|
+
ordered = sorted(pieces, key=lambda p: (p.address, p.end))
|
|
150
|
+
reach: Piece | None = None # the piece seen so far that ends furthest on
|
|
151
|
+
for piece in ordered:
|
|
152
|
+
if reach is not None and piece.address < reach.end:
|
|
153
|
+
end = min(piece.end, reach.end)
|
|
154
|
+
who = (
|
|
155
|
+
f"{reach.source} and {piece.source} both write"
|
|
156
|
+
if reach.source != piece.source
|
|
157
|
+
else f"{piece.source} writes twice to"
|
|
158
|
+
)
|
|
159
|
+
problems.append(Problem(ERROR, f"{who} {_span(piece.address, end)}."))
|
|
160
|
+
if reach is None or piece.end > reach.end:
|
|
161
|
+
reach = piece
|
|
162
|
+
return problems
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _placement(piece: Piece, complete: bool = True) -> Problem | None:
|
|
166
|
+
"""What is wrong with where one piece lands, if anything is."""
|
|
167
|
+
what = f"{piece.source}: {_bytes(piece.length)} at {_span(piece.address, piece.end)}"
|
|
168
|
+
region = piece.region
|
|
169
|
+
if region is None:
|
|
170
|
+
if not complete:
|
|
171
|
+
return Problem(
|
|
172
|
+
WARNING,
|
|
173
|
+
f"{what} are outside the memory listed for this target, which may not "
|
|
174
|
+
"be all of it.",
|
|
175
|
+
)
|
|
176
|
+
return Problem(ERROR, f"{what} are outside the target's memory.")
|
|
177
|
+
if region.alias_of:
|
|
178
|
+
return Problem(
|
|
179
|
+
ERROR,
|
|
180
|
+
f"{what} are in {region.name}, which is another address for "
|
|
181
|
+
f"{region.alias_of} and cannot be programmed through.",
|
|
182
|
+
)
|
|
183
|
+
if region.kind == RAM:
|
|
184
|
+
# Not refused: loading a program into RAM to run it is a real thing
|
|
185
|
+
# to do. But it is not what a file meant for flash should be doing.
|
|
186
|
+
return Problem(
|
|
187
|
+
WARNING, f"{what} are in RAM ({region.name}), and will be lost at the next reset."
|
|
188
|
+
)
|
|
189
|
+
if region.kind != FLASH:
|
|
190
|
+
return Problem(ERROR, f"{what} are in {region.name}, which cannot be written.")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _usage(pieces: Iterable[Piece], regions: Sequence[Region]) -> list[Usage]:
|
|
195
|
+
"""What each flash region holds, for the ones anything is written to."""
|
|
196
|
+
out = []
|
|
197
|
+
for region in regions:
|
|
198
|
+
if region.kind != FLASH or region.alias_of:
|
|
199
|
+
continue
|
|
200
|
+
mine = [p for p in pieces if p.region is region]
|
|
201
|
+
if not mine:
|
|
202
|
+
continue
|
|
203
|
+
sectors: set[int] = set()
|
|
204
|
+
if region.sector_size:
|
|
205
|
+
for piece in mine:
|
|
206
|
+
first = (piece.address - region.start) // region.sector_size
|
|
207
|
+
last = (piece.end - 1 - region.start) // region.sector_size
|
|
208
|
+
sectors.update(range(first, last + 1))
|
|
209
|
+
out.append(Usage(region, used=sum(p.length for p in mine), sectors=len(sectors)))
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lay_out(images: Iterable[Image], regions: Sequence[Region], complete: bool = True) -> Layout:
|
|
214
|
+
"""Where every byte of these files would go on a target with this memory.
|
|
215
|
+
|
|
216
|
+
``complete`` says whether the regions are every address the target can
|
|
217
|
+
be written at. Where they may not be, bytes outside them are a warning
|
|
218
|
+
and not an error: refusing a file that the target would have taken is
|
|
219
|
+
worse than passing on one that it will refuse for itself.
|
|
220
|
+
|
|
221
|
+
With no regions -- a target whose memory is not known until it is
|
|
222
|
+
connected to -- the pieces are still listed and overlaps still found, and
|
|
223
|
+
``checked`` says that nothing was compared with the target.
|
|
224
|
+
"""
|
|
225
|
+
layout = Layout(checked=bool(regions))
|
|
226
|
+
for image in images:
|
|
227
|
+
for segment in image.segments:
|
|
228
|
+
layout.pieces += _split(image.name, segment.address, len(segment), regions)
|
|
229
|
+
layout.problems += _overlaps(layout.pieces)
|
|
230
|
+
if layout.checked:
|
|
231
|
+
found = (_placement(piece, complete) for piece in layout.pieces)
|
|
232
|
+
layout.problems += [p for p in found if p is not None]
|
|
233
|
+
layout.usage = _usage(layout.pieces, regions)
|
|
234
|
+
return layout
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Which packages pyflashgui runs on, and which versions of them are here.
|
|
4
|
+
|
|
5
|
+
For About and the diagnostics report. The list is read rather than written
|
|
6
|
+
down here, so it cannot fall behind: a source folder's ``pyproject.toml`` is
|
|
7
|
+
what the launcher installs from, and anything else -- a pip installation, or
|
|
8
|
+
the Windows build, which carries the metadata for this -- has the installed
|
|
9
|
+
package's own record of what it requires.
|
|
10
|
+
|
|
11
|
+
Nothing is imported to find a version. The metadata says, and importing some
|
|
12
|
+
of these (pyOCD brings a disassembler) would take longer than the report does.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import tomllib
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from importlib import metadata
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
#: The folder pyproject.toml is in, for a source folder.
|
|
24
|
+
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
25
|
+
|
|
26
|
+
_REQUIREMENT = re.compile(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[([^\]]*)\])?")
|
|
27
|
+
_EXTRA = re.compile(r"""extra\s*==\s*["']([^"']+)["']""")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Requirement:
|
|
31
|
+
"""One line of a requirements list: a name, the extras asked of it, and
|
|
32
|
+
the extra it belongs to, if any."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, text: str) -> None:
|
|
35
|
+
spec, _, marker = text.partition(";")
|
|
36
|
+
found = _REQUIREMENT.match(spec)
|
|
37
|
+
if found is None:
|
|
38
|
+
raise ValueError(f"not a requirement: {text!r}")
|
|
39
|
+
self.name = found.group(1)
|
|
40
|
+
self.extras = {e.strip() for e in (found.group(2) or "").split(",") if e.strip()}
|
|
41
|
+
extra = _EXTRA.search(marker)
|
|
42
|
+
self.extra = extra.group(1) if extra else None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def key(self) -> str:
|
|
46
|
+
"""The name as pip compares them: PySide6_Essentials is PySide6-Essentials."""
|
|
47
|
+
return normalise(self.name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalise(name: str) -> str:
|
|
51
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def declared(root: Path = ROOT) -> list[Requirement]:
|
|
55
|
+
"""pyflashgui's own requirements: the dependencies, then the "all" extra."""
|
|
56
|
+
pyproject = root / "pyproject.toml"
|
|
57
|
+
try:
|
|
58
|
+
project = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]
|
|
59
|
+
lines = list(project["dependencies"])
|
|
60
|
+
optional = project.get("optional-dependencies", {}).get("all", [])
|
|
61
|
+
lines += [f'{line}; extra == "all"' for line in optional]
|
|
62
|
+
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
|
63
|
+
lines = metadata.requires("pyflashgui") or []
|
|
64
|
+
# Dev tools are the contributor's, not the application's.
|
|
65
|
+
return [r for r in map(Requirement, lines) if r.extra in (None, "all")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def version(name: str) -> str | None:
|
|
69
|
+
try:
|
|
70
|
+
return metadata.version(name)
|
|
71
|
+
except metadata.PackageNotFoundError:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pulled_in(
|
|
76
|
+
direct: list[Requirement],
|
|
77
|
+
requires: Callable[[str], list[str] | None] = metadata.requires,
|
|
78
|
+
have: Callable[[str], str | None] = version,
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
"""What the direct requirements brought with them, by name, installed ones only.
|
|
81
|
+
|
|
82
|
+
A requirement behind an environment marker -- Windows only, an older
|
|
83
|
+
Python -- is counted when it is installed and left out when it is not,
|
|
84
|
+
which answers the marker without having to evaluate it.
|
|
85
|
+
"""
|
|
86
|
+
seen = {r.key for r in direct}
|
|
87
|
+
found: dict[str, str] = {}
|
|
88
|
+
queue = [r for r in direct if have(r.name)]
|
|
89
|
+
while queue:
|
|
90
|
+
parent = queue.pop()
|
|
91
|
+
try:
|
|
92
|
+
lines = requires(parent.name) or []
|
|
93
|
+
except metadata.PackageNotFoundError:
|
|
94
|
+
continue
|
|
95
|
+
for line in lines:
|
|
96
|
+
child = Requirement(line)
|
|
97
|
+
if child.extra is not None and child.extra not in parent.extras:
|
|
98
|
+
continue
|
|
99
|
+
if child.key in seen or not have(child.name):
|
|
100
|
+
continue
|
|
101
|
+
seen.add(child.key)
|
|
102
|
+
found[child.key] = child.name
|
|
103
|
+
queue.append(child)
|
|
104
|
+
return sorted(found.values(), key=str.lower)
|
pyflashgui/core/paths.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Where the machine's own things live.
|
|
4
|
+
|
|
5
|
+
The settings and the layout live in a workspace -- see ``core/workspaces.py``
|
|
6
|
+
-- because they are about the product being worked on rather than about this
|
|
7
|
+
computer. What is here is the folder the workspaces all sit under.
|
|
8
|
+
|
|
9
|
+
Windows: %APPDATA%\\pyflashgui (e.g. C:\\Users\\you\\AppData\\Roaming\\pyflashgui)
|
|
10
|
+
Linux: $XDG_CONFIG_HOME/pyflashgui (default ~/.config/pyflashgui)
|
|
11
|
+
macOS: ~/Library/Application Support/pyflashgui
|
|
12
|
+
|
|
13
|
+
Set ``PYFLASHGUI_HOME`` to override (used by the tests).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pyflashgui import APP_NAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def made(path: Path) -> Path:
|
|
26
|
+
"""The folder, made if it is not there. Asked about before it is made.
|
|
27
|
+
|
|
28
|
+
``mkdir(exist_ok=True)`` is one call and looks cheaper than a question
|
|
29
|
+
followed by a call, and on a local disc it is. On a profile redirected
|
|
30
|
+
to a network share, or one a scanner is watching, creating a directory
|
|
31
|
+
is the expensive operation and asking whether it exists is not: half a
|
|
32
|
+
second went on two of these at startup on such a machine, and these
|
|
33
|
+
functions are called all over the place.
|
|
34
|
+
"""
|
|
35
|
+
if not path.is_dir():
|
|
36
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def user_dir() -> Path:
|
|
41
|
+
if override := os.environ.get("PYFLASHGUI_HOME"):
|
|
42
|
+
base = Path(override)
|
|
43
|
+
elif sys.platform == "win32":
|
|
44
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / APP_NAME
|
|
45
|
+
elif sys.platform == "darwin":
|
|
46
|
+
base = Path.home() / "Library" / "Application Support" / APP_NAME
|
|
47
|
+
else:
|
|
48
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
|
49
|
+
return made(base)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Persistent key/value store for things the GUI remembers (the target, the
|
|
4
|
+
last used probe, ...). One JSON file, dotted keys, saved on change.
|
|
5
|
+
|
|
6
|
+
Kept separate from QSettings (which holds window layout) so the contents are
|
|
7
|
+
human readable and easy to hand-edit or version control.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Settings:
|
|
18
|
+
def __init__(self, path: Path) -> None:
|
|
19
|
+
self._path = path
|
|
20
|
+
self._data: dict[str, Any] = {}
|
|
21
|
+
if path.exists():
|
|
22
|
+
try:
|
|
23
|
+
self._data = json.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
self._data = {}
|
|
26
|
+
|
|
27
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
28
|
+
return self._data.get(key, default)
|
|
29
|
+
|
|
30
|
+
def set(self, key: str, value: Any) -> None:
|
|
31
|
+
self._data[key] = value
|
|
32
|
+
self.save()
|
|
33
|
+
|
|
34
|
+
def keys(self) -> list[str]:
|
|
35
|
+
return sorted(self._data)
|
|
36
|
+
|
|
37
|
+
def remove(self, key: str) -> None:
|
|
38
|
+
"""Forget a setting, so that whatever default applies applies again."""
|
|
39
|
+
if self._data.pop(key, None) is not None:
|
|
40
|
+
self.save()
|
|
41
|
+
|
|
42
|
+
def save(self) -> None:
|
|
43
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
self._path.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8")
|