backlogops 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/__init__.py +87 -0
- backlogops/apply_format_rules.py +95 -0
- backlogops/available_teams.py +205 -0
- backlogops/available_teams_config.py +538 -0
- backlogops/available_teams_wizard.py +448 -0
- backlogops/backlog.py +465 -0
- backlogops/backlog_helpers.py +658 -0
- backlogops/backlog_releases.py +299 -0
- backlogops/backlog_releases_io.py +200 -0
- backlogops/console_yes_no_bridge.py +45 -0
- backlogops/date_ranges.py +59 -0
- backlogops/demo_backlog.py +129 -0
- backlogops/estimate_ready_date.py +379 -0
- backlogops/format_rules.py +73 -0
- backlogops/io_config.py +277 -0
- backlogops/key_list_io.py +227 -0
- backlogops/levels.py +165 -0
- backlogops/move_keys_first.py +166 -0
- backlogops/no_text_io.py +100 -0
- backlogops/order_by_dependencies.py +381 -0
- backlogops/person.py +25 -0
- backlogops/py.typed +0 -0
- backlogops/release_backlog_updates.py +239 -0
- backlogops/release_change_io.py +134 -0
- backlogops/releases.py +170 -0
- backlogops/table_create.py +47 -0
- backlogops/table_rows.py +125 -0
- backlogops/team.py +190 -0
- backlogops/work_hours.py +155 -0
- backlogops-0.1.dist-info/METADATA +238 -0
- backlogops-0.1.dist-info/RECORD +34 -0
- backlogops-0.1.dist-info/WHEEL +5 -0
- backlogops-0.1.dist-info/licenses/LICENSE.txt +22 -0
- backlogops-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Interactively build an AvailableTeams workforce configuration.
|
|
3
|
+
|
|
4
|
+
The public helper :func:`available_teams_wizard` asks the user for the
|
|
5
|
+
company work hours, the persons and their personal work-hour exceptions,
|
|
6
|
+
and the teams with their members. It takes a ``YesNoUiBridge`` (the
|
|
7
|
+
``tableio_cfg_json`` bridge abstraction extended with yes/no controls) so
|
|
8
|
+
the same wizard logic can drive a console text interface or a graphical
|
|
9
|
+
user interface.
|
|
10
|
+
|
|
11
|
+
Individual field values are validated as they are entered, and date
|
|
12
|
+
ranges are kept non-empty. Cross-item rules that span a whole workforce,
|
|
13
|
+
such as non-overlapping exception periods and per-person capacity, are
|
|
14
|
+
checked when the result is stored; an invalid combination is reported
|
|
15
|
+
then and the workforce must be entered again.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
19
|
+
# MIT License
|
|
20
|
+
|
|
21
|
+
from datetime import date
|
|
22
|
+
from typing import Optional, Sequence
|
|
23
|
+
from config_as_json import string_best_match
|
|
24
|
+
from tableio import Capabilities, FileAccess, access_capabilities
|
|
25
|
+
from tableio_cfg_json import TioJsonConfig, WizardUiBridge, \
|
|
26
|
+
tio_json_config_wizard
|
|
27
|
+
from backlogops.available_teams import AvailableTeams
|
|
28
|
+
from backlogops.available_teams_config import AvailableTeamsConfig
|
|
29
|
+
from backlogops.io_config import InputFormatConfig, OutputFormatConfig, \
|
|
30
|
+
PRESET_NAME_RE, make_input_config, make_output_config
|
|
31
|
+
from backlogops.person import Person
|
|
32
|
+
from backlogops.team import FteException, Membership, Team
|
|
33
|
+
from backlogops.work_hours import CompanyWorkHours, DEFAULT_WORK_WEEK, \
|
|
34
|
+
ExceptionWorkHours, ScheduleWorkHours, WeekDay
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# pylint: disable-next=too-few-public-methods
|
|
38
|
+
class YesNoUiBridge(WizardUiBridge):
|
|
39
|
+
"""Wizard bridge extended with a yes/no question.
|
|
40
|
+
|
|
41
|
+
The wizard asks every yes/no question through :meth:`ask_yes_no`, so a
|
|
42
|
+
user interface implements it with whatever controls suit it: a console
|
|
43
|
+
bridge reads a free-text ``y/N`` answer, while a graphical bridge can
|
|
44
|
+
show a pair of yes and no buttons. Concrete bridges must implement it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def ask_yes_no(self, question: str, default: bool) -> bool:
|
|
48
|
+
"""Ask a yes/no question and return the chosen boolean.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
question: The yes/no question to ask.
|
|
52
|
+
default: The value to use when the user makes no explicit
|
|
53
|
+
choice.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The user's choice as a boolean.
|
|
57
|
+
"""
|
|
58
|
+
raise NotImplementedError('ask_yes_no() not implemented')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def available_teams_wizard(ui_bridge: YesNoUiBridge) -> AvailableTeams:
|
|
62
|
+
"""Interactively create an available workforce configuration.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
ui_bridge: Bridge between the wizard and the user interface.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The workforce entered by the user. Field values are individually
|
|
69
|
+
valid, but whole-workforce consistency is only enforced when the
|
|
70
|
+
result is stored.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
EOFError: The input ended before all required answers were read.
|
|
74
|
+
"""
|
|
75
|
+
ui_bridge.show('Configure the available workforce.')
|
|
76
|
+
company = _build_company(ui_bridge)
|
|
77
|
+
persons = _build_persons(ui_bridge)
|
|
78
|
+
names = [person.name for person in persons.values()]
|
|
79
|
+
teams = _build_teams(ui_bridge, names)
|
|
80
|
+
return AvailableTeams(persons=persons, teams=teams,
|
|
81
|
+
company_work_hours=company)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_text(answer: object) -> str:
|
|
85
|
+
"""Return a bridge answer as text, accepting a numeric index too."""
|
|
86
|
+
return answer if isinstance(answer, str) else str(answer)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ask_text(ui_bridge: YesNoUiBridge, question: str, *,
|
|
90
|
+
default: Optional[str] = None, allow_empty: bool = False) -> str:
|
|
91
|
+
"""Ask for a text value with an optional default and re-ask on empty."""
|
|
92
|
+
prompt = question if default is None else f'{question} [{default}]'
|
|
93
|
+
re_ask: Optional[str] = None
|
|
94
|
+
while True:
|
|
95
|
+
answer = _as_text(ui_bridge.ask(prompt, re_ask))
|
|
96
|
+
if answer != '':
|
|
97
|
+
return answer
|
|
98
|
+
if default is not None:
|
|
99
|
+
return default
|
|
100
|
+
if allow_empty:
|
|
101
|
+
return ''
|
|
102
|
+
re_ask = 'Please enter a non-empty value.'
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ask_number(ui_bridge: YesNoUiBridge, question: str, default: float,
|
|
106
|
+
minimum: Optional[float], maximum: Optional[float]) -> float:
|
|
107
|
+
"""Ask for a floating point value within optional bounds."""
|
|
108
|
+
re_ask: Optional[str] = None
|
|
109
|
+
while True:
|
|
110
|
+
answer = _as_text(ui_bridge.ask(f'{question} [{default}]', re_ask))
|
|
111
|
+
if answer == '':
|
|
112
|
+
return default
|
|
113
|
+
try:
|
|
114
|
+
value = float(answer)
|
|
115
|
+
except ValueError:
|
|
116
|
+
re_ask = 'Please enter a number.'
|
|
117
|
+
continue
|
|
118
|
+
if minimum is not None and value < minimum:
|
|
119
|
+
re_ask = f'Please enter a value of at least {minimum}.'
|
|
120
|
+
elif maximum is not None and value > maximum:
|
|
121
|
+
re_ask = f'Please enter a value of at most {maximum}.'
|
|
122
|
+
else:
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ask_int(ui_bridge: YesNoUiBridge, question: str, default: int,
|
|
127
|
+
minimum: int) -> int:
|
|
128
|
+
"""Ask for an integer value that is at least ``minimum``."""
|
|
129
|
+
re_ask: Optional[str] = None
|
|
130
|
+
while True:
|
|
131
|
+
answer = _as_text(ui_bridge.ask(f'{question} [{default}]', re_ask))
|
|
132
|
+
if answer == '':
|
|
133
|
+
return default
|
|
134
|
+
try:
|
|
135
|
+
value = int(answer)
|
|
136
|
+
except ValueError:
|
|
137
|
+
re_ask = 'Please enter a whole number.'
|
|
138
|
+
continue
|
|
139
|
+
if value < minimum:
|
|
140
|
+
re_ask = f'Please enter a value of at least {minimum}.'
|
|
141
|
+
else:
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ask_yes_no(ui_bridge: YesNoUiBridge, question: str,
|
|
146
|
+
default: bool) -> bool:
|
|
147
|
+
"""Ask a yes/no question through the bridge's dedicated controls."""
|
|
148
|
+
return ui_bridge.ask_yes_no(question, default)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ask_date(ui_bridge: YesNoUiBridge, question: str) -> date:
|
|
152
|
+
"""Ask for a required ISO 8601 date such as ``2026-06-13``."""
|
|
153
|
+
re_ask: Optional[str] = None
|
|
154
|
+
while True:
|
|
155
|
+
answer = _as_text(ui_bridge.ask(f'{question} (YYYY-MM-DD)', re_ask))
|
|
156
|
+
parsed = _parse_date(answer)
|
|
157
|
+
if parsed is not None:
|
|
158
|
+
return parsed
|
|
159
|
+
re_ask = 'Please enter a date as YYYY-MM-DD.'
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ask_end_date(ui_bridge: YesNoUiBridge, question: str,
|
|
163
|
+
start_date: date) -> date:
|
|
164
|
+
"""Ask for an end date that is not before ``start_date``."""
|
|
165
|
+
while True:
|
|
166
|
+
end_date = _ask_date(ui_bridge, question)
|
|
167
|
+
if end_date >= start_date:
|
|
168
|
+
return end_date
|
|
169
|
+
ui_bridge.show('The end date must not be before the start date.')
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ask_opt_date(ui_bridge: YesNoUiBridge, question: str) -> Optional[date]:
|
|
173
|
+
"""Ask for an optional ISO date; an empty answer returns ``None``."""
|
|
174
|
+
re_ask: Optional[str] = None
|
|
175
|
+
while True:
|
|
176
|
+
answer = _as_text(ui_bridge.ask(f'{question} (YYYY-MM-DD, '
|
|
177
|
+
'blank for none)', re_ask))
|
|
178
|
+
if answer == '':
|
|
179
|
+
return None
|
|
180
|
+
parsed = _parse_date(answer)
|
|
181
|
+
if parsed is not None:
|
|
182
|
+
return parsed
|
|
183
|
+
re_ask = 'Please enter a date as YYYY-MM-DD, or leave blank.'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_date(answer: str) -> Optional[date]:
|
|
187
|
+
"""Return the ISO date in ``answer``, or ``None`` when it is invalid."""
|
|
188
|
+
try:
|
|
189
|
+
return date.fromisoformat(answer)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ask_choice(ui_bridge: YesNoUiBridge, question: str,
|
|
195
|
+
choices: Sequence[str]) -> str:
|
|
196
|
+
"""Ask the user to pick one of ``choices`` by number or by name."""
|
|
197
|
+
re_ask: Optional[str] = None
|
|
198
|
+
while True:
|
|
199
|
+
answer = ui_bridge.ask(question, re_ask, choices)
|
|
200
|
+
if isinstance(answer, int) and not isinstance(answer, bool):
|
|
201
|
+
if 0 <= answer < len(choices):
|
|
202
|
+
return choices[answer]
|
|
203
|
+
re_ask = 'Please pick one of the listed choices.'
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
return string_best_match(_as_text(answer), choices, 'choice',
|
|
207
|
+
ui_bridge.error_file())
|
|
208
|
+
except ValueError:
|
|
209
|
+
re_ask = 'Please pick one of the listed choices.'
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_company(ui_bridge: YesNoUiBridge) -> CompanyWorkHours:
|
|
213
|
+
"""Ask for the company weekly schedule and exception periods."""
|
|
214
|
+
ui_bridge.show('Company work hours per week day:')
|
|
215
|
+
work_hours = _build_schedule(ui_bridge)
|
|
216
|
+
exceptions = _build_exceptions(ui_bridge,
|
|
217
|
+
'company holiday, closure or special '
|
|
218
|
+
'work period')
|
|
219
|
+
return CompanyWorkHours(work_hours=work_hours, exceptions=exceptions)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_schedule(ui_bridge: YesNoUiBridge) -> ScheduleWorkHours:
|
|
223
|
+
"""Ask for the work hours of each week day."""
|
|
224
|
+
schedule: ScheduleWorkHours = {}
|
|
225
|
+
for week_day in WeekDay:
|
|
226
|
+
schedule[week_day] = _ask_number(
|
|
227
|
+
ui_bridge, f'Work hours on {week_day.name.capitalize()}',
|
|
228
|
+
DEFAULT_WORK_WEEK[week_day], 0.0, None)
|
|
229
|
+
return schedule
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_exceptions(ui_bridge: YesNoUiBridge,
|
|
233
|
+
label: str) -> list[ExceptionWorkHours]:
|
|
234
|
+
"""Loop asking for work-hour exception periods of the given kind."""
|
|
235
|
+
exceptions: list[ExceptionWorkHours] = []
|
|
236
|
+
while _ask_yes_no(ui_bridge, f'Add a {label}?', False):
|
|
237
|
+
exceptions.append(_ask_exception(ui_bridge))
|
|
238
|
+
return exceptions
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ask_exception(ui_bridge: YesNoUiBridge) -> ExceptionWorkHours:
|
|
242
|
+
"""Ask for one work-hour exception period."""
|
|
243
|
+
start_date = _ask_date(ui_bridge, 'Start date')
|
|
244
|
+
end_date = _ask_end_date(ui_bridge, 'End date', start_date)
|
|
245
|
+
hours = _ask_number(ui_bridge, 'Work hours per day during the period', 0.0,
|
|
246
|
+
0.0, None)
|
|
247
|
+
new_work_days = _ask_yes_no(
|
|
248
|
+
ui_bridge, 'Does this add work on days that are normally free?', False)
|
|
249
|
+
return ExceptionWorkHours(start_date=start_date, end_date=end_date,
|
|
250
|
+
hours_per_day=hours, new_work_days=new_work_days)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _build_persons(ui_bridge: YesNoUiBridge) -> dict[str, Person]:
|
|
254
|
+
"""Loop asking for persons and their personal work-hour exceptions."""
|
|
255
|
+
persons: dict[str, Person] = {}
|
|
256
|
+
while _ask_yes_no(ui_bridge, 'Add a person?', False):
|
|
257
|
+
name = _ask_person_name(ui_bridge, persons)
|
|
258
|
+
exceptions = _build_exceptions(
|
|
259
|
+
ui_bridge, f'vacation or work-hour exception for {name}')
|
|
260
|
+
persons[name.lower()] = Person(name=name, exceptions=exceptions)
|
|
261
|
+
return persons
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _ask_person_name(ui_bridge: YesNoUiBridge,
|
|
265
|
+
persons: dict[str, Person]) -> str:
|
|
266
|
+
"""Ask for a person name that is not already used."""
|
|
267
|
+
re_ask: Optional[str] = None
|
|
268
|
+
while True:
|
|
269
|
+
name = _ask_text(ui_bridge, 'Person name')
|
|
270
|
+
if name.lower() not in persons:
|
|
271
|
+
return name
|
|
272
|
+
re_ask = f'A person named {name!r} already exists.'
|
|
273
|
+
ui_bridge.show(re_ask)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_teams(ui_bridge: YesNoUiBridge,
|
|
277
|
+
person_names: list[str]) -> list[Team]:
|
|
278
|
+
"""Loop asking for teams and their memberships."""
|
|
279
|
+
teams: list[Team] = []
|
|
280
|
+
while _ask_yes_no(ui_bridge, 'Add a team?', False):
|
|
281
|
+
teams.append(_ask_team(ui_bridge, person_names))
|
|
282
|
+
return teams
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _ask_team(ui_bridge: YesNoUiBridge, person_names: list[str]) -> Team:
|
|
286
|
+
"""Ask for one team and its memberships."""
|
|
287
|
+
name = _ask_text(ui_bridge, 'Team name')
|
|
288
|
+
velocity = _ask_number(ui_bridge, 'Team velocity', 0.0, 0.0, None)
|
|
289
|
+
sum_fte = _ask_number(ui_bridge,
|
|
290
|
+
'Sum of full-time equivalents at that velocity', 1.0,
|
|
291
|
+
None, None)
|
|
292
|
+
sprint_length = _ask_int(ui_bridge, 'Sprint length in working days', 10, 1)
|
|
293
|
+
aliases = _build_aliases(ui_bridge)
|
|
294
|
+
members = _build_members(ui_bridge, person_names)
|
|
295
|
+
return Team(name=name, velocity=velocity, sum_fte_at_velocity=sum_fte,
|
|
296
|
+
sprint_length=sprint_length, aliases=aliases, members=members)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _build_aliases(ui_bridge: YesNoUiBridge) -> list[str]:
|
|
300
|
+
"""Loop asking for team aliases until an empty answer is given."""
|
|
301
|
+
aliases: list[str] = []
|
|
302
|
+
while _ask_yes_no(ui_bridge, 'Add an alias for the team?', False):
|
|
303
|
+
aliases.append(_ask_text(ui_bridge, 'Team alias'))
|
|
304
|
+
return aliases
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _build_members(ui_bridge: YesNoUiBridge,
|
|
308
|
+
person_names: list[str]) -> list[Membership]:
|
|
309
|
+
"""Loop asking for team memberships referencing known persons.
|
|
310
|
+
|
|
311
|
+
A person who is already a member of this team is not offered again,
|
|
312
|
+
so each person joins the team at most once.
|
|
313
|
+
"""
|
|
314
|
+
if not person_names:
|
|
315
|
+
ui_bridge.show('No persons defined yet, so the team has no members.')
|
|
316
|
+
return []
|
|
317
|
+
members: list[Membership] = []
|
|
318
|
+
available = list(person_names)
|
|
319
|
+
while available and _ask_yes_no(ui_bridge, 'Add a team member?', False):
|
|
320
|
+
membership = _ask_membership(ui_bridge, available)
|
|
321
|
+
members.append(membership)
|
|
322
|
+
available.remove(membership.person_name)
|
|
323
|
+
return members
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ask_membership(ui_bridge: YesNoUiBridge,
|
|
327
|
+
person_names: list[str]) -> Membership:
|
|
328
|
+
"""Ask for one team membership."""
|
|
329
|
+
person_name = _ask_choice(ui_bridge, 'Select the person:', person_names)
|
|
330
|
+
fte = _ask_number(ui_bridge, 'Full-time equivalent in this team', 1.0, 0.0,
|
|
331
|
+
1.0)
|
|
332
|
+
start_date = _ask_opt_date(ui_bridge, 'Membership start date')
|
|
333
|
+
end_date = _ask_membership_end(ui_bridge, start_date)
|
|
334
|
+
fte_exceptions = _build_fte_exceptions(ui_bridge)
|
|
335
|
+
return Membership(person_name=person_name, fte=fte, start_date=start_date,
|
|
336
|
+
end_date=end_date, fte_exceptions=fte_exceptions)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _ask_membership_end(ui_bridge: YesNoUiBridge,
|
|
340
|
+
start_date: Optional[date]) -> Optional[date]:
|
|
341
|
+
"""Ask for an optional membership end date not before the start date."""
|
|
342
|
+
while True:
|
|
343
|
+
end_date = _ask_opt_date(ui_bridge, 'Membership end date')
|
|
344
|
+
if end_date is None or start_date is None or end_date >= start_date:
|
|
345
|
+
return end_date
|
|
346
|
+
ui_bridge.show('The end date must not be before the start date.')
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _build_fte_exceptions(ui_bridge: YesNoUiBridge) -> list[FteException]:
|
|
350
|
+
"""Loop asking for full-time-equivalent exception periods."""
|
|
351
|
+
exceptions: list[FteException] = []
|
|
352
|
+
while _ask_yes_no(ui_bridge,
|
|
353
|
+
'Add a full-time-equivalent exception period?', False):
|
|
354
|
+
exceptions.append(_ask_fte_exception(ui_bridge))
|
|
355
|
+
return exceptions
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _ask_fte_exception(ui_bridge: YesNoUiBridge) -> FteException:
|
|
359
|
+
"""Ask for one full-time-equivalent exception period."""
|
|
360
|
+
start_date = _ask_date(ui_bridge, 'Exception start date')
|
|
361
|
+
end_date = _ask_end_date(ui_bridge, 'Exception end date', start_date)
|
|
362
|
+
fte = _ask_number(ui_bridge, 'Full-time equivalent during the period', 1.0,
|
|
363
|
+
0.0, 1.0)
|
|
364
|
+
return FteException(start_date=start_date, end_date=end_date, fte=fte)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def teams_config_wizard(ui_bridge: YesNoUiBridge) -> AvailableTeamsConfig:
|
|
368
|
+
"""Interactively create a workforce with optional TableIO presets.
|
|
369
|
+
|
|
370
|
+
The workforce is entered as by :func:`available_teams_wizard`, and the
|
|
371
|
+
user may then add any number of named input and output TableIO
|
|
372
|
+
configuration presets that are stored alongside the workforce.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
ui_bridge: Bridge between the wizard and the user interface.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The workforce configuration, ready to be written to a file.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
EOFError: The input ended before all required answers were read.
|
|
382
|
+
"""
|
|
383
|
+
teams = available_teams_wizard(ui_bridge)
|
|
384
|
+
config = AvailableTeamsConfig(neutral=teams)
|
|
385
|
+
config.input_configs = _build_input_presets(ui_bridge)
|
|
386
|
+
config.output_configs = _build_output_presets(ui_bridge)
|
|
387
|
+
return config
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _caps(file_access: FileAccess, ui_bridge: YesNoUiBridge) -> Capabilities:
|
|
391
|
+
"""Return the TableIO capabilities for one file access mode."""
|
|
392
|
+
return access_capabilities(file_access, error_file=ui_bridge.error_file())
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _collect_presets(ui_bridge: YesNoUiBridge, file_access: FileAccess,
|
|
396
|
+
label: str, from_label: str, to_label: str
|
|
397
|
+
) -> list[tuple[str, TioJsonConfig, dict[str, str]]]:
|
|
398
|
+
"""Loop asking for named TableIO presets of one direction."""
|
|
399
|
+
result: list[tuple[str, TioJsonConfig, dict[str, str]]] = []
|
|
400
|
+
while _ask_yes_no(ui_bridge, f'Add a named {label} configuration?', False):
|
|
401
|
+
used = {name for name, _, _ in result}
|
|
402
|
+
name = _ask_preset_name(ui_bridge, used)
|
|
403
|
+
tableio = tio_json_config_wizard(_caps(file_access, ui_bridge),
|
|
404
|
+
file_access, ui_bridge)
|
|
405
|
+
column_map = _build_column_map(ui_bridge, from_label, to_label)
|
|
406
|
+
result.append((name, tableio, column_map))
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _build_input_presets(ui_bridge: YesNoUiBridge
|
|
411
|
+
) -> dict[str, InputFormatConfig]:
|
|
412
|
+
"""Return the named input presets entered by the user."""
|
|
413
|
+
entries = _collect_presets(ui_bridge, FileAccess.READ, 'input',
|
|
414
|
+
'external column', 'internal field')
|
|
415
|
+
return {name: make_input_config(tableio, column_map)
|
|
416
|
+
for name, tableio, column_map in entries}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _build_output_presets(ui_bridge: YesNoUiBridge
|
|
420
|
+
) -> dict[str, OutputFormatConfig]:
|
|
421
|
+
"""Return the named output presets entered by the user."""
|
|
422
|
+
entries = _collect_presets(ui_bridge, FileAccess.CREATE, 'output',
|
|
423
|
+
'internal field', 'external column')
|
|
424
|
+
return {name: make_output_config(tableio, column_map)
|
|
425
|
+
for name, tableio, column_map in entries}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _ask_preset_name(ui_bridge: YesNoUiBridge, used: set[str]) -> str:
|
|
429
|
+
"""Ask for a preset name of letters and digits that is not used yet."""
|
|
430
|
+
while True:
|
|
431
|
+
name = _ask_text(ui_bridge, 'Preset name (letters and digits)')
|
|
432
|
+
if PRESET_NAME_RE.match(name) is None:
|
|
433
|
+
ui_bridge.show('Use only letters and digits for a preset name.')
|
|
434
|
+
elif name in used:
|
|
435
|
+
ui_bridge.show(f'A preset named {name!r} already exists.')
|
|
436
|
+
else:
|
|
437
|
+
return name
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _build_column_map(ui_bridge: YesNoUiBridge, from_label: str,
|
|
441
|
+
to_label: str) -> dict[str, str]:
|
|
442
|
+
"""Loop asking for column-name mappings of one direction."""
|
|
443
|
+
mapping: dict[str, str] = {}
|
|
444
|
+
while _ask_yes_no(ui_bridge, 'Add a column-name mapping?', False):
|
|
445
|
+
source = _ask_text(ui_bridge, f'{from_label} name')
|
|
446
|
+
target = _ask_text(ui_bridge, f'{to_label} name')
|
|
447
|
+
mapping[source] = target
|
|
448
|
+
return mapping
|