backlogops-gui 0.1__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.
- backlogops_gui/__init__.py +5 -0
- backlogops_gui/__main__.py +11 -0
- backlogops_gui/application.py +313 -0
- backlogops_gui/backlog_io.py +68 -0
- backlogops_gui/backlog_window.py +435 -0
- backlogops_gui/gui_wizard.py +237 -0
- backlogops_gui/io_dialogs.py +491 -0
- backlogops_gui/log_buffer.py +50 -0
- backlogops_gui/py.typed +0 -0
- backlogops_gui/table_view.py +165 -0
- backlogops_gui/tcltk_version.py +63 -0
- backlogops_gui-0.1.dist-info/METADATA +259 -0
- backlogops_gui-0.1.dist-info/RECORD +17 -0
- backlogops_gui-0.1.dist-info/WHEEL +5 -0
- backlogops_gui-0.1.dist-info/entry_points.txt +2 -0
- backlogops_gui-0.1.dist-info/licenses/LICENSE.txt +22 -0
- backlogops_gui-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""File choosers and format-option dialogs for backlog files.
|
|
3
|
+
|
|
4
|
+
The format options mirror the command line: the format is either inferred
|
|
5
|
+
from the file name, taken from a named preset stored in the teams
|
|
6
|
+
configuration, or read from a stand-alone configuration file. Writing also
|
|
7
|
+
offers to put the releases before the backlog. The chosen format is
|
|
8
|
+
returned as a single value understood by the resolver in
|
|
9
|
+
:mod:`backlogops_gui.backlog_io`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
13
|
+
# MIT License
|
|
14
|
+
|
|
15
|
+
import tkinter as tk
|
|
16
|
+
from tkinter import filedialog, messagebox, ttk
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import date
|
|
19
|
+
from typing import Callable, Optional, Sequence, TextIO
|
|
20
|
+
from backlogops import DependencyMode, DEFAULT_LEVELS, read_key_list
|
|
21
|
+
|
|
22
|
+
MODE_INFER = 0
|
|
23
|
+
MODE_PRESET = 1
|
|
24
|
+
MODE_FILE = 2
|
|
25
|
+
KEY_READ_ERRORS = (ValueError, TypeError, KeyError, OSError)
|
|
26
|
+
DEFAULT_BUFFER_DAYS = 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_value(mode: int, preset: str, path: str) -> Optional[str]:
|
|
30
|
+
"""Return the resolver value for a selected mode and inputs.
|
|
31
|
+
|
|
32
|
+
A preset or file mode with an empty input falls back to inference, so
|
|
33
|
+
an unfinished selection behaves like inferring from the file name.
|
|
34
|
+
"""
|
|
35
|
+
if mode == MODE_PRESET:
|
|
36
|
+
return preset or None
|
|
37
|
+
if mode == MODE_FILE:
|
|
38
|
+
return path or None
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ReadOptions:
|
|
44
|
+
"""The format selection entered for reading a file."""
|
|
45
|
+
|
|
46
|
+
config_value: Optional[str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class WriteOptions:
|
|
51
|
+
"""The format selection and ordering entered for writing a file."""
|
|
52
|
+
|
|
53
|
+
config_value: Optional[str]
|
|
54
|
+
releases_first: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def choose_input_file(parent: tk.Misc) -> Optional[str]:
|
|
58
|
+
"""Ask for an existing backlog file, or None when cancelled."""
|
|
59
|
+
name = filedialog.askopenfilename(parent=parent, title='Read backlog')
|
|
60
|
+
return name or None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def choose_output_file(parent: tk.Misc) -> Optional[str]:
|
|
64
|
+
"""Ask for a backlog file to create, or None when cancelled."""
|
|
65
|
+
name = filedialog.asksaveasfilename(parent=parent, title='Save backlog')
|
|
66
|
+
return name or None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def choose_config_file(parent: tk.Misc) -> Optional[str]:
|
|
70
|
+
"""Ask for a configuration file to create, or None when cancelled."""
|
|
71
|
+
name = filedialog.asksaveasfilename(parent=parent,
|
|
72
|
+
title='Write configuration')
|
|
73
|
+
return name or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# pylint: disable-next=too-few-public-methods
|
|
77
|
+
class _ModalDialog:
|
|
78
|
+
"""Base for small modal dialogs with OK and Cancel buttons."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, parent: tk.Misc, title: str) -> None:
|
|
81
|
+
"""Create the modal top-level window and its close handler."""
|
|
82
|
+
self.cancelled = False
|
|
83
|
+
self._win = tk.Toplevel(parent)
|
|
84
|
+
self._win.title(title)
|
|
85
|
+
if isinstance(parent, tk.Wm):
|
|
86
|
+
self._win.transient(parent)
|
|
87
|
+
self._win.protocol('WM_DELETE_WINDOW', self._cancel)
|
|
88
|
+
|
|
89
|
+
def _show(self) -> None:
|
|
90
|
+
"""Add the buttons, grab the focus and wait for the close."""
|
|
91
|
+
self._add_buttons()
|
|
92
|
+
self._win.grab_set()
|
|
93
|
+
self._win.wait_window()
|
|
94
|
+
|
|
95
|
+
def _add_buttons(self) -> None:
|
|
96
|
+
"""Add the confirm and cancel buttons."""
|
|
97
|
+
button_bar = tk.Frame(self._win)
|
|
98
|
+
button_bar.pack(padx=12, pady=10, fill='x')
|
|
99
|
+
tk.Button(button_bar, text='OK',
|
|
100
|
+
command=self._confirm).pack(side='left')
|
|
101
|
+
tk.Button(button_bar, text='Cancel',
|
|
102
|
+
command=self._cancel).pack(side='right')
|
|
103
|
+
|
|
104
|
+
def _confirm(self) -> None:
|
|
105
|
+
"""Close the dialog; subclasses override to store their values."""
|
|
106
|
+
self._win.destroy()
|
|
107
|
+
|
|
108
|
+
def _cancel(self) -> None:
|
|
109
|
+
"""Mark the dialog cancelled and close it."""
|
|
110
|
+
self.cancelled = True
|
|
111
|
+
self._win.destroy()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# pylint: disable-next=too-few-public-methods,too-many-instance-attributes
|
|
115
|
+
class _FormatDialog(_ModalDialog):
|
|
116
|
+
"""Modal dialog collecting the format selection for one file."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, parent: tk.Misc, presets: Sequence[str],
|
|
119
|
+
with_releases_first: bool) -> None:
|
|
120
|
+
"""Build, show and wait for the modal format dialog."""
|
|
121
|
+
super().__init__(parent, 'File format')
|
|
122
|
+
self.value: Optional[str] = None
|
|
123
|
+
self.releases_first = False
|
|
124
|
+
self._presets = presets
|
|
125
|
+
self._mode = tk.IntVar(self._win, MODE_INFER)
|
|
126
|
+
self._preset = tk.StringVar(self._win)
|
|
127
|
+
self._path = tk.StringVar(self._win)
|
|
128
|
+
self._rel_first = tk.BooleanVar(self._win, False)
|
|
129
|
+
self._build(with_releases_first)
|
|
130
|
+
self._show()
|
|
131
|
+
|
|
132
|
+
def _build(self, with_releases_first: bool) -> None:
|
|
133
|
+
"""Create the radio buttons, inputs and action buttons."""
|
|
134
|
+
self._add_radio('Infer format from file name', MODE_INFER)
|
|
135
|
+
self._add_preset_row()
|
|
136
|
+
self._add_file_row()
|
|
137
|
+
if with_releases_first:
|
|
138
|
+
check = tk.Checkbutton(self._win, variable=self._rel_first,
|
|
139
|
+
text='Write releases before backlog')
|
|
140
|
+
check.pack(anchor='w', padx=12, pady=4)
|
|
141
|
+
|
|
142
|
+
def _add_radio(self, text: str, mode: int) -> None:
|
|
143
|
+
"""Add one mode radio button."""
|
|
144
|
+
tk.Radiobutton(self._win, text=text, variable=self._mode,
|
|
145
|
+
value=mode).pack(anchor='w', padx=12, pady=2)
|
|
146
|
+
|
|
147
|
+
def _add_preset_row(self) -> None:
|
|
148
|
+
"""Add the preset radio button and its choices, when available."""
|
|
149
|
+
if not self._presets:
|
|
150
|
+
return
|
|
151
|
+
self._add_radio('Use a named preset:', MODE_PRESET)
|
|
152
|
+
box = ttk.Combobox(self._win, textvariable=self._preset,
|
|
153
|
+
values=list(self._presets), state='readonly')
|
|
154
|
+
box.pack(anchor='w', padx=36, pady=2)
|
|
155
|
+
|
|
156
|
+
def _add_file_row(self) -> None:
|
|
157
|
+
"""Add the configuration-file radio button, entry and browse."""
|
|
158
|
+
self._add_radio('Read format from a configuration file:', MODE_FILE)
|
|
159
|
+
row = tk.Frame(self._win)
|
|
160
|
+
row.pack(anchor='w', padx=36, pady=2, fill='x')
|
|
161
|
+
tk.Entry(row, textvariable=self._path, width=30).pack(side='left')
|
|
162
|
+
tk.Button(row, text='Browse', command=self._browse).pack(side='left',
|
|
163
|
+
padx=6)
|
|
164
|
+
|
|
165
|
+
def _browse(self) -> None:
|
|
166
|
+
"""Pick a configuration file and select the file mode."""
|
|
167
|
+
name = filedialog.askopenfilename(parent=self._win,
|
|
168
|
+
title='Format configuration')
|
|
169
|
+
if name:
|
|
170
|
+
self._path.set(name)
|
|
171
|
+
self._mode.set(MODE_FILE)
|
|
172
|
+
|
|
173
|
+
def _confirm(self) -> None:
|
|
174
|
+
"""Store the selected format value and close the dialog."""
|
|
175
|
+
self.value = self._selected_value()
|
|
176
|
+
self.releases_first = self._rel_first.get()
|
|
177
|
+
super()._confirm()
|
|
178
|
+
|
|
179
|
+
def _selected_value(self) -> Optional[str]:
|
|
180
|
+
"""Return the format value for the selected mode."""
|
|
181
|
+
return format_value(self._mode.get(), self._preset.get(),
|
|
182
|
+
self._path.get())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def ask_read_options(parent: tk.Misc, presets: Optional[Sequence[str]]
|
|
186
|
+
) -> Optional[ReadOptions]:
|
|
187
|
+
"""Ask how to read a file, or None when the dialog is cancelled."""
|
|
188
|
+
dialog = _FormatDialog(parent, presets or [], False)
|
|
189
|
+
if dialog.cancelled:
|
|
190
|
+
return None
|
|
191
|
+
return ReadOptions(config_value=dialog.value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ask_write_options(parent: tk.Misc, presets: Optional[Sequence[str]]
|
|
195
|
+
) -> Optional[WriteOptions]:
|
|
196
|
+
"""Ask how to write a file, or None when the dialog is cancelled."""
|
|
197
|
+
dialog = _FormatDialog(parent, presets or [], True)
|
|
198
|
+
if dialog.cancelled:
|
|
199
|
+
return None
|
|
200
|
+
return WriteOptions(config_value=dialog.value,
|
|
201
|
+
releases_first=dialog.releases_first)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def choose_key_list_output(parent: tk.Misc) -> Optional[str]:
|
|
205
|
+
"""Ask for a key list file to create, or None when cancelled."""
|
|
206
|
+
name = filedialog.asksaveasfilename(parent=parent, title='Write keys')
|
|
207
|
+
return name or None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def choose_changes_output(parent: tk.Misc) -> Optional[str]:
|
|
211
|
+
"""Ask for a changes file to create, or None when cancelled."""
|
|
212
|
+
name = filedialog.asksaveasfilename(parent=parent, title='Save changes')
|
|
213
|
+
return name or None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# pylint: disable-next=too-few-public-methods
|
|
217
|
+
class _BufferDialog(_ModalDialog):
|
|
218
|
+
"""Modal dialog collecting the buffer in calendar days."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, parent: tk.Misc) -> None:
|
|
221
|
+
"""Build, show and wait for the buffer days dialog."""
|
|
222
|
+
super().__init__(parent, 'Buffer days')
|
|
223
|
+
self.days: Optional[int] = None
|
|
224
|
+
self._text = tk.StringVar(self._win, str(DEFAULT_BUFFER_DAYS))
|
|
225
|
+
self._build()
|
|
226
|
+
self._show()
|
|
227
|
+
|
|
228
|
+
def _build(self) -> None:
|
|
229
|
+
"""Add the buffer label and entry prefilled with the default."""
|
|
230
|
+
tk.Label(self._win, text='Buffer in calendar days (0 or more):'
|
|
231
|
+
).pack(anchor='w', padx=12, pady=(10, 2))
|
|
232
|
+
tk.Entry(self._win, textvariable=self._text,
|
|
233
|
+
width=10).pack(anchor='w', padx=12)
|
|
234
|
+
|
|
235
|
+
def _confirm(self) -> None:
|
|
236
|
+
"""Parse the buffer, keeping the dialog open on a bad value."""
|
|
237
|
+
try:
|
|
238
|
+
days = int(self._text.get().strip())
|
|
239
|
+
except ValueError:
|
|
240
|
+
messagebox.showerror('Invalid number',
|
|
241
|
+
'Enter a whole number of days.',
|
|
242
|
+
parent=self._win)
|
|
243
|
+
return
|
|
244
|
+
if days < 0:
|
|
245
|
+
messagebox.showerror('Invalid number',
|
|
246
|
+
'The buffer must not be negative.',
|
|
247
|
+
parent=self._win)
|
|
248
|
+
return
|
|
249
|
+
self.days = days
|
|
250
|
+
super()._confirm()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def ask_buffer_days(parent: tk.Misc) -> Optional[int]:
|
|
254
|
+
"""Ask for the buffer in days, or None when the dialog is cancelled."""
|
|
255
|
+
dialog = _BufferDialog(parent)
|
|
256
|
+
if dialog.cancelled:
|
|
257
|
+
return None
|
|
258
|
+
return dialog.days
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def show_change_list(parent: tk.Misc, title: str, text: str,
|
|
262
|
+
on_save: Callable[[], None]) -> tk.Toplevel:
|
|
263
|
+
"""Show a change listing with Save-to-file and Dismiss buttons.
|
|
264
|
+
|
|
265
|
+
The listing is shown read-only. The Save button calls ``on_save`` and
|
|
266
|
+
the Dismiss button closes the window. The created window is returned
|
|
267
|
+
so a caller (or a test) can drive or close it.
|
|
268
|
+
"""
|
|
269
|
+
win = tk.Toplevel(parent)
|
|
270
|
+
win.title(title)
|
|
271
|
+
if isinstance(parent, tk.Wm):
|
|
272
|
+
win.transient(parent)
|
|
273
|
+
box = tk.Text(win, width=50, height=12, wrap='none')
|
|
274
|
+
box.insert('1.0', text)
|
|
275
|
+
box.configure(state='disabled')
|
|
276
|
+
box.pack(padx=12, pady=(10, 4), fill='both', expand=True)
|
|
277
|
+
button_bar = tk.Frame(win)
|
|
278
|
+
button_bar.pack(padx=12, pady=10, fill='x')
|
|
279
|
+
tk.Button(button_bar, text='Save to file…',
|
|
280
|
+
command=on_save).pack(side='left')
|
|
281
|
+
tk.Button(button_bar, text='Dismiss',
|
|
282
|
+
command=win.destroy).pack(side='right')
|
|
283
|
+
return win
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class DepOptions:
|
|
288
|
+
"""The options selected for ordering a backlog by dependencies."""
|
|
289
|
+
|
|
290
|
+
later: bool
|
|
291
|
+
mode: DependencyMode
|
|
292
|
+
space_around: Optional[list[str]]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass
|
|
296
|
+
class StartChoice:
|
|
297
|
+
"""The start date selected for estimating ready dates."""
|
|
298
|
+
|
|
299
|
+
start_date: Optional[date]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# pylint: disable-next=too-few-public-methods
|
|
303
|
+
class _KeysDialog(_ModalDialog):
|
|
304
|
+
"""Modal dialog collecting the leading keys for a reordering."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, parent: tk.Misc, sink: TextIO) -> None:
|
|
307
|
+
"""Build, show and wait for the key entry dialog."""
|
|
308
|
+
super().__init__(parent, 'Order by keys')
|
|
309
|
+
self.keys: Optional[list[str]] = None
|
|
310
|
+
self._sink = sink
|
|
311
|
+
self._text = self._build_text()
|
|
312
|
+
self._show()
|
|
313
|
+
|
|
314
|
+
def _build_text(self) -> tk.Text:
|
|
315
|
+
"""Add the entry label, text box and the load-from-file button."""
|
|
316
|
+
tk.Label(self._win, text='Enter keys separated by spaces or '
|
|
317
|
+
'newlines:').pack(anchor='w', padx=12, pady=(10, 2))
|
|
318
|
+
text = tk.Text(self._win, width=40, height=8)
|
|
319
|
+
text.pack(padx=12, pady=2)
|
|
320
|
+
tk.Button(self._win, text='Load from file…',
|
|
321
|
+
command=self._load).pack(anchor='w', padx=12, pady=4)
|
|
322
|
+
return text
|
|
323
|
+
|
|
324
|
+
def _load(self) -> None:
|
|
325
|
+
"""Read a key list file into the text box, reporting failures."""
|
|
326
|
+
name = filedialog.askopenfilename(parent=self._win,
|
|
327
|
+
title='Read key list')
|
|
328
|
+
if not name:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
keys = read_key_list(name, stderr_file=self._sink)
|
|
332
|
+
except KEY_READ_ERRORS as error:
|
|
333
|
+
messagebox.showerror('Could not read key list', str(error),
|
|
334
|
+
parent=self._win)
|
|
335
|
+
return
|
|
336
|
+
self._text.delete('1.0', 'end')
|
|
337
|
+
self._text.insert('end', '\n'.join(keys))
|
|
338
|
+
|
|
339
|
+
def _confirm(self) -> None:
|
|
340
|
+
"""Split the text on whitespace and close the dialog."""
|
|
341
|
+
self.keys = self._text.get('1.0', 'end').split()
|
|
342
|
+
super()._confirm()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# pylint: disable-next=too-few-public-methods
|
|
346
|
+
class _DepOptionsDialog(_ModalDialog):
|
|
347
|
+
"""Modal dialog collecting the order-by-dependencies options."""
|
|
348
|
+
|
|
349
|
+
def __init__(self, parent: tk.Misc) -> None:
|
|
350
|
+
"""Build, show and wait for the dependency options dialog."""
|
|
351
|
+
super().__init__(parent, 'Order by dependencies')
|
|
352
|
+
self.options: Optional[DepOptions] = None
|
|
353
|
+
self._later = tk.BooleanVar(self._win, False)
|
|
354
|
+
self._mode = tk.StringVar(self._win, DependencyMode.KEEP.name)
|
|
355
|
+
self._space = tk.StringVar(self._win)
|
|
356
|
+
self._build()
|
|
357
|
+
self._show()
|
|
358
|
+
|
|
359
|
+
def _build(self) -> None:
|
|
360
|
+
"""Add the later check box, the mode chooser and the key entry."""
|
|
361
|
+
tk.Checkbutton(self._win, variable=self._later,
|
|
362
|
+
text='Push dependent items later instead of pulling '
|
|
363
|
+
'prerequisites earlier').pack(anchor='w', padx=12,
|
|
364
|
+
pady=(10, 2))
|
|
365
|
+
self._build_mode()
|
|
366
|
+
self._build_space()
|
|
367
|
+
|
|
368
|
+
def _build_mode(self) -> None:
|
|
369
|
+
"""Add the placement-mode label and chooser."""
|
|
370
|
+
tk.Label(self._win, text='Placement of dependency items:'
|
|
371
|
+
).pack(anchor='w', padx=12, pady=(6, 2))
|
|
372
|
+
names = [mode.name for mode in DependencyMode]
|
|
373
|
+
ttk.Combobox(self._win, textvariable=self._mode, values=names,
|
|
374
|
+
state='readonly').pack(anchor='w', padx=12)
|
|
375
|
+
|
|
376
|
+
def _build_space(self) -> None:
|
|
377
|
+
"""Add the space-around label and key entry."""
|
|
378
|
+
tk.Label(self._win, text='Keys to keep far from dependencies '
|
|
379
|
+
'(optional, space separated):').pack(anchor='w', padx=12,
|
|
380
|
+
pady=(6, 2))
|
|
381
|
+
tk.Entry(self._win, textvariable=self._space,
|
|
382
|
+
width=40).pack(anchor='w', padx=12)
|
|
383
|
+
|
|
384
|
+
def _confirm(self) -> None:
|
|
385
|
+
"""Store the selected options and close the dialog."""
|
|
386
|
+
space = self._space.get().split()
|
|
387
|
+
self.options = DepOptions(later=self._later.get(),
|
|
388
|
+
mode=DependencyMode[self._mode.get()],
|
|
389
|
+
space_around=space or None)
|
|
390
|
+
super()._confirm()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# pylint: disable-next=too-few-public-methods
|
|
394
|
+
class _StartDateDialog(_ModalDialog):
|
|
395
|
+
"""Modal dialog collecting the start date for the estimate."""
|
|
396
|
+
|
|
397
|
+
def __init__(self, parent: tk.Misc) -> None:
|
|
398
|
+
"""Build, show and wait for the start date dialog."""
|
|
399
|
+
super().__init__(parent, 'Estimate ready date')
|
|
400
|
+
self.choice: Optional[StartChoice] = None
|
|
401
|
+
self._date = tk.StringVar(self._win, date.today().isoformat())
|
|
402
|
+
self._build()
|
|
403
|
+
self._show()
|
|
404
|
+
|
|
405
|
+
def _build(self) -> None:
|
|
406
|
+
"""Add the start date label and entry."""
|
|
407
|
+
tk.Label(self._win, text='Start date (ISO, empty for today):'
|
|
408
|
+
).pack(anchor='w', padx=12, pady=(10, 2))
|
|
409
|
+
tk.Entry(self._win, textvariable=self._date,
|
|
410
|
+
width=20).pack(anchor='w', padx=12)
|
|
411
|
+
|
|
412
|
+
def _confirm(self) -> None:
|
|
413
|
+
"""Parse the date, keeping the dialog open on a bad value."""
|
|
414
|
+
text = self._date.get().strip()
|
|
415
|
+
if text == '':
|
|
416
|
+
self.choice = StartChoice(start_date=None)
|
|
417
|
+
super()._confirm()
|
|
418
|
+
return
|
|
419
|
+
try:
|
|
420
|
+
self.choice = StartChoice(start_date=date.fromisoformat(text))
|
|
421
|
+
except ValueError as error:
|
|
422
|
+
messagebox.showerror('Invalid date', str(error), parent=self._win)
|
|
423
|
+
return
|
|
424
|
+
super()._confirm()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# pylint: disable-next=too-few-public-methods
|
|
428
|
+
class _LevelsDialog(_ModalDialog):
|
|
429
|
+
"""Modal dialog selecting the levels to extract keys at."""
|
|
430
|
+
|
|
431
|
+
def __init__(self, parent: tk.Misc) -> None:
|
|
432
|
+
"""Build, show and wait for the level selection dialog."""
|
|
433
|
+
super().__init__(parent, 'Extract keys')
|
|
434
|
+
self.levels: Optional[list[int]] = None
|
|
435
|
+
self._chosen = self._build()
|
|
436
|
+
self._show()
|
|
437
|
+
|
|
438
|
+
def _build(self) -> dict[int, tk.BooleanVar]:
|
|
439
|
+
"""Add a check box for each default level and return its variables."""
|
|
440
|
+
tk.Label(self._win, text='Select levels to extract keys at:'
|
|
441
|
+
).pack(anchor='w', padx=12, pady=(10, 2))
|
|
442
|
+
chosen: dict[int, tk.BooleanVar] = {}
|
|
443
|
+
for number in sorted(DEFAULT_LEVELS):
|
|
444
|
+
var = tk.BooleanVar(self._win, False)
|
|
445
|
+
chosen[number] = var
|
|
446
|
+
tk.Checkbutton(self._win, variable=var,
|
|
447
|
+
text=DEFAULT_LEVELS[number].name).pack(anchor='w',
|
|
448
|
+
padx=24)
|
|
449
|
+
return chosen
|
|
450
|
+
|
|
451
|
+
def _confirm(self) -> None:
|
|
452
|
+
"""Store the chosen levels, requiring at least one selection."""
|
|
453
|
+
selected = [n for n, var in self._chosen.items() if var.get()]
|
|
454
|
+
if not selected:
|
|
455
|
+
messagebox.showerror('No levels', 'Select at least one level.',
|
|
456
|
+
parent=self._win)
|
|
457
|
+
return
|
|
458
|
+
self.levels = selected
|
|
459
|
+
super()._confirm()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def ask_keys(parent: tk.Misc, sink: TextIO) -> Optional[list[str]]:
|
|
463
|
+
"""Ask for the leading keys, or None when the dialog is cancelled."""
|
|
464
|
+
dialog = _KeysDialog(parent, sink)
|
|
465
|
+
if dialog.cancelled:
|
|
466
|
+
return None
|
|
467
|
+
return dialog.keys
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def ask_dep_options(parent: tk.Misc) -> Optional[DepOptions]:
|
|
471
|
+
"""Ask for the dependency options, or None when cancelled."""
|
|
472
|
+
dialog = _DepOptionsDialog(parent)
|
|
473
|
+
if dialog.cancelled:
|
|
474
|
+
return None
|
|
475
|
+
return dialog.options
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def ask_start_date(parent: tk.Misc) -> Optional[StartChoice]:
|
|
479
|
+
"""Ask for the start date, or None when the dialog is cancelled."""
|
|
480
|
+
dialog = _StartDateDialog(parent)
|
|
481
|
+
if dialog.cancelled:
|
|
482
|
+
return None
|
|
483
|
+
return dialog.choice
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def ask_levels(parent: tk.Misc) -> Optional[list[int]]:
|
|
487
|
+
"""Ask for the levels to extract, or None when cancelled."""
|
|
488
|
+
dialog = _LevelsDialog(parent)
|
|
489
|
+
if dialog.cancelled:
|
|
490
|
+
return None
|
|
491
|
+
return dialog.levels
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""A bounded text sink that keeps the most recent log lines.
|
|
3
|
+
|
|
4
|
+
The graphical application routes the diagnostics that the library would
|
|
5
|
+
write to ``stderr`` into a log buffer instead of discarding them, so the
|
|
6
|
+
most recent lines can be shown in the main window. The buffer keeps only a
|
|
7
|
+
bounded number of the latest lines, so a long-running session cannot
|
|
8
|
+
exhaust memory.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
12
|
+
# MIT License
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
from collections import deque
|
|
16
|
+
from typing import override
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_LINES = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LogBuffer(io.StringIO):
|
|
22
|
+
"""A text sink keeping only the most recent written lines."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_lines: int = DEFAULT_MAX_LINES) -> None:
|
|
25
|
+
"""Create an empty buffer keeping at most ``max_lines`` lines."""
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._lines: deque[str] = deque(maxlen=max_lines)
|
|
28
|
+
self._partial = ''
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def write(self, s: str) -> int:
|
|
32
|
+
"""Append text, keeping only the most recent completed lines.
|
|
33
|
+
|
|
34
|
+
The text is split on newlines; completed lines join the bounded
|
|
35
|
+
store and any text after the last newline is kept as the pending
|
|
36
|
+
last line. Nothing is stored in the underlying string buffer, so
|
|
37
|
+
memory stays bounded regardless of how much is written.
|
|
38
|
+
"""
|
|
39
|
+
combined = self._partial + s
|
|
40
|
+
parts = combined.split('\n')
|
|
41
|
+
self._partial = parts.pop()
|
|
42
|
+
self._lines.extend(parts)
|
|
43
|
+
return len(s)
|
|
44
|
+
|
|
45
|
+
def text(self) -> str:
|
|
46
|
+
"""Return the kept lines, including any unfinished last line."""
|
|
47
|
+
lines = list(self._lines)
|
|
48
|
+
if self._partial:
|
|
49
|
+
lines.append(self._partial)
|
|
50
|
+
return '\n'.join(lines)
|
backlogops_gui/py.typed
ADDED
|
File without changes
|