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,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)
@@ -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")