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.
@@ -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)
File without changes