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,271 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Probes and targets through pyOCD.
4
+
5
+ Kept thin on purpose: each method is the pyOCD call that does it, with the
6
+ exception said again in pyflashgui's words. Anything worth testing without a
7
+ probe plugged in is in ``core`` or in the base class, and what is left here
8
+ can only be tried on hardware.
9
+
10
+ pyOCD is imported inside the methods that use it. It is the slowest thing
11
+ pyflashgui loads, and a window should not wait for it to say which probes
12
+ are plugged in before it can be drawn.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+
19
+ from pyflashgui.backends.base import (
20
+ ERASE_CHIP,
21
+ ERASE_SECTORS,
22
+ Backend,
23
+ BackendError,
24
+ Connection,
25
+ ProbeInfo,
26
+ Progress,
27
+ TargetInfo,
28
+ _nothing,
29
+ )
30
+ from pyflashgui.core.images import Segment
31
+ from pyflashgui.core.memory_map import FLASH, OTHER, RAM, ROM, Region
32
+
33
+ #: What pyOCD's loader calls the two ways of erasing.
34
+ _CHIP_ERASE = {ERASE_SECTORS: "sector", ERASE_CHIP: "chip"}
35
+
36
+
37
+ def _kind(region) -> str:
38
+ """One of pyflashgui's kinds of memory, for one of pyOCD's regions."""
39
+ if region.is_flash:
40
+ return FLASH
41
+ if region.is_ram:
42
+ return RAM
43
+ if region.is_rom:
44
+ return ROM
45
+ return OTHER
46
+
47
+
48
+ def regions_of(memory_map) -> list[Region]:
49
+ """A pyOCD memory map as pyflashgui's own regions, lowest address first."""
50
+ out = []
51
+ for region in memory_map:
52
+ alias = region.alias
53
+ out.append(
54
+ Region(
55
+ name=region.name,
56
+ start=region.start,
57
+ length=region.length,
58
+ kind=_kind(region),
59
+ # pyOCD's "blocksize" is what is erased at a time, for flash.
60
+ sector_size=int(getattr(region, "blocksize", 0) or 0) if region.is_flash else 0,
61
+ page_size=int(getattr(region, "page_size", 0) or 0) if region.is_flash else 0,
62
+ # An alias is given as the region itself, or as its name.
63
+ alias_of=getattr(alias, "name", alias) or "",
64
+ )
65
+ )
66
+ return sorted(out, key=lambda r: r.start)
67
+
68
+
69
+ def needs_one_packet_at_a_time(*names: str | None) -> bool:
70
+ """Whether a probe called this must be sent one command and then waited for.
71
+
72
+ pyOCD's own advice for the TI XDS110, whose firmware loses track when
73
+ several commands are outstanding. Set for every one of them, since it
74
+ is not known which versions are free of it, and the cost is only speed.
75
+ """
76
+ return any("xds110" in (name or "").lower() for name in names)
77
+
78
+
79
+ def _said(doing: str, exc: Exception) -> BackendError:
80
+ """A library's exception as a sentence about what was being attempted."""
81
+ detail = str(exc).strip() or type(exc).__name__
82
+ return BackendError(f"{doing} failed: {detail}")
83
+
84
+
85
+ class PyocdConnection(Connection):
86
+ def __init__(self, session) -> None:
87
+ self._session = session
88
+
89
+ def _target(self):
90
+ if self._session is None:
91
+ raise BackendError("This connection has been closed.")
92
+ return self._session.target
93
+
94
+ def regions(self) -> list[Region]:
95
+ return regions_of(self._target().memory_map)
96
+
97
+ def erase_chip(self) -> None:
98
+ from pyocd.flash.eraser import FlashEraser
99
+
100
+ self._target()
101
+ try:
102
+ FlashEraser(self._session, FlashEraser.Mode.CHIP).erase()
103
+ except Exception as exc:
104
+ raise _said("Erasing the flash", exc) from exc
105
+
106
+ def erase_ranges(self, ranges: Sequence[tuple[int, int]]) -> None:
107
+ from pyocd.flash.eraser import FlashEraser
108
+
109
+ flash = [r for r in self.regions() if r.kind == FLASH and not r.alias_of]
110
+ for start, length in ranges:
111
+ # Checked here: asked to erase what is not flash, pyOCD stops
112
+ # with a line in its own log and no error, having erased
113
+ # nothing, and that would be reported as done.
114
+ if not any(r.start <= start and start + length <= r.end for r in flash):
115
+ raise BackendError(
116
+ f"{start:08X} to {start + length - 1:08X} is not all within one "
117
+ "region of the target's flash."
118
+ )
119
+ try:
120
+ # pyOCD takes where each range starts and where it stops.
121
+ FlashEraser(self._session, FlashEraser.Mode.SECTOR).erase(
122
+ [(start, start + length) for start, length in ranges]
123
+ )
124
+ except Exception as exc:
125
+ raise _said("Erasing the flash", exc) from exc
126
+
127
+ def program(
128
+ self,
129
+ segments: Sequence[Segment],
130
+ erase: str = ERASE_SECTORS,
131
+ progress: Progress = _nothing,
132
+ ) -> None:
133
+ from pyocd.flash.loader import MemoryLoader
134
+
135
+ self._target()
136
+ if erase not in _CHIP_ERASE:
137
+ raise BackendError(f"There is no way of erasing called {erase!r}.")
138
+ try:
139
+ # smart_flash off: with it, pyOCD reads the flash first and
140
+ # leaves alone a page it judges unchanged. Every byte in the file
141
+ # is written here, and verifying afterwards says whether it took.
142
+ loader = MemoryLoader(
143
+ self._session,
144
+ progress=progress,
145
+ chip_erase=_CHIP_ERASE[erase],
146
+ smart_flash=False,
147
+ )
148
+ for segment in segments:
149
+ loader.add_data(segment.address, segment.data)
150
+ loader.commit()
151
+ except Exception as exc:
152
+ raise _said("Programming", exc) from exc
153
+
154
+ def read(self, address: int, length: int) -> bytes:
155
+ target = self._target()
156
+ try:
157
+ return bytes(target.read_memory_block8(address, length))
158
+ except Exception as exc:
159
+ raise _said(f"Reading {length} bytes at {address:08X}", exc) from exc
160
+
161
+ def reset(self, halt: bool = False) -> None:
162
+ target = self._target()
163
+ try:
164
+ if halt:
165
+ target.reset_and_halt()
166
+ else:
167
+ target.reset()
168
+ except Exception as exc:
169
+ raise _said("Resetting the target", exc) from exc
170
+
171
+ def close(self) -> None:
172
+ session, self._session = self._session, None
173
+ if session is not None:
174
+ try:
175
+ session.close()
176
+ except Exception as exc:
177
+ raise _said("Disconnecting", exc) from exc
178
+
179
+
180
+ class PyocdBackend(Backend):
181
+ name = "pyocd"
182
+ label = "pyOCD"
183
+
184
+ def probes(self) -> list[ProbeInfo]:
185
+ from pyocd.core.helpers import ConnectHelper
186
+
187
+ try:
188
+ found = ConnectHelper.get_all_connected_probes(blocking=False)
189
+ except Exception as exc:
190
+ raise _said("Looking for probes", exc) from exc
191
+ return [
192
+ ProbeInfo(
193
+ unique_id=probe.unique_id,
194
+ description=probe.description,
195
+ vendor=probe.vendor_name or "",
196
+ product=probe.product_name or "",
197
+ )
198
+ for probe in found
199
+ ]
200
+
201
+ def targets(self) -> list[TargetInfo]:
202
+ from pyocd.target import TARGET
203
+
204
+ out = [
205
+ TargetInfo(name, vendor=getattr(cls, "VENDOR", "") or "", source="built in")
206
+ for name, cls in TARGET.items()
207
+ ]
208
+ seen = {t.name for t in out}
209
+ for device in self._pack_devices():
210
+ name = device.part_number.lower()
211
+ if name not in seen:
212
+ seen.add(name)
213
+ out.append(TargetInfo(name, vendor=device.vendor or "", source="pack"))
214
+ return sorted(out, key=lambda t: t.name)
215
+
216
+ @staticmethod
217
+ def _pack_devices() -> list:
218
+ """The devices in the CMSIS-Packs installed here. None is not a fault."""
219
+ try:
220
+ from pyocd.target.pack.pack_target import ManagedPacks
221
+
222
+ return list(ManagedPacks.get_installed_targets())
223
+ except Exception:
224
+ # A pack index that is missing or damaged must not cost the
225
+ # built-in targets, which need no pack at all.
226
+ return []
227
+
228
+ def regions(self, target: str) -> list[Region]:
229
+ from pyocd.target import TARGET
230
+
231
+ cls = TARGET.get(target.lower())
232
+ memory_map = getattr(cls, "MEMORY_MAP", None)
233
+ # A built-in target carries its memory map on the class. A pack's is
234
+ # read from the pack as the target is made, which needs a connection.
235
+ return regions_of(memory_map) if memory_map is not None else []
236
+
237
+ def connect(
238
+ self,
239
+ probe: str,
240
+ target: str,
241
+ frequency: int | None = None,
242
+ interface: str | None = None,
243
+ ) -> Connection:
244
+ from pyocd.core.helpers import ConnectHelper
245
+ from pyocd.core.session import Session
246
+
247
+ try:
248
+ found = ConnectHelper.get_all_connected_probes(blocking=False, unique_id=probe)
249
+ except Exception as exc:
250
+ raise _said("Looking for the probe", exc) from exc
251
+ # Asked for by the whole id. pyOCD matches any id that *contains*
252
+ # what it is given, and given two would ask on a console which was
253
+ # meant -- a console pyflashgui does not have.
254
+ chosen = next((p for p in found if p.unique_id == probe), None)
255
+ if chosen is None:
256
+ raise BackendError(f"The probe {probe} is not plugged in.")
257
+ options: dict = {
258
+ "target_override": target,
259
+ # pyflashgui reports progress itself, through the callback.
260
+ "hide_programming_progress": True,
261
+ }
262
+ if frequency is not None:
263
+ options["frequency"] = int(frequency)
264
+ if needs_one_packet_at_a_time(chosen.description, chosen.product_name):
265
+ options["cmsis_dap.limit_packets"] = True
266
+ try:
267
+ session = Session(chosen, auto_open=False, options=options)
268
+ session.open()
269
+ except Exception as exc:
270
+ raise _said(f"Connecting to {target}", exc) from exc
271
+ return PyocdConnection(session)
@@ -0,0 +1,240 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """A probe and a target that exist only in memory.
4
+
5
+ For the tests, which cannot have a board plugged in, and for working on
6
+ pyflashgui at a desk with no hardware on it. It behaves as flash does where
7
+ that matters to what is built on it: erased flash reads as FF, a sector is
8
+ erased whole, and bytes can only be put where the target has memory.
9
+
10
+ What is programmed stays for as long as the backend object does, so that a
11
+ second connection to the same probe finds what the first one wrote -- as a
12
+ board keeps its flash between one connection and the next.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+
19
+ from pyflashgui.backends.base import (
20
+ ERASE_CHIP,
21
+ ERASE_SECTORS,
22
+ Backend,
23
+ BackendError,
24
+ Connection,
25
+ ProbeInfo,
26
+ Progress,
27
+ TargetInfo,
28
+ _nothing,
29
+ )
30
+ from pyflashgui.core.images import Segment
31
+ from pyflashgui.core.memory_map import FLASH, RAM, Region, region_at
32
+
33
+ ERASED = 0xFF
34
+
35
+ PROBES = (
36
+ ProbeInfo("SIM0001", "Simulated probe 1", vendor="pyflashgui", product="Simulated probe"),
37
+ ProbeInfo("SIM0002", "Simulated probe 2", vendor="pyflashgui", product="Simulated probe"),
38
+ )
39
+
40
+ #: The targets, and the memory of each. Sizes chosen to be awkward in the
41
+ #: ways real parts are: one with a single flash, one with two that do not
42
+ #: touch and have different sector sizes.
43
+ TARGETS: dict[str, tuple[Region, ...]] = {
44
+ "sim_single": (
45
+ Region("flash", 0x0800_0000, 0x4_0000, FLASH, sector_size=0x1000, page_size=0x100),
46
+ Region("ram", 0x2000_0000, 0x1_0000, RAM),
47
+ ),
48
+ "sim_dual": (
49
+ Region("boot", 0x0000_0000, 0x8000, FLASH, sector_size=0x400, page_size=0x100),
50
+ Region("application", 0x1000_0000, 0x10_0000, FLASH, sector_size=0x1000, page_size=0x100),
51
+ Region(
52
+ "application_mirror",
53
+ 0x1100_0000,
54
+ 0x10_0000,
55
+ "rom",
56
+ alias_of="application",
57
+ ),
58
+ Region("ram", 0x2000_0000, 0x2_0000, RAM),
59
+ ),
60
+ }
61
+
62
+
63
+ class _Board:
64
+ """What is on the end of one simulated probe: a target and its memory."""
65
+
66
+ def __init__(self, target: str) -> None:
67
+ self.target = target
68
+ self.regions = TARGETS[target]
69
+ self.memory = {
70
+ r.name: bytearray([ERASED if r.kind == FLASH else 0]) * r.length
71
+ for r in self.regions
72
+ if not r.alias_of
73
+ }
74
+ self.resets = 0
75
+
76
+ def storage(self, region: Region) -> bytearray:
77
+ """The bytes behind a region, which for an alias are another's."""
78
+ return self.memory[region.alias_of or region.name]
79
+
80
+
81
+ class SimulatedConnection(Connection):
82
+ def __init__(self, backend: SimulatedBackend, probe: str, board: _Board) -> None:
83
+ self._backend = backend
84
+ self._probe = probe
85
+ self._board: _Board | None = board
86
+
87
+ def _open(self) -> _Board:
88
+ if self._board is None:
89
+ raise BackendError("This connection has been closed.")
90
+ return self._board
91
+
92
+ def regions(self) -> list[Region]:
93
+ return list(self._open().regions)
94
+
95
+ def erase_chip(self) -> None:
96
+ board = self._open()
97
+ for region in board.regions:
98
+ if region.kind == FLASH and not region.alias_of:
99
+ board.memory[region.name][:] = bytes([ERASED]) * region.length
100
+
101
+ def erase_ranges(self, ranges: Sequence[tuple[int, int]]) -> None:
102
+ board = self._open()
103
+ # Everything is placed before anything is erased, so that a range
104
+ # that is not flash leaves the target as it was.
105
+ placed = [self._place(board, start, length) for start, length in ranges]
106
+ for runs in placed:
107
+ for region, _offset, _length in runs:
108
+ if region.kind != FLASH:
109
+ raise BackendError(f"{region.name} is not flash, and cannot be erased.")
110
+ for runs in placed:
111
+ for region, offset, length in runs:
112
+ size = region.sector_size
113
+ start = offset // size * size
114
+ stop = -(-(offset + length) // size) * size
115
+ board.memory[region.name][start:stop] = bytes([ERASED]) * (stop - start)
116
+
117
+ def _place(self, board: _Board, address: int, length: int) -> list[tuple[Region, int, int]]:
118
+ """A run of bytes as (region, offset in it, length), region by region."""
119
+ out = []
120
+ end = address + length
121
+ while address < end:
122
+ region = region_at(board.regions, address)
123
+ if region is None:
124
+ raise BackendError(f"The target has no memory at {address:08X}.")
125
+ if region.alias_of or region.kind not in (FLASH, RAM):
126
+ raise BackendError(f"{address:08X} is in {region.name}, which cannot be written.")
127
+ stop = min(end, region.end)
128
+ out.append((region, address - region.start, stop - address))
129
+ address = stop
130
+ return out
131
+
132
+ def program(
133
+ self,
134
+ segments: Sequence[Segment],
135
+ erase: str = ERASE_SECTORS,
136
+ progress: Progress = _nothing,
137
+ ) -> None:
138
+ board = self._open()
139
+ if erase not in (ERASE_SECTORS, ERASE_CHIP):
140
+ raise BackendError(f"There is no way of erasing called {erase!r}.")
141
+ # Everything is placed before anything is changed, so that a file
142
+ # that does not fit leaves the target as it was.
143
+ placed = [(s, self._place(board, s.address, len(s))) for s in segments]
144
+ if erase == ERASE_CHIP:
145
+ self.erase_chip()
146
+ else:
147
+ for _segment, runs in placed:
148
+ for region, offset, length in runs:
149
+ if region.kind != FLASH:
150
+ continue
151
+ size = region.sector_size
152
+ start = offset // size * size
153
+ stop = -(-(offset + length) // size) * size
154
+ board.memory[region.name][start:stop] = bytes([ERASED]) * (stop - start)
155
+ total = sum(len(s) for s in segments)
156
+ done = 0
157
+ for segment, runs in placed:
158
+ position = 0
159
+ for region, offset, length in runs:
160
+ data = segment.data[position : position + length]
161
+ board.memory[region.name][offset : offset + length] = data
162
+ position += length
163
+ done += length
164
+ progress(done / total)
165
+
166
+ def read(self, address: int, length: int) -> bytes:
167
+ board = self._open()
168
+ out = bytearray()
169
+ end = address + length
170
+ while address < end:
171
+ region = region_at(board.regions, address)
172
+ if region is None:
173
+ raise BackendError(f"The target has no memory at {address:08X}.")
174
+ stop = min(end, region.end)
175
+ offset = address - region.start
176
+ out += board.storage(region)[offset : offset + stop - address]
177
+ address = stop
178
+ return bytes(out)
179
+
180
+ def reset(self, halt: bool = False) -> None:
181
+ board = self._open()
182
+ board.resets += 1
183
+ # What a reset does to RAM on a real part is nothing anybody should
184
+ # rely on. Cleared here, so that relying on it shows up.
185
+ for region in board.regions:
186
+ if region.kind == RAM:
187
+ board.memory[region.name][:] = bytes(region.length)
188
+
189
+ def close(self) -> None:
190
+ if self._board is not None:
191
+ self._board = None
192
+ self._backend._released(self._probe)
193
+
194
+
195
+ class SimulatedBackend(Backend):
196
+ name = "simulated"
197
+ label = "Simulated"
198
+
199
+ def __init__(self) -> None:
200
+ #: Probe id -> what it is attached to. Made when first connected to,
201
+ #: and kept, so that flash outlives a connection.
202
+ self._boards: dict[str, _Board] = {}
203
+ self._in_use: set[str] = set()
204
+
205
+ def probes(self) -> list[ProbeInfo]:
206
+ return list(PROBES)
207
+
208
+ def targets(self) -> list[TargetInfo]:
209
+ return [TargetInfo(name, vendor="pyflashgui", source="built in") for name in TARGETS]
210
+
211
+ def regions(self, target: str) -> list[Region]:
212
+ return list(TARGETS.get(target, ()))
213
+
214
+ def connect(
215
+ self,
216
+ probe: str,
217
+ target: str,
218
+ frequency: int | None = None,
219
+ interface: str | None = None,
220
+ ) -> Connection:
221
+ if probe not in {p.unique_id for p in PROBES}:
222
+ raise BackendError(f"There is no probe with the id {probe}.")
223
+ if target not in TARGETS:
224
+ raise BackendError(f"{target} is not a target the simulated backend has.")
225
+ if probe in self._in_use:
226
+ raise BackendError(f"Probe {probe} is already connected.")
227
+ board = self._boards.get(probe)
228
+ if board is None or board.target != target:
229
+ # A different target on the same probe is a different board
230
+ # plugged in, with nothing of the last one's on it.
231
+ board = self._boards[probe] = _Board(target)
232
+ self._in_use.add(probe)
233
+ return SimulatedConnection(self, probe, board)
234
+
235
+ def _released(self, probe: str) -> None:
236
+ self._in_use.discard(probe)
237
+
238
+ def board(self, probe: str) -> _Board | None:
239
+ """What is on the end of a probe, for a test to look at or tamper with."""
240
+ return self._boards.get(probe)
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
@@ -0,0 +1,134 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Which commit a source checkout is running, for a report to name.
4
+
5
+ The version number is the same for every build between two releases, which is
6
+ no use when somebody is running what they pulled this morning: "it does X" and
7
+ "it did X yesterday" are the same sentence about two different programs. A
8
+ checkout knows exactly what it is, and the answer is in ``.git`` for the
9
+ reading.
10
+
11
+ Read from the files rather than by running ``git``: the launcher starts
12
+ pythonw, so a subprocess would flash a console window on Windows, it would be
13
+ a process start on the way to the first window, and a frozen build has no git
14
+ to run. Reading two small files costs nothing and works the same everywhere.
15
+
16
+ Nothing here reports whether the working tree has been edited. That needs the
17
+ index compared against the files, which is what git is for; the commit is what
18
+ somebody needs to tell one build from another.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ #: Enough of a hash to be unambiguous in any repository this size, and short
26
+ #: enough to be read out over a telephone.
27
+ SHORT = 7
28
+
29
+
30
+ def _git_dir(root: Path) -> Path | None:
31
+ """The .git directory, which for a worktree is a file pointing at one."""
32
+ git = root / ".git"
33
+ if git.is_dir():
34
+ return git
35
+ if git.is_file():
36
+ # A linked worktree: ".git" holds "gitdir: <path>".
37
+ try:
38
+ said = git.read_text(encoding="utf-8").strip()
39
+ except OSError:
40
+ return None
41
+ if said.startswith("gitdir:"):
42
+ where = Path(said.split(":", 1)[1].strip())
43
+ if not where.is_absolute():
44
+ where = (root / where).resolve()
45
+ return where if where.is_dir() else None
46
+ return None
47
+
48
+
49
+ def _packed(git: Path, ref: str) -> str | None:
50
+ """A ref that has been packed away, which is where an old branch ends up."""
51
+ try:
52
+ lines = (git / "packed-refs").read_text(encoding="utf-8").splitlines()
53
+ except OSError:
54
+ return None
55
+ for line in lines:
56
+ if line.startswith("#") or line.startswith("^"):
57
+ continue
58
+ parts = line.split()
59
+ if len(parts) == 2 and parts[1] == ref:
60
+ return parts[0]
61
+ return None
62
+
63
+
64
+ def _resolve(git: Path, ref: str) -> str | None:
65
+ """A ref's commit: the loose file first, because packed can be stale."""
66
+ loose = git / ref
67
+ try:
68
+ if loose.is_file():
69
+ return loose.read_text(encoding="utf-8").strip() or None
70
+ except OSError:
71
+ return None
72
+ return _packed(git, ref)
73
+
74
+
75
+ def _tag_for(git: Path, commit: str) -> str | None:
76
+ """A tag pointing at this commit, if one does: that is the better name."""
77
+ tags = git / "refs" / "tags"
78
+ if tags.is_dir():
79
+ for path in sorted(tags.rglob("*")):
80
+ try:
81
+ if path.is_file() and path.read_text(encoding="utf-8").strip() == commit:
82
+ return path.name
83
+ except OSError:
84
+ continue
85
+ try:
86
+ lines = (git / "packed-refs").read_text(encoding="utf-8").splitlines()
87
+ except OSError:
88
+ return None
89
+ for line in lines:
90
+ parts = line.split()
91
+ if len(parts) == 2 and parts[1].startswith("refs/tags/") and parts[0] == commit:
92
+ return parts[1][len("refs/tags/") :]
93
+ return None
94
+
95
+
96
+ def head(root: Path | None = None) -> tuple[str | None, str] | None:
97
+ """(branch, full commit) this checkout is on, or None outside a checkout.
98
+
99
+ The branch is None when the checkout is on a commit rather than
100
+ following a branch -- a tag, or an old commit checked out.
101
+ """
102
+ root = Path(__file__).resolve().parents[2] if root is None else Path(root)
103
+ git = _git_dir(root)
104
+ if git is None:
105
+ return None
106
+ try:
107
+ said = (git / "HEAD").read_text(encoding="utf-8").strip()
108
+ except OSError:
109
+ return None
110
+ if said.startswith("ref:"):
111
+ ref = said.split(":", 1)[1].strip()
112
+ commit = _resolve(git, ref)
113
+ branch = ref.removeprefix("refs/heads/")
114
+ else:
115
+ commit, branch = said, None
116
+ return (branch, commit) if commit else None
117
+
118
+
119
+ def describe(root: Path | None = None) -> str | None:
120
+ """The branch or tag and the short hash, or None outside a checkout.
121
+
122
+ None is the ordinary answer for an installed or frozen build, which has
123
+ no repository to ask and a version number that means something anyway.
124
+ """
125
+ root = Path(__file__).resolve().parents[2] if root is None else Path(root)
126
+ if (found := head(root)) is None:
127
+ return None
128
+ branch, commit = found
129
+ git = _git_dir(root)
130
+ tag = _tag_for(git, commit) if git is not None else None
131
+ # Detached: sitting on a commit rather than following a branch, which is
132
+ # what checking out a tag or an old commit leaves.
133
+ name = tag or (branch.rsplit("/", 1)[-1] if branch else "detached")
134
+ return f"{name} {commit[:SHORT]}"