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,313 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Tkinter application for backlog operations.
|
|
3
|
+
|
|
4
|
+
The application opens a main window whose menu reads a backlog from a file,
|
|
5
|
+
runs the teams configuration wizard, writes the running configuration to a
|
|
6
|
+
file, and creates a demonstration backlog. Each backlog opens in its own
|
|
7
|
+
window. On macOS the menu bar sits at the top of the display rather than in
|
|
8
|
+
the window, so the main window body shows a short description, the current
|
|
9
|
+
configuration status, and a log of the most recent diagnostic messages, to
|
|
10
|
+
make clear that the application is running. The teams configuration is
|
|
11
|
+
taken from the file given with ``-c`` or from the configured locations;
|
|
12
|
+
when no configuration is found the wizard runs at startup, and cancelling
|
|
13
|
+
it ends the application.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
17
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
18
|
+
# MIT License
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import tkinter as tk
|
|
22
|
+
from tkinter import messagebox, ttk
|
|
23
|
+
from typing import Optional, TextIO
|
|
24
|
+
import argcomplete
|
|
25
|
+
from config_as_json.file_extension import fix_file_extension
|
|
26
|
+
from backlogops import (
|
|
27
|
+
AvailableTeams, AvailableTeamsConfig, BacklogReleases, InputFormatConfig,
|
|
28
|
+
NoTextIO, OutputFormatConfig, get_demo_backlog, get_available_teams,
|
|
29
|
+
teams_config_wizard)
|
|
30
|
+
from backlogops_gui.backlog_io import read_backlog
|
|
31
|
+
from backlogops_gui.backlog_window import BacklogWindow
|
|
32
|
+
from backlogops_gui.gui_wizard import TkWizardBridge
|
|
33
|
+
from backlogops_gui.io_dialogs import (
|
|
34
|
+
ask_read_options, choose_config_file, choose_input_file)
|
|
35
|
+
from backlogops_gui.log_buffer import LogBuffer
|
|
36
|
+
from backlogops_gui.tcltk_version import check_tcltk_version
|
|
37
|
+
|
|
38
|
+
APP_TITLE = 'Backlog operations GUI'
|
|
39
|
+
CONFIG_EXTENSION = '.cfg'
|
|
40
|
+
WRAP_LENGTH = 520
|
|
41
|
+
LOG_REFRESH_MS = 800
|
|
42
|
+
HEADING_FONT = ('TkDefaultFont', 14, 'bold')
|
|
43
|
+
INSTRUCTIONS = (
|
|
44
|
+
'Use the menus to read a backlog file, run the teams wizard, write the '
|
|
45
|
+
'current configuration to a file, or create a demonstration backlog. '
|
|
46
|
+
'Each backlog opens in its own window. On macOS the menu bar is at the '
|
|
47
|
+
'top of the display.')
|
|
48
|
+
DESCRIPTION = 'Graphical user interface for backlog operations'
|
|
49
|
+
CONFIG_ERRORS = (FileNotFoundError, NotADirectoryError, RuntimeError,
|
|
50
|
+
ValueError, TypeError, KeyError, OSError)
|
|
51
|
+
IO_ERRORS = (ValueError, TypeError, KeyError, OSError)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def initial_config(config_arg: Optional[str], sink: Optional[TextIO] = None
|
|
55
|
+
) -> tuple[Optional[AvailableTeamsConfig], Optional[str]]:
|
|
56
|
+
"""Return the startup configuration and an optional error message.
|
|
57
|
+
|
|
58
|
+
The configuration is looked up as documented for
|
|
59
|
+
:func:`backlogops.get_available_teams`. A failure is mapped to a None
|
|
60
|
+
configuration and the error text, so the caller can decide whether to
|
|
61
|
+
show the error and whether to run the wizard.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_arg: The file from ``-c``, or None to search the defaults.
|
|
65
|
+
sink: Stream for diagnostics, or None to discard them.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The loaded configuration and None, or None and the error text.
|
|
69
|
+
"""
|
|
70
|
+
out = NoTextIO() if sink is None else sink
|
|
71
|
+
try:
|
|
72
|
+
return get_available_teams(config_arg, out), None
|
|
73
|
+
except CONFIG_ERRORS as error:
|
|
74
|
+
return None, str(error)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BacklogApp:
|
|
78
|
+
"""The backlog operations application and its menu actions."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, root: tk.Tk,
|
|
81
|
+
config: Optional[AvailableTeamsConfig] = None) -> None:
|
|
82
|
+
"""Store the main window, configuration, and a log buffer."""
|
|
83
|
+
self.root = root
|
|
84
|
+
self.config = config
|
|
85
|
+
self.log = LogBuffer()
|
|
86
|
+
self.config_source: Optional[str] = None
|
|
87
|
+
self.log_view: Optional[tk.Text] = None
|
|
88
|
+
self._status: Optional[tk.StringVar] = None
|
|
89
|
+
|
|
90
|
+
def in_presets(self) -> Optional[dict[str, InputFormatConfig]]:
|
|
91
|
+
"""Return the input presets of the current configuration."""
|
|
92
|
+
return self.config.input_configs if self.config else None
|
|
93
|
+
|
|
94
|
+
def out_presets(self) -> Optional[dict[str, OutputFormatConfig]]:
|
|
95
|
+
"""Return the output presets of the current configuration."""
|
|
96
|
+
return self.config.output_configs if self.config else None
|
|
97
|
+
|
|
98
|
+
def available_teams(self) -> Optional[AvailableTeams]:
|
|
99
|
+
"""Return the loaded teams configuration, or None when absent."""
|
|
100
|
+
return self.config
|
|
101
|
+
|
|
102
|
+
def show_error(self, title: str, message: str) -> None:
|
|
103
|
+
"""Show an error message to the user."""
|
|
104
|
+
messagebox.showerror(title, message, parent=self.root)
|
|
105
|
+
|
|
106
|
+
def show_info(self, title: str, message: str) -> None:
|
|
107
|
+
"""Show an informational message to the user."""
|
|
108
|
+
messagebox.showinfo(title, message, parent=self.root)
|
|
109
|
+
|
|
110
|
+
def start(self, config_arg: Optional[str]) -> bool:
|
|
111
|
+
"""Load the startup configuration, running the wizard if needed.
|
|
112
|
+
|
|
113
|
+
A configuration named with ``-c`` that cannot be read is reported
|
|
114
|
+
before the wizard runs. When no configuration is loaded and the
|
|
115
|
+
wizard is cancelled, the application is not ready to run.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
config_arg: The file from ``-c``, or None to search defaults.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Whether the application is ready to enter its main loop.
|
|
122
|
+
"""
|
|
123
|
+
config, error = initial_config(config_arg, self.log)
|
|
124
|
+
if config is None:
|
|
125
|
+
if config_arg is not None and error is not None:
|
|
126
|
+
self.show_error('Configuration error', error)
|
|
127
|
+
config = self.run_wizard()
|
|
128
|
+
if config is None:
|
|
129
|
+
return False
|
|
130
|
+
self.config_source = 'the wizard'
|
|
131
|
+
else:
|
|
132
|
+
self.config_source = (config_arg if config_arg is not None
|
|
133
|
+
else 'the default location')
|
|
134
|
+
self.config = config
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
def run_wizard(self) -> Optional[AvailableTeamsConfig]:
|
|
138
|
+
"""Run the teams wizard and return its configuration, or None."""
|
|
139
|
+
bridge = TkWizardBridge(self.root, self.log)
|
|
140
|
+
try:
|
|
141
|
+
return teams_config_wizard(bridge)
|
|
142
|
+
except EOFError:
|
|
143
|
+
return None
|
|
144
|
+
except IO_ERRORS as error:
|
|
145
|
+
self.show_error('Wizard error', str(error))
|
|
146
|
+
return None
|
|
147
|
+
finally:
|
|
148
|
+
bridge.close()
|
|
149
|
+
|
|
150
|
+
def run_teams_wizard(self) -> None:
|
|
151
|
+
"""Run the wizard and make a new configuration active on success."""
|
|
152
|
+
config = self.run_wizard()
|
|
153
|
+
if config is not None:
|
|
154
|
+
self.config = config
|
|
155
|
+
self.config_source = 'the wizard'
|
|
156
|
+
self._update_status()
|
|
157
|
+
self.show_info('Wizard', 'The new configuration is now active.')
|
|
158
|
+
|
|
159
|
+
def write_config(self) -> None:
|
|
160
|
+
"""Write the running configuration to a chosen file."""
|
|
161
|
+
if self.config is None:
|
|
162
|
+
self.show_error('No configuration',
|
|
163
|
+
'There is no configuration to write.')
|
|
164
|
+
return
|
|
165
|
+
path = choose_config_file(self.root)
|
|
166
|
+
if path is None:
|
|
167
|
+
return
|
|
168
|
+
path = fix_file_extension(path, CONFIG_EXTENSION)
|
|
169
|
+
try:
|
|
170
|
+
self.config.write(to_json_filename=path, stderr_file=self.log)
|
|
171
|
+
except IO_ERRORS as error:
|
|
172
|
+
self.show_error('Could not write configuration', str(error))
|
|
173
|
+
return
|
|
174
|
+
self.show_info('Wrote configuration', f'Wrote {path}')
|
|
175
|
+
|
|
176
|
+
def read_backlog_file(self) -> None:
|
|
177
|
+
"""Read a backlog from a chosen file into a new window."""
|
|
178
|
+
path = choose_input_file(self.root)
|
|
179
|
+
if path is None:
|
|
180
|
+
return
|
|
181
|
+
presets = self.in_presets()
|
|
182
|
+
options = ask_read_options(self.root,
|
|
183
|
+
sorted(presets) if presets else None)
|
|
184
|
+
if options is None:
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
data = read_backlog(path, options.config_value, presets, self.log)
|
|
188
|
+
except IO_ERRORS as error:
|
|
189
|
+
self.show_error('Could not read file', str(error))
|
|
190
|
+
return
|
|
191
|
+
self.open_backlog(data, path)
|
|
192
|
+
|
|
193
|
+
def new_demo_backlog(self) -> None:
|
|
194
|
+
"""Open a demonstration backlog in a new window."""
|
|
195
|
+
self.open_backlog(get_demo_backlog(), 'Demo backlog')
|
|
196
|
+
|
|
197
|
+
def open_backlog(self, data: BacklogReleases, title: str) -> None:
|
|
198
|
+
"""Open one backlog and its releases in a new window."""
|
|
199
|
+
BacklogWindow(self.root, data, title, self.out_presets,
|
|
200
|
+
self.available_teams, self.log)
|
|
201
|
+
|
|
202
|
+
def build_menu(self) -> None:
|
|
203
|
+
"""Build the menu bar of the main window."""
|
|
204
|
+
menubar = tk.Menu(self.root)
|
|
205
|
+
self._add_file_menu(menubar)
|
|
206
|
+
self._add_config_menu(menubar)
|
|
207
|
+
self.root.config(menu=menubar)
|
|
208
|
+
|
|
209
|
+
def _add_file_menu(self, menubar: tk.Menu) -> None:
|
|
210
|
+
"""Add the file menu with the backlog and exit actions."""
|
|
211
|
+
menu = tk.Menu(menubar, tearoff=False)
|
|
212
|
+
menu.add_command(label='Read backlog…', command=self.read_backlog_file)
|
|
213
|
+
menu.add_command(label='New demo backlog',
|
|
214
|
+
command=self.new_demo_backlog)
|
|
215
|
+
menu.add_separator()
|
|
216
|
+
menu.add_command(label='Exit', command=self.root.destroy)
|
|
217
|
+
menubar.add_cascade(label='File', menu=menu)
|
|
218
|
+
|
|
219
|
+
def _add_config_menu(self, menubar: tk.Menu) -> None:
|
|
220
|
+
"""Add the configuration menu with the wizard and write actions."""
|
|
221
|
+
menu = tk.Menu(menubar, tearoff=False)
|
|
222
|
+
menu.add_command(label='Run teams wizard…',
|
|
223
|
+
command=self.run_teams_wizard)
|
|
224
|
+
menu.add_command(label='Write configuration…',
|
|
225
|
+
command=self.write_config)
|
|
226
|
+
menubar.add_cascade(label='Configuration', menu=menu)
|
|
227
|
+
|
|
228
|
+
def build_body(self) -> None:
|
|
229
|
+
"""Build the main window body and start the log refresh."""
|
|
230
|
+
tk.Label(self.root, text=APP_TITLE,
|
|
231
|
+
font=HEADING_FONT).pack(anchor='w', padx=12, pady=(12, 4))
|
|
232
|
+
tk.Label(self.root, text=INSTRUCTIONS, wraplength=WRAP_LENGTH,
|
|
233
|
+
justify='left').pack(anchor='w', padx=12)
|
|
234
|
+
warning = check_tcltk_version(self.root)
|
|
235
|
+
if warning is not None:
|
|
236
|
+
tk.Label(self.root, text=warning, fg='red', justify='left',
|
|
237
|
+
wraplength=WRAP_LENGTH).pack(anchor='w', padx=12, pady=4)
|
|
238
|
+
self._status = tk.StringVar(self.root, self._status_text())
|
|
239
|
+
tk.Label(self.root, textvariable=self._status).pack(anchor='w',
|
|
240
|
+
padx=12, pady=4)
|
|
241
|
+
self._build_log_view()
|
|
242
|
+
self._schedule_refresh()
|
|
243
|
+
|
|
244
|
+
def _build_log_view(self) -> None:
|
|
245
|
+
"""Build the read-only log view in the main window."""
|
|
246
|
+
frame = tk.LabelFrame(self.root, text='Log (most recent messages)')
|
|
247
|
+
frame.pack(fill='both', expand=True, padx=12, pady=8)
|
|
248
|
+
text = tk.Text(frame, height=8, wrap='word', state='disabled')
|
|
249
|
+
scroll = ttk.Scrollbar(frame, orient='vertical', command=text.yview)
|
|
250
|
+
text.configure(yscrollcommand=scroll.set)
|
|
251
|
+
scroll.pack(side='right', fill='y')
|
|
252
|
+
text.pack(side='left', fill='both', expand=True)
|
|
253
|
+
self.log_view = text
|
|
254
|
+
|
|
255
|
+
def _status_text(self) -> str:
|
|
256
|
+
"""Return the configuration status line for the main window."""
|
|
257
|
+
if self.config is None:
|
|
258
|
+
return 'No configuration loaded.'
|
|
259
|
+
return f'Configuration loaded from {self.config_source}.'
|
|
260
|
+
|
|
261
|
+
def _update_status(self) -> None:
|
|
262
|
+
"""Refresh the configuration status line, when it is shown."""
|
|
263
|
+
if self._status is not None:
|
|
264
|
+
self._status.set(self._status_text())
|
|
265
|
+
|
|
266
|
+
def _refresh_log(self) -> None:
|
|
267
|
+
"""Copy the latest log lines into the read-only log view."""
|
|
268
|
+
if self.log_view is None:
|
|
269
|
+
return
|
|
270
|
+
self.log_view.configure(state='normal')
|
|
271
|
+
self.log_view.delete('1.0', 'end')
|
|
272
|
+
self.log_view.insert('end', self.log.text())
|
|
273
|
+
self.log_view.see('end')
|
|
274
|
+
self.log_view.configure(state='disabled')
|
|
275
|
+
|
|
276
|
+
def _schedule_refresh(self) -> None:
|
|
277
|
+
"""Refresh the log view and schedule the next refresh."""
|
|
278
|
+
try:
|
|
279
|
+
self._refresh_log()
|
|
280
|
+
self.root.after(LOG_REFRESH_MS, self._schedule_refresh)
|
|
281
|
+
except tk.TclError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
286
|
+
"""Build the command line parser for the GUI launcher."""
|
|
287
|
+
parser = argparse.ArgumentParser(description=DESCRIPTION)
|
|
288
|
+
parser.add_argument('-c', '--config', dest='config',
|
|
289
|
+
help='Teams configuration file to start with. '
|
|
290
|
+
'Without -c the file is found from $BACKLOGOPS_CFG, '
|
|
291
|
+
'else backlogops.cfg in $BACKLOGOPS_DIR, else '
|
|
292
|
+
'$HOME/.backlogops.cfg.')
|
|
293
|
+
return parser
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main(args: Optional[list[str]] = None) -> None:
|
|
297
|
+
"""Start the backlog operations GUI.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
|
|
301
|
+
"""
|
|
302
|
+
parser = _build_parser()
|
|
303
|
+
argcomplete.autocomplete(parser)
|
|
304
|
+
parsed = parser.parse_args(args)
|
|
305
|
+
root = tk.Tk()
|
|
306
|
+
app = BacklogApp(root)
|
|
307
|
+
if not app.start(parsed.config):
|
|
308
|
+
root.destroy()
|
|
309
|
+
return
|
|
310
|
+
root.title(APP_TITLE)
|
|
311
|
+
app.build_menu()
|
|
312
|
+
app.build_body()
|
|
313
|
+
root.mainloop()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Read and write a backlog and releases with format options.
|
|
3
|
+
|
|
4
|
+
These helpers wrap the library read and write functions and resolve the
|
|
5
|
+
format the same way the command line does: an empty value infers the
|
|
6
|
+
format from the file name, a value of only letters and digits is a preset
|
|
7
|
+
name looked up in the presets from the teams configuration, and any other
|
|
8
|
+
value is the path of a stand-alone format configuration file. Diagnostics
|
|
9
|
+
go to the given sink, because a graphical application shows them in a log
|
|
10
|
+
view rather than on a console.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
14
|
+
# MIT License
|
|
15
|
+
|
|
16
|
+
from typing import Optional, TextIO
|
|
17
|
+
from backlogops import (
|
|
18
|
+
BacklogReleases, FormatRules, InputFormatConfig, OutputFormatConfig,
|
|
19
|
+
NoTextIO, read_backlog_releases, write_backlog_releases,
|
|
20
|
+
resolve_input_config, resolve_output_config)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sink(sink: Optional[TextIO]) -> TextIO:
|
|
24
|
+
"""Return the given diagnostics sink, or a discarding one."""
|
|
25
|
+
return sink if sink is not None else NoTextIO()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_backlog(path: str, value: Optional[str],
|
|
29
|
+
presets: Optional[dict[str, InputFormatConfig]],
|
|
30
|
+
sink: Optional[TextIO] = None) -> BacklogReleases:
|
|
31
|
+
"""Read and validate a backlog and releases from one file.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: The data file to read.
|
|
35
|
+
value: The format selection, as documented for the module.
|
|
36
|
+
presets: Named input presets, or None when none are configured.
|
|
37
|
+
sink: Stream for diagnostics, or None to discard them.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The validated backlog and releases read from the file.
|
|
41
|
+
"""
|
|
42
|
+
out = _sink(sink)
|
|
43
|
+
config = resolve_input_config(value, data_file=path, presets=presets,
|
|
44
|
+
stderr_file=out)
|
|
45
|
+
data = read_backlog_releases(path, config, None, out)
|
|
46
|
+
data.check_consistency(out)
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
51
|
+
def write_backlog(data: BacklogReleases, path: str, value: Optional[str],
|
|
52
|
+
presets: Optional[dict[str, OutputFormatConfig]],
|
|
53
|
+
releases_first: bool, sink: Optional[TextIO] = None) -> None:
|
|
54
|
+
"""Write a backlog and releases to one file.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: The backlog and releases to write.
|
|
58
|
+
path: The data file to create.
|
|
59
|
+
value: The format selection, as documented for the module.
|
|
60
|
+
presets: Named output presets, or None when none are configured.
|
|
61
|
+
releases_first: Whether to write the releases before the backlog.
|
|
62
|
+
sink: Stream for diagnostics, or None to discard them.
|
|
63
|
+
"""
|
|
64
|
+
out = _sink(sink)
|
|
65
|
+
config = resolve_output_config(value, data_file=path, presets=presets,
|
|
66
|
+
stderr_file=out)
|
|
67
|
+
rules = FormatRules(backlog_first=not releases_first)
|
|
68
|
+
write_backlog_releases(data, path, config, rules, stderr_file=out)
|