backlogops 0.4__tar.gz → 0.5__tar.gz
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-0.4 → backlogops-0.5}/PKG-INFO +7 -10
- {backlogops-0.4 → backlogops-0.5}/README_pypi.md +5 -8
- {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/PKG-INFO +7 -10
- {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/requires.txt +1 -1
- {backlogops-0.4 → backlogops-0.5}/setup.py +2 -2
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_ops_wizard.py +140 -18
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_wizard.py +39 -3
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_forms.py +107 -117
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_helpers.py +8 -176
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_navigator.py +12 -30
- {backlogops-0.4 → backlogops-0.5}/LICENSE.txt +0 -0
- {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/SOURCES.txt +0 -0
- {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/dependency_links.txt +0 -0
- {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/top_level.txt +0 -0
- {backlogops-0.4 → backlogops-0.5}/pyproject.toml +0 -0
- {backlogops-0.4 → backlogops-0.5}/setup.cfg +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/__init__.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/apply_format_rules.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/available_teams.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/available_teams_config.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_helpers.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_in_release_order.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_ops_config.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_releases_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/blo_version_reporter.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/config_file_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/date_ranges.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/demo_backlog.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/estimate_ready_date.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/format_rules.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/io_config.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/io_preset_wizard.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_connect.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_io_config.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_order_releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_backlog.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_by_keys.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_move_keys.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_read.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rename_releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_search.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_token.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_update_backlog.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_update_releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_fields.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_format.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/key_list_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/levels.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/move_keys_first.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/name_list_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/no_text_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/order_by_dependencies.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/person.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/py.typed +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/release_backlog_updates.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/release_change_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/releases.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/rename_list_io.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/table_create.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/table_rows.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/team.py +0 -0
- {backlogops-0.4 → backlogops-0.5}/src/backlogops/work_hours.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backlogops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
4
4
|
Summary: Library with backlog operations.
|
|
5
5
|
Author: Tom Björkholm
|
|
6
6
|
Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
|
|
@@ -26,7 +26,7 @@ Requires-Dist: config-as-json>=1.4
|
|
|
26
26
|
Requires-Dist: cryptography>=49.0.0
|
|
27
27
|
Requires-Dist: jira[cli,opt]>=3.10.5
|
|
28
28
|
Requires-Dist: tableio>=1.1
|
|
29
|
-
Requires-Dist: tableio-cfg-json>=0
|
|
29
|
+
Requires-Dist: tableio-cfg-json>=1.0
|
|
30
30
|
Requires-Dist: versionreporter>=0.4
|
|
31
31
|
Dynamic: author
|
|
32
32
|
Dynamic: license-file
|
|
@@ -51,15 +51,11 @@ There are 3 related packages for backlog operations:
|
|
|
51
51
|
|
|
52
52
|
## Project status
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
place, so the main work from here is on the user experience rather than on new
|
|
56
|
-
core functionality. The three packages share a version number and are released
|
|
57
|
-
together.
|
|
54
|
+
The three packages share a version number and are released together.
|
|
58
55
|
|
|
59
56
|
The library is not meant to ever be "finished": using it for real planning keeps
|
|
60
57
|
surfacing new ideas. Some of those will be added to backlogops itself, and
|
|
61
|
-
others will fit better in separate packages built on top of backlogops.
|
|
62
|
-
adopters are very welcome, and their feedback shapes what comes next.
|
|
58
|
+
others will fit better in separate packages built on top of backlogops.
|
|
63
59
|
|
|
64
60
|
## Available functionality
|
|
65
61
|
|
|
@@ -316,9 +312,10 @@ For the full set of public names see the API documentation linked above.
|
|
|
316
312
|
|
|
317
313
|
## Test summary
|
|
318
314
|
|
|
319
|
-
- Test result:
|
|
315
|
+
- Test result: 2450 passed, 9 deselected in 41s
|
|
320
316
|
- No flake8 warnings.
|
|
321
317
|
- No mypy errors found.
|
|
318
|
+
- No pylint warnings.
|
|
322
319
|
- No python layout warnings.
|
|
323
|
-
- Built version(s): 0.
|
|
320
|
+
- Built version(s): 0.5
|
|
324
321
|
- Build and test using Python 3.14.6
|
|
@@ -16,15 +16,11 @@ There are 3 related packages for backlog operations:
|
|
|
16
16
|
|
|
17
17
|
## Project status
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
place, so the main work from here is on the user experience rather than on new
|
|
21
|
-
core functionality. The three packages share a version number and are released
|
|
22
|
-
together.
|
|
19
|
+
The three packages share a version number and are released together.
|
|
23
20
|
|
|
24
21
|
The library is not meant to ever be "finished": using it for real planning keeps
|
|
25
22
|
surfacing new ideas. Some of those will be added to backlogops itself, and
|
|
26
|
-
others will fit better in separate packages built on top of backlogops.
|
|
27
|
-
adopters are very welcome, and their feedback shapes what comes next.
|
|
23
|
+
others will fit better in separate packages built on top of backlogops.
|
|
28
24
|
|
|
29
25
|
## Available functionality
|
|
30
26
|
|
|
@@ -281,9 +277,10 @@ For the full set of public names see the API documentation linked above.
|
|
|
281
277
|
|
|
282
278
|
## Test summary
|
|
283
279
|
|
|
284
|
-
- Test result:
|
|
280
|
+
- Test result: 2450 passed, 9 deselected in 41s
|
|
285
281
|
- No flake8 warnings.
|
|
286
282
|
- No mypy errors found.
|
|
283
|
+
- No pylint warnings.
|
|
287
284
|
- No python layout warnings.
|
|
288
|
-
- Built version(s): 0.
|
|
285
|
+
- Built version(s): 0.5
|
|
289
286
|
- Build and test using Python 3.14.6
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backlogops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
4
4
|
Summary: Library with backlog operations.
|
|
5
5
|
Author: Tom Björkholm
|
|
6
6
|
Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
|
|
@@ -26,7 +26,7 @@ Requires-Dist: config-as-json>=1.4
|
|
|
26
26
|
Requires-Dist: cryptography>=49.0.0
|
|
27
27
|
Requires-Dist: jira[cli,opt]>=3.10.5
|
|
28
28
|
Requires-Dist: tableio>=1.1
|
|
29
|
-
Requires-Dist: tableio-cfg-json>=0
|
|
29
|
+
Requires-Dist: tableio-cfg-json>=1.0
|
|
30
30
|
Requires-Dist: versionreporter>=0.4
|
|
31
31
|
Dynamic: author
|
|
32
32
|
Dynamic: license-file
|
|
@@ -51,15 +51,11 @@ There are 3 related packages for backlog operations:
|
|
|
51
51
|
|
|
52
52
|
## Project status
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
place, so the main work from here is on the user experience rather than on new
|
|
56
|
-
core functionality. The three packages share a version number and are released
|
|
57
|
-
together.
|
|
54
|
+
The three packages share a version number and are released together.
|
|
58
55
|
|
|
59
56
|
The library is not meant to ever be "finished": using it for real planning keeps
|
|
60
57
|
surfacing new ideas. Some of those will be added to backlogops itself, and
|
|
61
|
-
others will fit better in separate packages built on top of backlogops.
|
|
62
|
-
adopters are very welcome, and their feedback shapes what comes next.
|
|
58
|
+
others will fit better in separate packages built on top of backlogops.
|
|
63
59
|
|
|
64
60
|
## Available functionality
|
|
65
61
|
|
|
@@ -316,9 +312,10 @@ For the full set of public names see the API documentation linked above.
|
|
|
316
312
|
|
|
317
313
|
## Test summary
|
|
318
314
|
|
|
319
|
-
- Test result:
|
|
315
|
+
- Test result: 2450 passed, 9 deselected in 41s
|
|
320
316
|
- No flake8 warnings.
|
|
321
317
|
- No mypy errors found.
|
|
318
|
+
- No pylint warnings.
|
|
322
319
|
- No python layout warnings.
|
|
323
|
-
- Built version(s): 0.
|
|
320
|
+
- Built version(s): 0.5
|
|
324
321
|
- Build and test using Python 3.14.6
|
|
@@ -5,7 +5,7 @@ from setuptools import setup
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name='backlogops',
|
|
8
|
-
version='0.
|
|
8
|
+
version='0.5',
|
|
9
9
|
description='Library with backlog operations.',
|
|
10
10
|
author='Tom Björkholm',
|
|
11
11
|
author_email='klausuler_linnet0q@icloud.com',
|
|
@@ -18,7 +18,7 @@ setup(
|
|
|
18
18
|
'cryptography >= 49.0.0',
|
|
19
19
|
'jira[cli,opt] >= 3.10.5',
|
|
20
20
|
'tableio >= 1.1',
|
|
21
|
-
'tableio-cfg-json >= 0
|
|
21
|
+
'tableio-cfg-json >= 1.0',
|
|
22
22
|
'versionreporter >= 0.4'
|
|
23
23
|
]
|
|
24
24
|
)
|
|
@@ -31,7 +31,8 @@ from backlogops.levels import DEFAULT_LEVELS, Level
|
|
|
31
31
|
from backlogops.person import Person
|
|
32
32
|
from backlogops.table_rows import RELEASE_FIELDS
|
|
33
33
|
from backlogops.team import FteException, Membership, Team
|
|
34
|
-
from backlogops.work_hours import CompanyWorkHours,
|
|
34
|
+
from backlogops.work_hours import CompanyWorkHours, DEFAULT_WORK_WEEK, \
|
|
35
|
+
ExceptionWorkHours, ScheduleWorkHours, WeekDay
|
|
35
36
|
from backlogops.wizard_helpers import _backlog_map_fields
|
|
36
37
|
from backlogops.wizard_navigator import _Navigator, _ask_level_display
|
|
37
38
|
from backlogops.wizard_forms import FormField, FormResult, choice_field, \
|
|
@@ -220,29 +221,145 @@ def _levels_or_none(levels: list[Level]) -> Optional[list[Level]]:
|
|
|
220
221
|
return levels
|
|
221
222
|
|
|
222
223
|
|
|
224
|
+
_COMPANY_QUESTION = (
|
|
225
|
+
'Configure the company work hours.\n'
|
|
226
|
+
'Set the work hours for each week day, then, if there are any, the '
|
|
227
|
+
'first holiday, closure or special-work period and how many more '
|
|
228
|
+
'there are.')
|
|
229
|
+
"""Instruction shown above the combined company work-hours form."""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_MAX_EXCEPTIONS = 60
|
|
233
|
+
"""Upper bound on the number of extra company periods the wizard adds."""
|
|
234
|
+
|
|
235
|
+
|
|
223
236
|
def _build_company(nav: _Navigator,
|
|
224
237
|
default: Optional[AvailableTeams]) -> CompanyWorkHours:
|
|
225
238
|
"""Ask for the company weekly schedule and holiday periods.
|
|
226
239
|
|
|
227
|
-
The weekly
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
The weekly work hours and the first holiday, closure or special-work
|
|
241
|
+
period are asked together on one form; any further periods each get
|
|
242
|
+
their own form whose question lists the periods entered so far.
|
|
230
243
|
"""
|
|
231
244
|
company = default.company_work_hours if default else None
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
values = nav.ask_form(_COMPANY_QUESTION, _company_fields(), _company_rule,
|
|
246
|
+
seed=_company_seed(company))
|
|
247
|
+
stored = company.exceptions if company else []
|
|
248
|
+
return CompanyWorkHours(work_hours=_schedule_from(values),
|
|
249
|
+
exceptions=_build_exceptions(nav, values, stored))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _schedule_fields() -> list[FormField]:
|
|
253
|
+
"""Return one work-hours field per week day, Monday first.
|
|
254
|
+
|
|
255
|
+
A day has between zero and twenty-four work hours, so the float field
|
|
256
|
+
enforces that inclusive range.
|
|
257
|
+
"""
|
|
258
|
+
return [number_field(day.name.lower(), day.name.capitalize(),
|
|
259
|
+
default=DEFAULT_WORK_WEEK[day], minimum=0.0,
|
|
260
|
+
maximum=24.0)
|
|
261
|
+
for day in WeekDay]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _company_fields() -> list[FormField]:
|
|
265
|
+
"""Return the combined company schedule and first-period fields."""
|
|
266
|
+
periods_q = 'Any holiday, closure or special-work periods?'
|
|
267
|
+
more_q = 'Number of additional such periods'
|
|
268
|
+
return (_schedule_fields()
|
|
269
|
+
+ [yes_no_field('has_periods', periods_q, False)]
|
|
270
|
+
+ _exception_fields()
|
|
271
|
+
+ [int_field('more', more_q, default=0, minimum=0,
|
|
272
|
+
maximum=_MAX_EXCEPTIONS)])
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _company_rule(values: FormResult) -> tuple[Optional[str], set[str]]:
|
|
276
|
+
"""Disable the period fields with no periods, else order-check them."""
|
|
277
|
+
if not values.flag('has_periods'):
|
|
278
|
+
return None, {'start', 'end', 'hours', 'new_days', 'more'}
|
|
279
|
+
return _period_rule(values)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _company_seed(company: Optional[CompanyWorkHours]) -> Optional[FormResult]:
|
|
283
|
+
"""Return the combined company form values from stored work hours."""
|
|
284
|
+
if company is None:
|
|
285
|
+
return None
|
|
286
|
+
values: dict[str, object] = {
|
|
287
|
+
day.name.lower(): company.work_hours.get(day, DEFAULT_WORK_WEEK[day])
|
|
288
|
+
for day in WeekDay}
|
|
289
|
+
periods = company.exceptions
|
|
290
|
+
values['has_periods'] = bool(periods)
|
|
291
|
+
values['more'] = max(len(periods) - 1, 0)
|
|
292
|
+
if periods:
|
|
293
|
+
values.update(_period_values(periods[0]))
|
|
294
|
+
return FormResult(values)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _schedule_from(values: FormResult) -> ScheduleWorkHours:
|
|
298
|
+
"""Return the weekly schedule read from the combined company form."""
|
|
299
|
+
return {day: values.number(day.name.lower()) for day in WeekDay}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _build_exceptions(nav: _Navigator, values: FormResult,
|
|
303
|
+
stored: list[ExceptionWorkHours]
|
|
304
|
+
) -> list[ExceptionWorkHours]:
|
|
305
|
+
"""Return the company periods, the first from the combined form."""
|
|
306
|
+
if not values.flag('has_periods'):
|
|
307
|
+
return []
|
|
308
|
+
first = _period_from(values)
|
|
309
|
+
rest = stored[1:] if len(stored) > 1 else []
|
|
310
|
+
return [first] + _more_exceptions(nav, values.whole('more'), first, rest)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _more_exceptions(nav: _Navigator, count: int, first: ExceptionWorkHours,
|
|
314
|
+
seeds: list[ExceptionWorkHours]
|
|
315
|
+
) -> list[ExceptionWorkHours]:
|
|
316
|
+
"""Collect the additional company periods after the first one.
|
|
317
|
+
|
|
318
|
+
Each period is asked on its own form whose question lists the periods
|
|
319
|
+
entered so far. The running list is reset on the first item, so a
|
|
320
|
+
replayed sub-level after a back or cancel request does not double it.
|
|
321
|
+
"""
|
|
322
|
+
shown = [first]
|
|
323
|
+
|
|
324
|
+
def build(item: Optional[tuple[int, Optional[ExceptionWorkHours]]]
|
|
325
|
+
) -> ExceptionWorkHours:
|
|
326
|
+
"""Ask one additional period, shown after the earlier ones."""
|
|
327
|
+
assert item is not None
|
|
328
|
+
index, seed = item
|
|
329
|
+
if index == 0:
|
|
330
|
+
del shown[1:]
|
|
331
|
+
values = nav.ask_form(_more_question(shown), _exception_fields(),
|
|
332
|
+
_period_rule, seed=_exc_seed(seed))
|
|
333
|
+
shown.append(_period_from(values))
|
|
334
|
+
return shown[-1]
|
|
335
|
+
indexed: list[tuple[int, Optional[ExceptionWorkHours]]] = [
|
|
336
|
+
(k, seeds[k] if k < len(seeds) else None) for k in range(count)]
|
|
337
|
+
return nav.counted(count, indexed, build)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _more_question(shown: list[ExceptionWorkHours]) -> str:
|
|
341
|
+
"""Return the next-period question listing the periods entered so far."""
|
|
342
|
+
lines = ['Configure the next holiday, closure or special-work period.',
|
|
343
|
+
'Periods entered so far:']
|
|
344
|
+
return '\n'.join(lines + [_period_line(period) for period in shown])
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _period_line(period: ExceptionWorkHours) -> str:
|
|
348
|
+
"""Return a one-line summary of one exception period."""
|
|
349
|
+
extra = ' (adds free-day work)' if period.new_work_days else ''
|
|
350
|
+
return (f' {period.start_date} to {period.end_date}: '
|
|
351
|
+
f'{period.hours_per_day:g} h/day{extra}')
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _period_values(period: ExceptionWorkHours) -> dict[str, object]:
|
|
355
|
+
"""Return the form values for one work-hour exception period."""
|
|
356
|
+
return {'start': period.start_date, 'end': period.end_date,
|
|
357
|
+
'hours': period.hours_per_day, 'new_days': period.new_work_days}
|
|
237
358
|
|
|
238
359
|
|
|
239
360
|
def _exc_seed(exc: Optional[ExceptionWorkHours]) -> Optional[FormResult]:
|
|
240
361
|
"""Return the work-hour exception form values from an exception."""
|
|
241
|
-
if exc is None
|
|
242
|
-
return None
|
|
243
|
-
return FormResult({'start': exc.start_date, 'end': exc.end_date,
|
|
244
|
-
'hours': exc.hours_per_day,
|
|
245
|
-
'new_days': exc.new_work_days})
|
|
362
|
+
return None if exc is None else FormResult(_period_values(exc))
|
|
246
363
|
|
|
247
364
|
|
|
248
365
|
def _period_rule(values: FormResult) -> tuple[Optional[str], set[str]]:
|
|
@@ -265,16 +382,21 @@ def _exception_fields() -> list[FormField]:
|
|
|
265
382
|
'normally free?', False)]
|
|
266
383
|
|
|
267
384
|
|
|
385
|
+
def _period_from(values: FormResult) -> ExceptionWorkHours:
|
|
386
|
+
"""Return one exception period from a form's period fields."""
|
|
387
|
+
return ExceptionWorkHours(start_date=values.day('start'),
|
|
388
|
+
end_date=values.day('end'),
|
|
389
|
+
hours_per_day=values.number('hours'),
|
|
390
|
+
new_work_days=values.flag('new_days'))
|
|
391
|
+
|
|
392
|
+
|
|
268
393
|
def _ask_exception(nav: _Navigator, seed: Optional[ExceptionWorkHours] = None
|
|
269
394
|
) -> ExceptionWorkHours:
|
|
270
395
|
"""Ask for one work-hour exception period on a single form."""
|
|
271
396
|
values = nav.ask_form('Configure the work-hour exception period.',
|
|
272
397
|
_exception_fields(), _period_rule,
|
|
273
398
|
seed=_exc_seed(seed))
|
|
274
|
-
return
|
|
275
|
-
end_date=values.day('end'),
|
|
276
|
-
hours_per_day=values.number('hours'),
|
|
277
|
-
new_work_days=values.flag('new_days'))
|
|
399
|
+
return _period_from(values)
|
|
278
400
|
|
|
279
401
|
|
|
280
402
|
def _build_persons(nav: _Navigator,
|
|
@@ -25,7 +25,7 @@ token is visible while typed; the pass phrases are masked.
|
|
|
25
25
|
from dataclasses import dataclass
|
|
26
26
|
from functools import partial
|
|
27
27
|
from typing import Callable, Optional, TypeVar
|
|
28
|
-
from tableio_cfg_json import WizardPathKind
|
|
28
|
+
from tableio_cfg_json import PrefillValueType, WizardPathKind
|
|
29
29
|
from backlogops.jira_io_config import DEF_BACKLOG_COLUMN_MAP, \
|
|
30
30
|
DEF_RELEASE_COLUMN_MAP, JiraColumnMap, JiraConnectConfig, JiraIOConfig, \
|
|
31
31
|
JiraIssueTypeMap, JiraPreset, JiraType, TokenStorage, _FILE_MODES, \
|
|
@@ -364,8 +364,9 @@ _ISSUE_MAP_Q = ('Use a level-to-issue-type map when writing (otherwise the '
|
|
|
364
364
|
|
|
365
365
|
_FILTER_EG = default_jira_filter('<project key>')
|
|
366
366
|
"""Illustrative blank-filter default shown in the filter help text."""
|
|
367
|
-
_FILTER_HELP = ('Leave blank to select the
|
|
368
|
-
f'
|
|
367
|
+
_FILTER_HELP = ('Leave blank to select the whole project in rank order; '
|
|
368
|
+
f'the default {_FILTER_EG} is offered once the project key '
|
|
369
|
+
'is set.')
|
|
369
370
|
"""Help shown for the blank-defaulting issue filter field."""
|
|
370
371
|
|
|
371
372
|
|
|
@@ -417,6 +418,40 @@ def _preset_rule(has_issue_type: bool
|
|
|
417
418
|
return rule
|
|
418
419
|
|
|
419
420
|
|
|
421
|
+
# pylint: disable-next=too-few-public-methods
|
|
422
|
+
class _FilterPrefill:
|
|
423
|
+
"""Offer the default rank filter while the preset filter stays blank.
|
|
424
|
+
|
|
425
|
+
While the user has not written their own issue filter, every change to
|
|
426
|
+
the project key re-derives the default filter and offers it to the
|
|
427
|
+
filter field, so the filter tracks the project as it is typed. The
|
|
428
|
+
moment the filter holds text the user placed there, tracking stops and
|
|
429
|
+
the filter is left alone. The last value offered is remembered so the
|
|
430
|
+
bridge writing that value back does not look like the user typing it.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def __init__(self) -> None:
|
|
434
|
+
"""Start with nothing offered and the filter not yet user-owned."""
|
|
435
|
+
self._taken_over = False
|
|
436
|
+
self._last_fill: Optional[str] = None
|
|
437
|
+
|
|
438
|
+
def __call__(self, values: FormResult,
|
|
439
|
+
changed: str) -> list[tuple[str, PrefillValueType]]:
|
|
440
|
+
"""Return a filter prefill for a project change, or nothing."""
|
|
441
|
+
self._note_user_filter(values)
|
|
442
|
+
project = values.opt_text('def_project')
|
|
443
|
+
if self._taken_over or changed != 'def_project' or not project:
|
|
444
|
+
return []
|
|
445
|
+
self._last_fill = default_jira_filter(project)
|
|
446
|
+
return [('def_filter', self._last_fill)]
|
|
447
|
+
|
|
448
|
+
def _note_user_filter(self, values: FormResult) -> None:
|
|
449
|
+
"""Mark the filter user-owned once it holds text we did not offer."""
|
|
450
|
+
current = values.opt_text('def_filter')
|
|
451
|
+
if current and current != self._last_fill:
|
|
452
|
+
self._taken_over = True
|
|
453
|
+
|
|
454
|
+
|
|
420
455
|
def _preset_seed(name: Optional[str], preset: Optional[JiraPreset],
|
|
421
456
|
choices: _PresetChoices) -> Optional[FormResult]:
|
|
422
457
|
"""Return the preset form values from a stored preset."""
|
|
@@ -444,6 +479,7 @@ def _ask_preset(nav: _Navigator, choices: _PresetChoices, used: set[str],
|
|
|
444
479
|
values = nav.ask_form('Configure the Jira preset.',
|
|
445
480
|
_preset_fields(used, choices),
|
|
446
481
|
_preset_rule(bool(choices.issue_type_maps)),
|
|
482
|
+
prefill=_FilterPrefill(),
|
|
447
483
|
seed=_preset_seed(seed_name, seed_preset, choices))
|
|
448
484
|
return _preset_from(nav, values, choices)
|
|
449
485
|
|
|
@@ -15,9 +15,16 @@ the answers so far make irrelevant. :func:`run_form` shows the fields,
|
|
|
15
15
|
disables the irrelevant ones, blocks an invalid form and returns the typed
|
|
16
16
|
answers as a :class:`FormResult`.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
A form may also pass a ``prefill`` callback. It is called with the current
|
|
19
|
+
:class:`FormResult` and the key of the field that just changed, and returns
|
|
20
|
+
``(key, value)`` requests that offer a value to another field as its live
|
|
21
|
+
default, exactly as if the user had typed it. This lets one field be derived
|
|
22
|
+
from others, such as a Jira filter derived from the project key, while the
|
|
23
|
+
user stays free to override the offered value.
|
|
24
|
+
|
|
25
|
+
Dates use :class:`AskDateField` (a calendar picker in a graphical bridge) and
|
|
26
|
+
decimals use :class:`AskFloatField`, so both come back as typed answers, with
|
|
27
|
+
their format and range checked by the field rather than by validated text.
|
|
21
28
|
"""
|
|
22
29
|
|
|
23
30
|
# Copyright (c) 2026, Tom Björkholm
|
|
@@ -27,21 +34,18 @@ from dataclasses import dataclass, replace
|
|
|
27
34
|
from datetime import date
|
|
28
35
|
from pathlib import Path
|
|
29
36
|
from typing import Callable, Optional, Sequence
|
|
30
|
-
from tableio_cfg_json import AnswerField, AskChoiceField,
|
|
31
|
-
AskIntField, AskPathField, AskTextField,
|
|
32
|
-
PartFormValidationResult, PathAskOptions,
|
|
33
|
-
WizardUiBridge
|
|
37
|
+
from tableio_cfg_json import AnswerField, AskChoiceField, AskDateField, \
|
|
38
|
+
AskField, AskFloatField, AskIntField, AskPathField, AskTextField, \
|
|
39
|
+
AskYesNoField, PartFormValidationResult, PathAskOptions, PrefillValues, \
|
|
40
|
+
PrefillValueType, WizardPathKind, WizardUiBridge
|
|
34
41
|
from backlogops.io_config import PRESET_NAME_RE
|
|
35
42
|
|
|
36
43
|
_REQUIRED = 'Please enter a value.'
|
|
37
|
-
|
|
38
|
-
_DATE_ERROR = 'Please enter a date as YYYY-MM-DD.'
|
|
44
|
+
_DATE_ERROR = 'Please enter a date.'
|
|
39
45
|
_NAME_ERROR = 'Use only letters and digits for a name.'
|
|
40
46
|
_CHOICE_ERROR = 'Please choose a value.'
|
|
41
47
|
_PHRASE_ERROR = 'Please enter a pass phrase.'
|
|
42
48
|
_INVALID_FORM = 'Please correct the highlighted fields.'
|
|
43
|
-
_DATE_HINT = ' (YYYY-MM-DD)'
|
|
44
|
-
_DATE_HINT_OPT = ' (YYYY-MM-DD, blank for none)'
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
@dataclass(frozen=True)
|
|
@@ -126,6 +130,12 @@ def _no_rule(_values: FormResult) -> tuple[Optional[str], set[str]]:
|
|
|
126
130
|
return None, set()
|
|
127
131
|
|
|
128
132
|
|
|
133
|
+
def _no_prefill(_values: FormResult,
|
|
134
|
+
_changed: str) -> list[tuple[str, PrefillValueType]]:
|
|
135
|
+
"""Offer no field a derived default."""
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
129
139
|
def _seeded_fields(fields: Sequence[FormField],
|
|
130
140
|
seed: Optional[FormResult]) -> list[FormField]:
|
|
131
141
|
"""Return the fields with each ask pre-filled from a seed result."""
|
|
@@ -148,15 +158,24 @@ def _reseed_ask(ask: AskField, value: object) -> AskField:
|
|
|
148
158
|
if isinstance(ask, AskIntField) and isinstance(value, int) \
|
|
149
159
|
and not isinstance(value, bool):
|
|
150
160
|
return replace(ask, default=_clamped_int(value, ask))
|
|
151
|
-
if isinstance(ask, AskChoiceField) and isinstance(value, str) \
|
|
152
|
-
and value in ask.choices:
|
|
153
|
-
return replace(ask, default=value)
|
|
154
|
-
if isinstance(ask, AskTextField) and not ask.sensitive:
|
|
155
|
-
text = _as_text(value)
|
|
156
|
-
return ask if text is None else replace(ask, default=text)
|
|
157
161
|
if isinstance(ask, AskPathField) and isinstance(value, str) and value:
|
|
158
162
|
options = replace(ask.path_options, default=Path(value))
|
|
159
163
|
return replace(ask, path_options=options)
|
|
164
|
+
return _reseed_typed(ask, value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _reseed_typed(ask: AskField, value: object) -> AskField:
|
|
168
|
+
"""Return ask reseeded for the value-typed fields, else unchanged."""
|
|
169
|
+
if isinstance(ask, AskFloatField) and isinstance(value, float):
|
|
170
|
+
return replace(ask, default=value)
|
|
171
|
+
if isinstance(ask, AskDateField) and isinstance(value, date):
|
|
172
|
+
return replace(ask, default=value)
|
|
173
|
+
if isinstance(ask, AskChoiceField) and isinstance(value, str) \
|
|
174
|
+
and value in ask.choices:
|
|
175
|
+
return replace(ask, default=value)
|
|
176
|
+
if isinstance(ask, AskTextField) and not ask.sensitive \
|
|
177
|
+
and isinstance(value, str):
|
|
178
|
+
return replace(ask, default=value)
|
|
160
179
|
return ask
|
|
161
180
|
|
|
162
181
|
|
|
@@ -174,31 +193,25 @@ def _clamped_int(value: int, ask: AskIntField) -> int:
|
|
|
174
193
|
return value
|
|
175
194
|
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
"""Return a text default for a value, or None when it has no text."""
|
|
179
|
-
if isinstance(value, date):
|
|
180
|
-
return value.isoformat()
|
|
181
|
-
if isinstance(value, bool):
|
|
182
|
-
return None
|
|
183
|
-
if isinstance(value, float):
|
|
184
|
-
return _num_text(value)
|
|
185
|
-
if isinstance(value, str):
|
|
186
|
-
return value
|
|
187
|
-
return None
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
# pylint: disable-next=too-many-arguments
|
|
190
197
|
def run_form(bridge: WizardUiBridge, question: str,
|
|
191
198
|
fields: Sequence[FormField],
|
|
192
199
|
rule: Callable[[FormResult], tuple[Optional[str], set[str]]]
|
|
193
|
-
= _no_rule,
|
|
200
|
+
= _no_rule, *,
|
|
201
|
+
prefill: Callable[[FormResult, str],
|
|
202
|
+
list[tuple[str, PrefillValueType]]]
|
|
203
|
+
= _no_prefill,
|
|
204
|
+
seed: Optional[FormResult] = None) -> FormResult:
|
|
194
205
|
"""Ask a whole form and return its validated, typed answers.
|
|
195
206
|
|
|
196
207
|
The rule disables the fields that the current answers make irrelevant
|
|
197
|
-
and reports any cross-field problem.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
and reports any cross-field problem. The prefill callback offers a
|
|
209
|
+
derived value to another field after each change, ignored on submit so
|
|
210
|
+
the caller must still apply the same default itself. A bridge that
|
|
211
|
+
validates on submit returns only valid answers; a plain console bridge
|
|
212
|
+
may return an invalid form, which is re-asked with the blocking message
|
|
213
|
+
shown. When a ``seed`` result is given each field starts pre-filled with
|
|
214
|
+
its seed value, so a re-asked or default-driven form opens on the earlier
|
|
202
215
|
answers; sensitive fields keep no default and are always asked afresh.
|
|
203
216
|
"""
|
|
204
217
|
fields = _seeded_fields(fields, seed)
|
|
@@ -207,7 +220,7 @@ def run_form(bridge: WizardUiBridge, question: str,
|
|
|
207
220
|
def validate(answers: Sequence[AnswerField],
|
|
208
221
|
changed: int) -> PartFormValidationResult:
|
|
209
222
|
"""Validate the current answers for the bridge's live feedback."""
|
|
210
|
-
return _validate(fields, rule, list(answers), changed)
|
|
223
|
+
return _validate(fields, rule, list(answers), changed, prefill)
|
|
211
224
|
reason: Optional[str] = None
|
|
212
225
|
while True:
|
|
213
226
|
answers = list(bridge.ask_form(question, asks, re_ask_reason=reason,
|
|
@@ -227,28 +240,46 @@ def _values_of(fields: Sequence[FormField],
|
|
|
227
240
|
|
|
228
241
|
def _validate(fields: Sequence[FormField],
|
|
229
242
|
rule: Callable[[FormResult], tuple[Optional[str], set[str]]],
|
|
230
|
-
answers: list[AnswerField],
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
answers: list[AnswerField], changed: int,
|
|
244
|
+
prefill: Callable[[FormResult, str],
|
|
245
|
+
list[tuple[str, PrefillValueType]]]
|
|
246
|
+
= _no_prefill) -> PartFormValidationResult:
|
|
247
|
+
"""Run the rule, the field checks and the prefill into one result."""
|
|
248
|
+
values = FormResult(_values_of(fields, answers))
|
|
249
|
+
message, disabled_keys = rule(values)
|
|
234
250
|
errors = _field_errors(fields, answers, disabled_keys)
|
|
235
251
|
disabled = tuple(index for index, field in enumerate(fields)
|
|
236
252
|
if field.key in disabled_keys)
|
|
237
253
|
valid = message is None and not errors
|
|
254
|
+
prefills = _prefills(fields, values, changed, prefill)
|
|
238
255
|
return PartFormValidationResult(valid, _message(errors, message, changed),
|
|
239
|
-
disabled)
|
|
256
|
+
disabled, prefills)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _prefills(fields: Sequence[FormField], values: FormResult, changed: int,
|
|
260
|
+
prefill: Callable[[FormResult, str],
|
|
261
|
+
list[tuple[str, PrefillValueType]]]
|
|
262
|
+
) -> PrefillValues:
|
|
263
|
+
"""Translate the prefill callback's key requests into row indexes."""
|
|
264
|
+
index_of = {field.key: index for index, field in enumerate(fields)}
|
|
265
|
+
return tuple((index_of[key], value)
|
|
266
|
+
for key, value in prefill(values, fields[changed].key))
|
|
240
267
|
|
|
241
268
|
|
|
242
269
|
def _field_errors(fields: Sequence[FormField], answers: list[AnswerField],
|
|
243
270
|
disabled_keys: set[str]) -> dict[int, str]:
|
|
244
|
-
"""Return the own error of
|
|
271
|
+
"""Return the labelled own error of each enabled field, by row index.
|
|
272
|
+
|
|
273
|
+
Each message is prefixed with the field's label so a form that shows
|
|
274
|
+
every field at once makes clear which field the message refers to.
|
|
275
|
+
"""
|
|
245
276
|
errors: dict[int, str] = {}
|
|
246
277
|
for index, field in enumerate(fields):
|
|
247
278
|
if field.key in disabled_keys:
|
|
248
279
|
continue
|
|
249
280
|
error = field.error(answers[index])
|
|
250
281
|
if error is not None:
|
|
251
|
-
errors[index] = error
|
|
282
|
+
errors[index] = f'{field.ask.short_question}: {error}'
|
|
252
283
|
return errors
|
|
253
284
|
|
|
254
285
|
|
|
@@ -278,29 +309,6 @@ def _answer_path(answer: AnswerField) -> Optional[Path]:
|
|
|
278
309
|
return value
|
|
279
310
|
|
|
280
311
|
|
|
281
|
-
def _parse_date(answer: str) -> Optional[date]:
|
|
282
|
-
"""Return the ISO date in ``answer``, or None when it is invalid."""
|
|
283
|
-
try:
|
|
284
|
-
return date.fromisoformat(answer)
|
|
285
|
-
except ValueError:
|
|
286
|
-
return None
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def _num_text(value: float) -> str:
|
|
290
|
-
"""Return a compact decimal text for a default numeric value."""
|
|
291
|
-
return f'{value:g}'
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def _parse_float(text: Optional[str]) -> Optional[float]:
|
|
295
|
-
"""Return the float in ``text``, or None when it is not a number."""
|
|
296
|
-
if not text:
|
|
297
|
-
return None
|
|
298
|
-
try:
|
|
299
|
-
return float(text)
|
|
300
|
-
except ValueError:
|
|
301
|
-
return None
|
|
302
|
-
|
|
303
|
-
|
|
304
312
|
def name_error(name: Optional[str], used: set[str]) -> Optional[str]:
|
|
305
313
|
"""Return why a preset-style name is invalid, or None when it is fine."""
|
|
306
314
|
if name is None or PRESET_NAME_RE.match(name) is None:
|
|
@@ -422,41 +430,32 @@ def int_field(key: str, question: str, *, default: int,
|
|
|
422
430
|
def number_field(key: str, question: str, *, default: float,
|
|
423
431
|
minimum: Optional[float] = None,
|
|
424
432
|
maximum: Optional[float] = None) -> FormField:
|
|
425
|
-
"""Return a decimal field pre-filled with its default.
|
|
426
|
-
ask = AskTextField(question, None, nullable=True,
|
|
427
|
-
default=_num_text(default))
|
|
433
|
+
"""Return a decimal field pre-filled with its default.
|
|
428
434
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
parsed = _parse_float(_answer_text(answer))
|
|
436
|
-
return default if parsed is None else parsed
|
|
437
|
-
return FormField(key, ask, error, value)
|
|
435
|
+
The field itself checks that the answer is a number within the
|
|
436
|
+
inclusive bounds and returns the default for a blank answer.
|
|
437
|
+
"""
|
|
438
|
+
ask = AskFloatField(question, None, default=default, min_value=minimum,
|
|
439
|
+
max_value=maximum)
|
|
440
|
+
return FormField(key, ask, _no_error, _float_value)
|
|
438
441
|
|
|
439
442
|
|
|
440
443
|
def date_field(key: str, question: str, *,
|
|
441
444
|
help_text: Optional[str] = None) -> FormField:
|
|
442
|
-
"""Return a required
|
|
443
|
-
ask =
|
|
445
|
+
"""Return a required date field, shown with a calendar picker."""
|
|
446
|
+
ask = AskDateField(question, help_text, nullable=True)
|
|
444
447
|
|
|
445
448
|
def error(answer: AnswerField) -> Optional[str]:
|
|
446
|
-
"""Reject a missing
|
|
447
|
-
return
|
|
448
|
-
return FormField(key, ask, error,
|
|
449
|
+
"""Reject a missing required date."""
|
|
450
|
+
return None if _answer_date(answer) is not None else _DATE_ERROR
|
|
451
|
+
return FormField(key, ask, error, _answer_date)
|
|
449
452
|
|
|
450
453
|
|
|
451
454
|
def opt_date_field(key: str, question: str, *,
|
|
452
455
|
help_text: Optional[str] = None) -> FormField:
|
|
453
|
-
"""Return an optional
|
|
454
|
-
ask =
|
|
455
|
-
|
|
456
|
-
def error(answer: AnswerField) -> Optional[str]:
|
|
457
|
-
"""Reject a malformed date but accept a blank one."""
|
|
458
|
-
return _date_error(_answer_text(answer), required=False)
|
|
459
|
-
return FormField(key, ask, error, _date_value)
|
|
456
|
+
"""Return an optional date field that may be left blank."""
|
|
457
|
+
ask = AskDateField(question, help_text, nullable=True)
|
|
458
|
+
return FormField(key, ask, _no_error, _answer_date)
|
|
460
459
|
|
|
461
460
|
|
|
462
461
|
def _no_error(_answer: AnswerField) -> Optional[str]:
|
|
@@ -472,35 +471,26 @@ def _flag_value(answer: AnswerField) -> object:
|
|
|
472
471
|
|
|
473
472
|
|
|
474
473
|
def _int_value(answer: AnswerField) -> object:
|
|
475
|
-
"""Return the integer an answer holds.
|
|
474
|
+
"""Return the integer an answer holds, or None when not yet valid.
|
|
475
|
+
|
|
476
|
+
A graphical or textual bridge runs the validator after every change,
|
|
477
|
+
so a field may still be empty or out of range; the strict FormResult
|
|
478
|
+
getters check the type only when the accepted answer is read.
|
|
479
|
+
"""
|
|
476
480
|
value = answer.value
|
|
477
|
-
assert isinstance(value, int)
|
|
481
|
+
assert value is None or isinstance(value, int)
|
|
478
482
|
return value
|
|
479
483
|
|
|
480
484
|
|
|
481
|
-
def
|
|
482
|
-
"""Return the
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def _number_error(text: Optional[str], minimum: Optional[float],
|
|
488
|
-
maximum: Optional[float]) -> Optional[str]:
|
|
489
|
-
"""Return why a decimal answer is invalid, or None when it is fine."""
|
|
490
|
-
if not text:
|
|
491
|
-
return None
|
|
492
|
-
value = _parse_float(text)
|
|
493
|
-
if value is None:
|
|
494
|
-
return _NUMBER_ERROR
|
|
495
|
-
if minimum is not None and value < minimum:
|
|
496
|
-
return f'Please enter a value of at least {minimum:g}.'
|
|
497
|
-
if maximum is not None and value > maximum:
|
|
498
|
-
return f'Please enter a value of at most {maximum:g}.'
|
|
499
|
-
return None
|
|
485
|
+
def _float_value(answer: AnswerField) -> object:
|
|
486
|
+
"""Return the decimal an answer holds, or None when not yet valid."""
|
|
487
|
+
value = answer.value
|
|
488
|
+
assert value is None or isinstance(value, float)
|
|
489
|
+
return value
|
|
500
490
|
|
|
501
491
|
|
|
502
|
-
def
|
|
503
|
-
"""Return
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return
|
|
492
|
+
def _answer_date(answer: AnswerField) -> Optional[date]:
|
|
493
|
+
"""Return the date an answer holds, or None when it is blank."""
|
|
494
|
+
value = answer.value
|
|
495
|
+
assert value is None or isinstance(value, date)
|
|
496
|
+
return value
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
"""Reusable field-input helpers for the wizards.
|
|
3
3
|
|
|
4
4
|
The ``_read_*`` and ``_parse_*`` helpers ask and validate the single-value
|
|
5
|
-
and whole-table fields, such as a preset name, the
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
and whole-table fields, such as a preset name, the column rename maps and
|
|
6
|
+
the backlog item levels, and pre-fill each from an optional seed value so a
|
|
7
|
+
re-asked or default-driven question opens on the earlier answer. The
|
|
8
|
+
re-runnable :class:`_Navigator` that calls them lives in
|
|
9
|
+
:mod:`backlogops.wizard_navigator`; the one-screen form toolkit, which now
|
|
10
|
+
asks the company work hours and its exception periods, lives in
|
|
11
|
+
:mod:`backlogops.wizard_forms`. The small domain helper
|
|
11
12
|
:func:`_backlog_map_fields` is shared by the configuration and the preset
|
|
12
13
|
wizards.
|
|
13
14
|
|
|
@@ -27,12 +28,10 @@ from tableio_cfg_json import TableCell, TableColumn, TioJsonConfig, \
|
|
|
27
28
|
from backlogops.backlog import Status
|
|
28
29
|
from backlogops.jira_io_config import JiraAttrPath, JiraAttrType, \
|
|
29
30
|
JiraColumnMap, JiraIssueTypeMap
|
|
30
|
-
from backlogops.wizard_forms import name_error
|
|
31
|
+
from backlogops.wizard_forms import name_error
|
|
31
32
|
from backlogops.levels import DEFAULT_LEVELS, Level, Levels, levels_from_list
|
|
32
33
|
from backlogops.table_rows import BACKLOG_FIELDS, LEVEL_COLUMN, \
|
|
33
34
|
LEVEL_NAME_COLUMN
|
|
34
|
-
from backlogops.work_hours import DEFAULT_WORK_WEEK, ExceptionWorkHours, \
|
|
35
|
-
ScheduleWorkHours, WeekDay
|
|
36
35
|
|
|
37
36
|
|
|
38
37
|
def _read_text(ui: WizardUiBridge, question: str, default: Optional[str],
|
|
@@ -94,173 +93,6 @@ def _read_tableio(ui: WizardUiBridge, file_access: FileAccess,
|
|
|
94
93
|
default=default, backward=backward)
|
|
95
94
|
|
|
96
95
|
|
|
97
|
-
def _is_nonneg(text: Optional[str]) -> bool:
|
|
98
|
-
"""Return whether ``text`` parses as a number that is at least zero."""
|
|
99
|
-
if text is None:
|
|
100
|
-
return False
|
|
101
|
-
try:
|
|
102
|
-
return float(text) >= 0.0
|
|
103
|
-
except ValueError:
|
|
104
|
-
return False
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _sched_check(table: list[list[Optional[str]]],
|
|
108
|
-
position: tuple[int, int]) -> tuple[bool, str]:
|
|
109
|
-
"""Give early feedback that an edited work-hours cell is a number."""
|
|
110
|
-
row, col = position
|
|
111
|
-
if col != 1:
|
|
112
|
-
return (True, '')
|
|
113
|
-
ok = _is_nonneg(table[row][1])
|
|
114
|
-
return (ok, '' if ok else 'Enter work hours as a number that is >= 0.')
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _parse_schedule(days: Sequence[WeekDay],
|
|
118
|
-
table: Sequence[Sequence[Optional[str]]]
|
|
119
|
-
) -> Optional[ScheduleWorkHours]:
|
|
120
|
-
"""Return the weekly schedule from a table, or None when invalid."""
|
|
121
|
-
schedule: ScheduleWorkHours = {}
|
|
122
|
-
for day, row in zip(days, table):
|
|
123
|
-
hours = row[1]
|
|
124
|
-
if not _is_nonneg(hours):
|
|
125
|
-
return None
|
|
126
|
-
assert hours is not None
|
|
127
|
-
schedule[day] = float(hours)
|
|
128
|
-
return schedule
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _read_schedule(ui: WizardUiBridge, seed: Optional[ScheduleWorkHours] = None
|
|
132
|
-
) -> ScheduleWorkHours:
|
|
133
|
-
"""Ask the weekly work-hours schedule as one table question.
|
|
134
|
-
|
|
135
|
-
The hours are pre-filled from the seed schedule, or from the default
|
|
136
|
-
work week when no seed is given.
|
|
137
|
-
"""
|
|
138
|
-
days = list(WeekDay)
|
|
139
|
-
source = seed if seed is not None else DEFAULT_WORK_WEEK
|
|
140
|
-
columns = [TableColumn(header='Day', read_only=True),
|
|
141
|
-
TableColumn(header='Work hours')]
|
|
142
|
-
cells = [[TableCell(value=day.name.capitalize()),
|
|
143
|
-
TableCell(value=_num_text(source.get(day,
|
|
144
|
-
DEFAULT_WORK_WEEK[day])))]
|
|
145
|
-
for day in days]
|
|
146
|
-
reason: Optional[str] = None
|
|
147
|
-
while True:
|
|
148
|
-
table = ui.ask_table(columns, cells,
|
|
149
|
-
'Company work hours per week day:',
|
|
150
|
-
re_ask_reason=reason, partial_check=_sched_check)
|
|
151
|
-
schedule = _parse_schedule(days, table)
|
|
152
|
-
if schedule is not None:
|
|
153
|
-
return schedule
|
|
154
|
-
reason = 'Enter work hours as a number that is at least zero.'
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
_MAX_EXCEPTIONS = 60
|
|
158
|
-
"""Upper bound on the number of exception-period rows the wizard accepts."""
|
|
159
|
-
|
|
160
|
-
_EXC_HINT = ("Enter each period's dates as YYYY-MM-DD, its work hours per "
|
|
161
|
-
"day as a number (blank means zero), and 'yes' or 'no' for "
|
|
162
|
-
'whether it adds work on days that are normally free.')
|
|
163
|
-
"""Help text shown above the work-hour exception table."""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _parse_yes_no(text: Optional[str]) -> Optional[bool]:
|
|
167
|
-
"""Return the boolean a yes/no cell holds, or None when invalid.
|
|
168
|
-
|
|
169
|
-
A blank cell counts as 'no', so an added but unset flag means the
|
|
170
|
-
period adds no work on days that are normally free.
|
|
171
|
-
"""
|
|
172
|
-
token = '' if text is None else text.strip().lower()
|
|
173
|
-
if token in ('', 'no', 'n', 'false', '0'):
|
|
174
|
-
return False
|
|
175
|
-
if token in ('yes', 'y', 'true', '1'):
|
|
176
|
-
return True
|
|
177
|
-
return None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _exc_check(table: list[list[Optional[str]]],
|
|
181
|
-
position: tuple[int, int]) -> tuple[bool, str]:
|
|
182
|
-
"""Give early feedback on one work-hour exception cell."""
|
|
183
|
-
row, col = position
|
|
184
|
-
text = table[row][col] or ''
|
|
185
|
-
if col in (0, 1):
|
|
186
|
-
ok = text == '' or _parse_date(text) is not None
|
|
187
|
-
return (ok, '' if ok else 'Enter the date as YYYY-MM-DD.')
|
|
188
|
-
if col == 2:
|
|
189
|
-
ok = text == '' or _is_nonneg(text)
|
|
190
|
-
return (ok, '' if ok else 'Enter work hours as a number >= 0.')
|
|
191
|
-
ok = _parse_yes_no(text) is not None
|
|
192
|
-
return (ok, '' if ok else "Enter 'yes' or 'no'.")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _parse_exceptions(table: list[list[Optional[str]]]
|
|
196
|
-
) -> Optional[list[ExceptionWorkHours]]:
|
|
197
|
-
"""Return the exception periods from a table, or None when invalid.
|
|
198
|
-
|
|
199
|
-
A row with both dates blank is treated as an unused row and skipped.
|
|
200
|
-
Any other row needs a valid start and end date with the end on or
|
|
201
|
-
after the start, non-negative work hours (blank means zero) and a
|
|
202
|
-
yes/no adds-free-day-work flag (blank means no).
|
|
203
|
-
"""
|
|
204
|
-
result: list[ExceptionWorkHours] = []
|
|
205
|
-
for row in table:
|
|
206
|
-
start_text, end_text = row[0], row[1]
|
|
207
|
-
hours_text, adds_text = row[2], row[3]
|
|
208
|
-
if not start_text and not end_text:
|
|
209
|
-
continue
|
|
210
|
-
start = _parse_date(start_text) if start_text else None
|
|
211
|
-
end = _parse_date(end_text) if end_text else None
|
|
212
|
-
adds = _parse_yes_no(adds_text)
|
|
213
|
-
if start is None or end is None or end < start or adds is None:
|
|
214
|
-
return None
|
|
215
|
-
if hours_text and not _is_nonneg(hours_text):
|
|
216
|
-
return None
|
|
217
|
-
hours = float(hours_text) if hours_text else 0.0
|
|
218
|
-
result.append(ExceptionWorkHours(start_date=start, end_date=end,
|
|
219
|
-
hours_per_day=hours,
|
|
220
|
-
new_work_days=adds))
|
|
221
|
-
return result
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def _exc_cells(exceptions: Sequence[ExceptionWorkHours]
|
|
225
|
-
) -> list[list[TableCell]]:
|
|
226
|
-
"""Return table rows filled from the given exception periods."""
|
|
227
|
-
return [[TableCell(value=exc.start_date.isoformat()),
|
|
228
|
-
TableCell(value=exc.end_date.isoformat()),
|
|
229
|
-
TableCell(value=_num_text(exc.hours_per_day)),
|
|
230
|
-
TableCell(value='yes' if exc.new_work_days else 'no')]
|
|
231
|
-
for exc in exceptions]
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _read_exceptions(ui: WizardUiBridge, question: str,
|
|
235
|
-
seed: Optional[Sequence[ExceptionWorkHours]] = None
|
|
236
|
-
) -> list[ExceptionWorkHours]:
|
|
237
|
-
"""Ask the work-hour exception periods as one variable-row table.
|
|
238
|
-
|
|
239
|
-
The table starts from the seed periods, or empty when none are given,
|
|
240
|
-
and may be left empty for no exceptions. Individual cells are checked
|
|
241
|
-
as they are entered; an inconsistent whole table, such as a period
|
|
242
|
-
whose end precedes its start, is re-asked with the user's own rows
|
|
243
|
-
kept.
|
|
244
|
-
"""
|
|
245
|
-
columns = [TableColumn(header='Start date'),
|
|
246
|
-
TableColumn(header='End date'),
|
|
247
|
-
TableColumn(header='Work hours per day'),
|
|
248
|
-
TableColumn(header='Adds work on free days?')]
|
|
249
|
-
cells = _exc_cells(seed) if seed else []
|
|
250
|
-
instruction = f'{question} {_EXC_HINT}'
|
|
251
|
-
reason: Optional[str] = None
|
|
252
|
-
while True:
|
|
253
|
-
table = ui.ask_table(columns, cells, instruction, re_ask_reason=reason,
|
|
254
|
-
partial_check=_exc_check, min_rows=0,
|
|
255
|
-
max_rows=_MAX_EXCEPTIONS)
|
|
256
|
-
parsed = _parse_exceptions(table)
|
|
257
|
-
if parsed is not None:
|
|
258
|
-
return parsed
|
|
259
|
-
reason = ('Give each period a start and end date (end not before '
|
|
260
|
-
'start), non-negative hours, and yes or no.')
|
|
261
|
-
cells = _cells_from_table(table)
|
|
262
|
-
|
|
263
|
-
|
|
264
96
|
_MAX_EXTRA_COLUMNS = 30
|
|
265
97
|
"""How many extra-field rows the user may add to a backlog rename table."""
|
|
266
98
|
|
|
@@ -21,17 +21,16 @@ from dataclasses import dataclass, field
|
|
|
21
21
|
from functools import partial
|
|
22
22
|
from typing import Callable, Optional, Sequence, TextIO, TypeVar
|
|
23
23
|
from tableio import FileAccess
|
|
24
|
-
from tableio_cfg_json import TioJsonConfig, WizardBack,
|
|
25
|
-
WizardUiBridge
|
|
24
|
+
from tableio_cfg_json import PrefillValueType, TioJsonConfig, WizardBack, \
|
|
25
|
+
WizardCancelLevel, WizardUiBridge
|
|
26
26
|
from backlogops.backlog import Status
|
|
27
27
|
from backlogops.jira_io_config import JiraColumnMap, JiraIssueTypeMap
|
|
28
28
|
from backlogops.levels import Level, LevelDisplay, Levels
|
|
29
|
-
from backlogops.
|
|
30
|
-
|
|
31
|
-
from backlogops.wizard_helpers import _RenameKind,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
_read_tableio, _read_text
|
|
29
|
+
from backlogops.wizard_forms import FormField, FormResult, run_form, \
|
|
30
|
+
_no_prefill, _no_rule
|
|
31
|
+
from backlogops.wizard_helpers import _RenameKind, _read_int, \
|
|
32
|
+
_read_issue_type_map, _read_jira_map, _read_levels, _read_preset_name, \
|
|
33
|
+
_read_renames, _read_status_map, _read_tableio, _read_text
|
|
35
34
|
|
|
36
35
|
_T = TypeVar('_T')
|
|
37
36
|
_D = TypeVar('_D')
|
|
@@ -234,11 +233,15 @@ class _Navigator:
|
|
|
234
233
|
def ask_form(self, question: str, fields: Sequence[FormField],
|
|
235
234
|
rule: Callable[[FormResult], tuple[Optional[str], set[str]]]
|
|
236
235
|
= _no_rule, *,
|
|
236
|
+
prefill: Callable[[FormResult, str],
|
|
237
|
+
list[tuple[str, PrefillValueType]]]
|
|
238
|
+
= _no_prefill,
|
|
237
239
|
seed: Optional[FormResult] = None) -> FormResult:
|
|
238
240
|
"""Ask a whole form on one screen, pre-filled from a seed result."""
|
|
239
241
|
def ask(sd: object, _bw: bool) -> object:
|
|
240
242
|
pre = sd if isinstance(sd, FormResult) else None
|
|
241
|
-
return run_form(self._ui, question, fields, rule,
|
|
243
|
+
return run_form(self._ui, question, fields, rule, prefill=prefill,
|
|
244
|
+
seed=pre)
|
|
242
245
|
result = self._ask(ask, seed)
|
|
243
246
|
assert isinstance(result, FormResult)
|
|
244
247
|
return result
|
|
@@ -267,27 +270,6 @@ class _Navigator:
|
|
|
267
270
|
assert isinstance(result, TioJsonConfig)
|
|
268
271
|
return result
|
|
269
272
|
|
|
270
|
-
def ask_schedule(self, *, seed: Optional[ScheduleWorkHours] = None
|
|
271
|
-
) -> ScheduleWorkHours:
|
|
272
|
-
"""Ask the weekly work-hours schedule as one table question."""
|
|
273
|
-
def ask(sd: object, _bw: bool) -> object:
|
|
274
|
-
pre = sd if isinstance(sd, dict) else None
|
|
275
|
-
return _read_schedule(self._ui, pre)
|
|
276
|
-
result = self._ask(ask, seed)
|
|
277
|
-
assert isinstance(result, dict)
|
|
278
|
-
return result
|
|
279
|
-
|
|
280
|
-
def ask_exceptions(self, question: str, *,
|
|
281
|
-
seed: Optional[list[ExceptionWorkHours]] = None
|
|
282
|
-
) -> list[ExceptionWorkHours]:
|
|
283
|
-
"""Ask the work-hour exception periods as one variable-row table."""
|
|
284
|
-
def ask(sd: object, _bw: bool) -> object:
|
|
285
|
-
pre = sd if isinstance(sd, list) else None
|
|
286
|
-
return _read_exceptions(self._ui, question, pre)
|
|
287
|
-
result = self._ask(ask, seed)
|
|
288
|
-
assert isinstance(result, list)
|
|
289
|
-
return result
|
|
290
|
-
|
|
291
273
|
def ask_levels(self, *, seed: Optional[list[Level]] = None) -> list[Level]:
|
|
292
274
|
"""Ask the backlog item levels as one variable-row table."""
|
|
293
275
|
def ask(sd: object, _bw: bool) -> object:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|