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.
Files changed (66) hide show
  1. {backlogops-0.4 → backlogops-0.5}/PKG-INFO +7 -10
  2. {backlogops-0.4 → backlogops-0.5}/README_pypi.md +5 -8
  3. {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/PKG-INFO +7 -10
  4. {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/requires.txt +1 -1
  5. {backlogops-0.4 → backlogops-0.5}/setup.py +2 -2
  6. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_ops_wizard.py +140 -18
  7. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_wizard.py +39 -3
  8. {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_forms.py +107 -117
  9. {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_helpers.py +8 -176
  10. {backlogops-0.4 → backlogops-0.5}/src/backlogops/wizard_navigator.py +12 -30
  11. {backlogops-0.4 → backlogops-0.5}/LICENSE.txt +0 -0
  12. {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/SOURCES.txt +0 -0
  13. {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/dependency_links.txt +0 -0
  14. {backlogops-0.4 → backlogops-0.5}/backlogops.egg-info/top_level.txt +0 -0
  15. {backlogops-0.4 → backlogops-0.5}/pyproject.toml +0 -0
  16. {backlogops-0.4 → backlogops-0.5}/setup.cfg +0 -0
  17. {backlogops-0.4 → backlogops-0.5}/src/backlogops/__init__.py +0 -0
  18. {backlogops-0.4 → backlogops-0.5}/src/backlogops/apply_format_rules.py +0 -0
  19. {backlogops-0.4 → backlogops-0.5}/src/backlogops/available_teams.py +0 -0
  20. {backlogops-0.4 → backlogops-0.5}/src/backlogops/available_teams_config.py +0 -0
  21. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog.py +0 -0
  22. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_helpers.py +0 -0
  23. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_in_release_order.py +0 -0
  24. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_ops_config.py +0 -0
  25. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_releases.py +0 -0
  26. {backlogops-0.4 → backlogops-0.5}/src/backlogops/backlog_releases_io.py +0 -0
  27. {backlogops-0.4 → backlogops-0.5}/src/backlogops/blo_version_reporter.py +0 -0
  28. {backlogops-0.4 → backlogops-0.5}/src/backlogops/config_file_io.py +0 -0
  29. {backlogops-0.4 → backlogops-0.5}/src/backlogops/date_ranges.py +0 -0
  30. {backlogops-0.4 → backlogops-0.5}/src/backlogops/demo_backlog.py +0 -0
  31. {backlogops-0.4 → backlogops-0.5}/src/backlogops/estimate_ready_date.py +0 -0
  32. {backlogops-0.4 → backlogops-0.5}/src/backlogops/format_rules.py +0 -0
  33. {backlogops-0.4 → backlogops-0.5}/src/backlogops/io_config.py +0 -0
  34. {backlogops-0.4 → backlogops-0.5}/src/backlogops/io_preset_wizard.py +0 -0
  35. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_connect.py +0 -0
  36. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_io_config.py +0 -0
  37. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_order_releases.py +0 -0
  38. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_backlog.py +0 -0
  39. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_by_keys.py +0 -0
  40. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rank_move_keys.py +0 -0
  41. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_read.py +0 -0
  42. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_rename_releases.py +0 -0
  43. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_search.py +0 -0
  44. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_token.py +0 -0
  45. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_update_backlog.py +0 -0
  46. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_update_releases.py +0 -0
  47. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write.py +0 -0
  48. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_fields.py +0 -0
  49. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_format.py +0 -0
  50. {backlogops-0.4 → backlogops-0.5}/src/backlogops/jira_write_releases.py +0 -0
  51. {backlogops-0.4 → backlogops-0.5}/src/backlogops/key_list_io.py +0 -0
  52. {backlogops-0.4 → backlogops-0.5}/src/backlogops/levels.py +0 -0
  53. {backlogops-0.4 → backlogops-0.5}/src/backlogops/move_keys_first.py +0 -0
  54. {backlogops-0.4 → backlogops-0.5}/src/backlogops/name_list_io.py +0 -0
  55. {backlogops-0.4 → backlogops-0.5}/src/backlogops/no_text_io.py +0 -0
  56. {backlogops-0.4 → backlogops-0.5}/src/backlogops/order_by_dependencies.py +0 -0
  57. {backlogops-0.4 → backlogops-0.5}/src/backlogops/person.py +0 -0
  58. {backlogops-0.4 → backlogops-0.5}/src/backlogops/py.typed +0 -0
  59. {backlogops-0.4 → backlogops-0.5}/src/backlogops/release_backlog_updates.py +0 -0
  60. {backlogops-0.4 → backlogops-0.5}/src/backlogops/release_change_io.py +0 -0
  61. {backlogops-0.4 → backlogops-0.5}/src/backlogops/releases.py +0 -0
  62. {backlogops-0.4 → backlogops-0.5}/src/backlogops/rename_list_io.py +0 -0
  63. {backlogops-0.4 → backlogops-0.5}/src/backlogops/table_create.py +0 -0
  64. {backlogops-0.4 → backlogops-0.5}/src/backlogops/table_rows.py +0 -0
  65. {backlogops-0.4 → backlogops-0.5}/src/backlogops/team.py +0 -0
  66. {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.4
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.9
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
- backlogops is in beta. The algorithms intended for its initial scope are in
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. Early
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: 2638 passed, 7 deselected in 51s
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.4
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
- backlogops is in beta. The algorithms intended for its initial scope are in
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. Early
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: 2638 passed, 7 deselected in 51s
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.4
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.4
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.9
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
- backlogops is in beta. The algorithms intended for its initial scope are in
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. Early
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: 2638 passed, 7 deselected in 51s
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.4
320
+ - Built version(s): 0.5
324
321
  - Build and test using Python 3.14.6
@@ -2,5 +2,5 @@ config-as-json>=1.4
2
2
  cryptography>=49.0.0
3
3
  jira[cli,opt]>=3.10.5
4
4
  tableio>=1.1
5
- tableio-cfg-json>=0.9
5
+ tableio-cfg-json>=1.0
6
6
  versionreporter>=0.4
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='backlogops',
8
- version='0.4',
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.9',
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, ExceptionWorkHours
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 schedule is one table and the holiday, closure and
228
- special-work periods are a second table, so the whole company work
229
- schedule is two screens.
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
- work_hours = nav.ask_schedule(seed=company.work_hours if company else None)
233
- exceptions = nav.ask_exceptions(
234
- 'Company holiday, closure and special-work periods.',
235
- seed=company.exceptions if company else None)
236
- return CompanyWorkHours(work_hours=work_hours, exceptions=exceptions)
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 ExceptionWorkHours(start_date=values.day('start'),
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 default project in rank '
368
- f'order, i.e. {_FILTER_EG}.')
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
- Dates and decimals have no native field type, so they are asked as text
19
- fields validated here. :func:`_parse_date` and :func:`_num_text` are shared
20
- with the table-based wizard helpers.
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, AskField, \
31
- AskIntField, AskPathField, AskTextField, AskYesNoField, \
32
- PartFormValidationResult, PathAskOptions, WizardPathKind, \
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
- _NUMBER_ERROR = 'Please enter a number.'
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
- def _as_text(value: object) -> Optional[str]:
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, seed: Optional[FormResult] = None) -> FormResult:
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. A bridge that validates on submit
198
- returns only valid answers; a plain console bridge may return an
199
- invalid form, which is re-asked with the blocking message shown. When a
200
- ``seed`` result is given each field starts pre-filled with its seed
201
- value, so a re-asked or default-driven form opens on the earlier
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
- changed: int) -> PartFormValidationResult:
232
- """Run the rule and the field checks into one validation result."""
233
- message, disabled_keys = rule(FormResult(_values_of(fields, answers)))
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 every enabled field, keyed by row index."""
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
- def error(answer: AnswerField) -> Optional[str]:
430
- """Reject a non-numeric or out-of-range decimal answer."""
431
- return _number_error(_answer_text(answer), minimum, maximum)
432
-
433
- def value(answer: AnswerField) -> object:
434
- """Return the entered decimal, or the default for a blank answer."""
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 ISO date field, asked as validated text."""
443
- ask = AskTextField(question + _DATE_HINT, help_text, nullable=True)
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 or malformed required date."""
447
- return _date_error(_answer_text(answer), required=True)
448
- return FormField(key, ask, error, _date_value)
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 ISO date field that may be left blank."""
454
- ask = AskTextField(question + _DATE_HINT_OPT, help_text, nullable=True)
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 _date_value(answer: AnswerField) -> object:
482
- """Return the date an answer holds, or None when it is blank."""
483
- text = _answer_text(answer)
484
- return _parse_date(text) if text else None
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 _date_error(text: Optional[str], required: bool) -> Optional[str]:
503
- """Return why a date answer is invalid, or None when it is fine."""
504
- if not text:
505
- return _DATE_ERROR if required else None
506
- return None if _parse_date(text) else _DATE_ERROR
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 weekly work-hours
6
- schedule, the column rename maps and the backlog item levels, and pre-fill
7
- each from an optional seed value so a re-asked or default-driven question
8
- opens on the earlier answer. The re-runnable :class:`_Navigator` that calls
9
- them lives in :mod:`backlogops.wizard_navigator`; the one-screen form
10
- toolkit lives in :mod:`backlogops.wizard_forms`. The small domain helper
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, _num_text, _parse_date
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, WizardCancelLevel, \
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.work_hours import ExceptionWorkHours, ScheduleWorkHours
30
- from backlogops.wizard_forms import FormField, FormResult, run_form, _no_rule
31
- from backlogops.wizard_helpers import _RenameKind, _read_exceptions, \
32
- _read_int, _read_issue_type_map, _read_jira_map, _read_levels, \
33
- _read_preset_name, _read_renames, _read_schedule, _read_status_map, \
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, pre)
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