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.
@@ -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