backlogops 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backlogops/__init__.py +87 -0
- backlogops/apply_format_rules.py +95 -0
- backlogops/available_teams.py +205 -0
- backlogops/available_teams_config.py +538 -0
- backlogops/available_teams_wizard.py +448 -0
- backlogops/backlog.py +465 -0
- backlogops/backlog_helpers.py +658 -0
- backlogops/backlog_releases.py +299 -0
- backlogops/backlog_releases_io.py +200 -0
- backlogops/console_yes_no_bridge.py +45 -0
- backlogops/date_ranges.py +59 -0
- backlogops/demo_backlog.py +129 -0
- backlogops/estimate_ready_date.py +379 -0
- backlogops/format_rules.py +73 -0
- backlogops/io_config.py +277 -0
- backlogops/key_list_io.py +227 -0
- backlogops/levels.py +165 -0
- backlogops/move_keys_first.py +166 -0
- backlogops/no_text_io.py +100 -0
- backlogops/order_by_dependencies.py +381 -0
- backlogops/person.py +25 -0
- backlogops/py.typed +0 -0
- backlogops/release_backlog_updates.py +239 -0
- backlogops/release_change_io.py +134 -0
- backlogops/releases.py +170 -0
- backlogops/table_create.py +47 -0
- backlogops/table_rows.py +125 -0
- backlogops/team.py +190 -0
- backlogops/work_hours.py +155 -0
- backlogops-0.1.dist-info/METADATA +238 -0
- backlogops-0.1.dist-info/RECORD +34 -0
- backlogops-0.1.dist-info/WHEEL +5 -0
- backlogops-0.1.dist-info/licenses/LICENSE.txt +22 -0
- backlogops-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Backlog and and its related releases."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from typing import Optional, TextIO, Sequence
|
|
10
|
+
import sys
|
|
11
|
+
from backlogops.backlog import Backlog, check_backlog_consistency
|
|
12
|
+
from backlogops.backlog_helpers import report_unknown_reference
|
|
13
|
+
from backlogops.releases import Release, Releases, check_releases
|
|
14
|
+
from backlogops.move_keys_first import move_keys_first
|
|
15
|
+
from backlogops.order_by_dependencies import order_by_dependencies, \
|
|
16
|
+
DependencyMode
|
|
17
|
+
from backlogops.estimate_ready_date import estimate_ready_date, \
|
|
18
|
+
set_plan_from_estimate
|
|
19
|
+
from backlogops.available_teams import AvailableTeams
|
|
20
|
+
from backlogops.release_backlog_updates import estimate_release_dates, \
|
|
21
|
+
release_plan_on_estimate, adjust_release_content, ReleaseChanges, \
|
|
22
|
+
ReleaseDateChanges
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class BacklogReleases:
|
|
27
|
+
"""A backlog and its related releases.
|
|
28
|
+
|
|
29
|
+
The releases list describes the releases that the backlog items are
|
|
30
|
+
delivered in. A backlog item refers to its release by name through
|
|
31
|
+
its ``release`` field. The releases list may hold releases that no
|
|
32
|
+
backlog item refers to yet, but every release named by a backlog
|
|
33
|
+
item is expected to be present in the releases list.
|
|
34
|
+
|
|
35
|
+
Fields:
|
|
36
|
+
backlog: The backlog of items.
|
|
37
|
+
releases: The releases the backlog items are delivered in.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
backlog: Backlog
|
|
41
|
+
releases: Releases
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def add_to_releases(backlog: Backlog, releases: Releases) -> Releases:
|
|
45
|
+
"""Add all releases mentioned in the backlog to the releases list.
|
|
46
|
+
|
|
47
|
+
For each backlog item that names a release, a release with that
|
|
48
|
+
name is added to the releases list when no release of that name
|
|
49
|
+
is present yet. A release added this way has no planned or
|
|
50
|
+
estimated date, because a backlog item only carries the release
|
|
51
|
+
name. The order of the existing releases is kept and any new
|
|
52
|
+
releases are appended in the order they are first met in the
|
|
53
|
+
backlog.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
backlog: The backlog to take the release names from.
|
|
57
|
+
releases: The releases to add the missing releases to.
|
|
58
|
+
The argument is not modified.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The releases list with the added releases. If all releases
|
|
62
|
+
named by the backlog are already present, the argument
|
|
63
|
+
object is returned unchanged. If any new releases are added,
|
|
64
|
+
a new list is returned.
|
|
65
|
+
"""
|
|
66
|
+
known = {release.name for release in releases}
|
|
67
|
+
added: Releases = []
|
|
68
|
+
for item in backlog:
|
|
69
|
+
if item.release is not None and item.release not in known:
|
|
70
|
+
known.add(item.release)
|
|
71
|
+
added.append(Release(name=item.release))
|
|
72
|
+
return releases + added if added else releases
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def check_in_releases(backlog: Backlog, releases: Releases,
|
|
76
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
77
|
+
"""Check that all releases in the backlog are in the releases list.
|
|
78
|
+
|
|
79
|
+
For each backlog item that names a release, the release is
|
|
80
|
+
checked to be present by name in the releases list.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
backlog: The backlog to check.
|
|
84
|
+
releases: The releases to check the backlog against.
|
|
85
|
+
stderr_file: The file to report errors to.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
KeyError: If a release named by the backlog is not present in
|
|
89
|
+
the releases list.
|
|
90
|
+
"""
|
|
91
|
+
known = {release.name for release in releases}
|
|
92
|
+
for item in backlog:
|
|
93
|
+
if item.release is not None and item.release not in known:
|
|
94
|
+
report_unknown_reference('release', item.key, item.release,
|
|
95
|
+
stderr_file)
|
|
96
|
+
|
|
97
|
+
def update_releases(self) -> None:
|
|
98
|
+
"""Update the releases list to include all releases in the backlog.
|
|
99
|
+
|
|
100
|
+
For each backlog item that names a release, the release is added
|
|
101
|
+
to the releases list when it is not already present, as
|
|
102
|
+
documented for :meth:`add_to_releases`.
|
|
103
|
+
"""
|
|
104
|
+
self.releases = self.add_to_releases(self.backlog, self.releases)
|
|
105
|
+
|
|
106
|
+
def check_release_xref(self, stderr_file: TextIO = sys.stderr) -> None:
|
|
107
|
+
"""Check that all releases in the backlog are in the releases list.
|
|
108
|
+
|
|
109
|
+
This is the cross reference check documented for
|
|
110
|
+
:meth:`check_in_releases`, applied to the member backlog and
|
|
111
|
+
releases.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
stderr_file: The file to report errors to.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
KeyError: If a release named by the backlog is not present in
|
|
118
|
+
the releases list.
|
|
119
|
+
"""
|
|
120
|
+
self.check_in_releases(self.backlog, self.releases, stderr_file)
|
|
121
|
+
|
|
122
|
+
def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
|
|
123
|
+
"""Check the internal consistency of the backlog and releases.
|
|
124
|
+
|
|
125
|
+
The backlog is checked for full consistency as documented for
|
|
126
|
+
:func:`check_backlog_consistency`, the releases are checked for
|
|
127
|
+
internal consistency and unique names as documented for
|
|
128
|
+
:func:`check_releases`, and every release named by the backlog
|
|
129
|
+
is checked to be present in the releases list.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
stderr_file: The file to report errors to.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
TypeError: If a field has the wrong type.
|
|
136
|
+
ValueError: If a field value violates a constraint, or if
|
|
137
|
+
release names are not unique.
|
|
138
|
+
KeyError: If a key reference is invalid, or if a release
|
|
139
|
+
named by the backlog is not in the releases list.
|
|
140
|
+
"""
|
|
141
|
+
check_backlog_consistency(self.backlog, stderr_file)
|
|
142
|
+
check_releases(self.releases, stderr_file)
|
|
143
|
+
self.check_in_releases(self.backlog, self.releases, stderr_file)
|
|
144
|
+
|
|
145
|
+
def move_keys_first(self, keys: Sequence[str],
|
|
146
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
147
|
+
"""Move the items named by ``keys`` to the front of the backlog.
|
|
148
|
+
|
|
149
|
+
The named items lead the backlog in the order of ``keys``. Each
|
|
150
|
+
named item is preceded by its descendants in post order: a child
|
|
151
|
+
comes right before its own parent, and that parent right before
|
|
152
|
+
the grandparent, up to the named item. Siblings keep their
|
|
153
|
+
original backlog order. A named descendant is placed by its own
|
|
154
|
+
key instead, so it may end up after its named parent. A descendant
|
|
155
|
+
is pulled to the front only when it appears after its named
|
|
156
|
+
ancestor in the backlog, so that no item is moved to a later
|
|
157
|
+
position because of an ancestor's key. The remaining items keep
|
|
158
|
+
their original order after the front block. The behavior is the
|
|
159
|
+
one documented for :func:`backlogops.move_keys_first`.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
keys: The keys to move to the front, in the wanted order. The
|
|
163
|
+
keys must be unique and must exist in the backlog.
|
|
164
|
+
stderr_file: The file to report errors to.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
KeyError: If a key is not found in the backlog.
|
|
168
|
+
ValueError: If a key is not unique.
|
|
169
|
+
"""
|
|
170
|
+
self.backlog = move_keys_first(self.backlog, keys, stderr_file)
|
|
171
|
+
|
|
172
|
+
def order_by_dependencies(self, *, later: bool = False,
|
|
173
|
+
mode: DependencyMode = DependencyMode.KEEP,
|
|
174
|
+
space_around: Optional[str | Sequence[str]]
|
|
175
|
+
= None,
|
|
176
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
177
|
+
"""Order the member backlog by dependencies.
|
|
178
|
+
|
|
179
|
+
The member backlog is replaced by a backlog ordered so that a
|
|
180
|
+
team can start the items in backlog order without starting an
|
|
181
|
+
item before the items it depends on. The behavior is the one
|
|
182
|
+
documented for :func:`backlogops.order_by_dependencies`.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
later: How a dependency that is not yet satisfied is resolved.
|
|
186
|
+
If False (the default) the prerequisite item is pulled to
|
|
187
|
+
a position just before the dependent item. If True the
|
|
188
|
+
dependent item is pushed to a position just after its
|
|
189
|
+
prerequisites.
|
|
190
|
+
mode: How items that take part in a dependency are placed in
|
|
191
|
+
relation to items that take part in no dependency, as
|
|
192
|
+
documented for :class:`DependencyMode`. The default is
|
|
193
|
+
KEEP.
|
|
194
|
+
space_around: Key or keys of items that should have as many
|
|
195
|
+
other items as possible placed between them and the items
|
|
196
|
+
they depend on, and between them and the items that
|
|
197
|
+
depend on them. It only works well for one or very few
|
|
198
|
+
items. None means no item is treated this way.
|
|
199
|
+
stderr_file: The file to report errors to.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
TypeError: If space_around is neither None, a string, nor a
|
|
203
|
+
sequence of strings.
|
|
204
|
+
KeyError: If a space_around key is not found in the backlog.
|
|
205
|
+
RuntimeError: If space_around names more keys than allowed:
|
|
206
|
+
more than five, or more than ten percent of a backlog of
|
|
207
|
+
fewer than fifty items.
|
|
208
|
+
"""
|
|
209
|
+
self.backlog = order_by_dependencies(self.backlog, later=later,
|
|
210
|
+
mode=mode,
|
|
211
|
+
space_around=space_around,
|
|
212
|
+
stderr_file=stderr_file)
|
|
213
|
+
|
|
214
|
+
def estimate_ready_date(self, available_teams: AvailableTeams,
|
|
215
|
+
start_date: Optional[date] = None,
|
|
216
|
+
stderr_file: TextIO = sys.stderr) \
|
|
217
|
+
-> ReleaseDateChanges:
|
|
218
|
+
"""Estimate the ready date of the member backlog items.
|
|
219
|
+
|
|
220
|
+
The member backlog is replaced by a backlog whose items carry the
|
|
221
|
+
estimated ready date. The teams start working on the start date,
|
|
222
|
+
which defaults to today when None is given. The behavior is the
|
|
223
|
+
one documented for :func:`backlogops.estimate_ready_date`.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
available_teams: The available teams used to estimate the
|
|
227
|
+
ready date, including absence, velocity and work
|
|
228
|
+
hours.
|
|
229
|
+
start_date: The day the teams start working, or None for today.
|
|
230
|
+
stderr_file: The file to report warnings to.
|
|
231
|
+
"""
|
|
232
|
+
self.backlog = estimate_ready_date(self.backlog, available_teams,
|
|
233
|
+
start_date, stderr_file)
|
|
234
|
+
self.releases, changes = estimate_release_dates(self.releases,
|
|
235
|
+
self.backlog)
|
|
236
|
+
return changes
|
|
237
|
+
|
|
238
|
+
def set_plan_from_estimate(self, stderr_file: TextIO = sys.stderr) -> None:
|
|
239
|
+
"""Set the planned ready dates from the estimated ready dates.
|
|
240
|
+
|
|
241
|
+
The member backlog is replaced by a backlog whose items carry the
|
|
242
|
+
planned ready date taken from the estimated ready date, as
|
|
243
|
+
documented for :func:`backlogops.set_plan_from_estimate`.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
stderr_file: The file to report errors to.
|
|
247
|
+
"""
|
|
248
|
+
self.backlog = set_plan_from_estimate(self.backlog, stderr_file)
|
|
249
|
+
|
|
250
|
+
def adjust_release_content(self, buffer: timedelta,
|
|
251
|
+
stderr_file: TextIO = sys.stderr) \
|
|
252
|
+
-> ReleaseChanges:
|
|
253
|
+
"""Adjust the release content to fit the planned release dates.
|
|
254
|
+
|
|
255
|
+
The member backlog is replaced by a backlog whose items carry the
|
|
256
|
+
adjusted release content. The behavior is the one documented for
|
|
257
|
+
:func:`backlogops.adjust_release_content`.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
buffer: The buffer or slack added to the estimated ready dates
|
|
261
|
+
to gain confidence that an item fits a release. Must
|
|
262
|
+
not be negative.
|
|
263
|
+
stderr_file: The file to report errors to.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
A record of how the release content was changed.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: If the buffer is negative.
|
|
270
|
+
"""
|
|
271
|
+
_ = stderr_file
|
|
272
|
+
self.backlog, changes = adjust_release_content(self.releases,
|
|
273
|
+
self.backlog, buffer)
|
|
274
|
+
return changes
|
|
275
|
+
|
|
276
|
+
def release_plan_on_estimate(self, buffer: timedelta,
|
|
277
|
+
stderr_file: TextIO = sys.stderr) \
|
|
278
|
+
-> ReleaseDateChanges:
|
|
279
|
+
"""Set the planned release dates from the estimated release dates.
|
|
280
|
+
|
|
281
|
+
The member releases is replaced by releases whose items carry the
|
|
282
|
+
planned release dates taken from the estimated release dates, as
|
|
283
|
+
documented for :func:`backlogops.release_plan_on_estimate`.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
buffer: The buffer or slack to add to the estimated release dates
|
|
287
|
+
to get the planned release dates. Must not be negative.
|
|
288
|
+
stderr_file: The file to report errors to.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
A record of how the release dates were changed.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ValueError: If the buffer is negative.
|
|
295
|
+
"""
|
|
296
|
+
_ = stderr_file
|
|
297
|
+
self.releases, changes = release_plan_on_estimate(self.releases,
|
|
298
|
+
buffer)
|
|
299
|
+
return changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Read and write a backlog and its releases as tables with TableIO.
|
|
3
|
+
|
|
4
|
+
A backlog and its releases form two tables. They are written to one file
|
|
5
|
+
(and read back) using TableIO, which supports several tables in one sheet
|
|
6
|
+
separated by headings. Reading walks the tables in the file and tells a
|
|
7
|
+
backlog table from a releases table by their columns: a table with a
|
|
8
|
+
``key`` column is the backlog, a table with a ``name`` column is the
|
|
9
|
+
releases.
|
|
10
|
+
|
|
11
|
+
The internal field names of the data model can differ from the column
|
|
12
|
+
names in the file. An :class:`InputFormatConfig` carries a map from
|
|
13
|
+
external column name to internal field name, and an
|
|
14
|
+
:class:`OutputFormatConfig` carries a map from internal field name to
|
|
15
|
+
external column name. The dependency lists of a backlog item are stored
|
|
16
|
+
as one space separated string per dependency kind, and the extra fields
|
|
17
|
+
of a backlog item become extra columns.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
21
|
+
# MIT License
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Optional, TextIO, TypeVar
|
|
25
|
+
from config_as_json import PathOrStr
|
|
26
|
+
from tableio import CAP_IGNORABLE, Capabilities, DictData, FileAccess, \
|
|
27
|
+
TableIO, Value, ValueFmt, access_capabilities, tio_config_create
|
|
28
|
+
from backlogops.backlog import Backlog
|
|
29
|
+
from backlogops.backlog_releases import BacklogReleases
|
|
30
|
+
from backlogops.io_config import InputFormatConfig, OutputFormatConfig
|
|
31
|
+
from backlogops.levels import Levels
|
|
32
|
+
from backlogops.releases import Releases
|
|
33
|
+
from backlogops.format_rules import FormatRules
|
|
34
|
+
from backlogops.apply_format_rules import format_backlog, format_releases
|
|
35
|
+
from backlogops.table_rows import BACKLOG_FIELDS, RELEASE_FIELDS, \
|
|
36
|
+
row_to_item, row_to_release
|
|
37
|
+
|
|
38
|
+
BACKLOG_HEADING = 'Backlog'
|
|
39
|
+
"""Heading written before the backlog table."""
|
|
40
|
+
|
|
41
|
+
RELEASE_HEADING = 'Releases'
|
|
42
|
+
"""Heading written before the releases table."""
|
|
43
|
+
|
|
44
|
+
_RenameCell = TypeVar('_RenameCell', Value, ValueFmt)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rename(row: dict[str, _RenameCell],
|
|
48
|
+
names: dict[str, str]) -> dict[str, _RenameCell]:
|
|
49
|
+
"""Return the row with its keys translated through a name map."""
|
|
50
|
+
return {names.get(key, key): value for key, value in row.items()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_backlog_table(rows: DictData[Value]) -> bool:
|
|
54
|
+
"""Return whether a table of internal-named rows is the backlog."""
|
|
55
|
+
return 'key' in rows[0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_release_table(rows: DictData[Value]) -> bool:
|
|
59
|
+
"""Return whether a table of internal-named rows is the releases."""
|
|
60
|
+
return 'name' in rows[0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _collect_tables(config: InputFormatConfig, data_file: PathOrStr,
|
|
64
|
+
stderr_file: TextIO
|
|
65
|
+
) -> tuple[DictData[Value], DictData[Value]]:
|
|
66
|
+
"""Read every table and split it into backlog and release rows."""
|
|
67
|
+
capabilities = access_capabilities(FileAccess.READ, error_file=stderr_file)
|
|
68
|
+
backlog_rows: DictData[Value] = []
|
|
69
|
+
release_rows: DictData[Value] = []
|
|
70
|
+
with tio_config_create(config=config.tableio, file_name=data_file,
|
|
71
|
+
file_access=FileAccess.READ,
|
|
72
|
+
capabilities=capabilities) as tableio:
|
|
73
|
+
while True:
|
|
74
|
+
result = tableio.read_table_dictdata()
|
|
75
|
+
if not result.data:
|
|
76
|
+
break
|
|
77
|
+
rows = [_rename(row, config.to_internal) for row in result.data]
|
|
78
|
+
if _is_backlog_table(rows):
|
|
79
|
+
backlog_rows.extend(rows)
|
|
80
|
+
elif _is_release_table(rows):
|
|
81
|
+
release_rows.extend(rows)
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError('A table has neither a key column (backlog) '
|
|
84
|
+
'nor a name column (releases).')
|
|
85
|
+
return backlog_rows, release_rows
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_backlog_releases(data_file: PathOrStr, config: InputFormatConfig,
|
|
89
|
+
levels: Optional[Levels] = None,
|
|
90
|
+
stderr_file: TextIO = sys.stderr) -> BacklogReleases:
|
|
91
|
+
"""Read a backlog, releases, or both from one file.
|
|
92
|
+
|
|
93
|
+
Each table in the file is read and classified by its columns. The
|
|
94
|
+
column names are translated to internal field names through the input
|
|
95
|
+
configuration before classification and conversion. Field values are
|
|
96
|
+
converted to their internal types; consistency across items is not
|
|
97
|
+
checked here.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data_file: The data file to read.
|
|
101
|
+
config: The input configuration (format and column-name map).
|
|
102
|
+
levels: The levels used to resolve a string level, or None for
|
|
103
|
+
the default levels.
|
|
104
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The backlog and releases found in the file. Either may be empty.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
KeyError: A mandatory field is missing in a row.
|
|
111
|
+
TypeError: A field value has a type that cannot be converted.
|
|
112
|
+
ValueError: A table cannot be classified as backlog or releases.
|
|
113
|
+
"""
|
|
114
|
+
backlog_rows, release_rows = _collect_tables(config, data_file,
|
|
115
|
+
stderr_file)
|
|
116
|
+
backlog: Backlog = [row_to_item(row, levels, stderr_file)
|
|
117
|
+
for row in backlog_rows]
|
|
118
|
+
releases: Releases = [row_to_release(row, stderr_file)
|
|
119
|
+
for row in release_rows]
|
|
120
|
+
return BacklogReleases(backlog=backlog, releases=releases)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_capabilities(stderr_file: TextIO) -> Capabilities:
|
|
124
|
+
"""Return CREATE capabilities that prefer borders, format and filter.
|
|
125
|
+
|
|
126
|
+
The border, cell formatting, highlight and filter features are
|
|
127
|
+
requested as ignorable, so a backend that supports them (such as an
|
|
128
|
+
Excel backend like XlsxWriter or OpenPyXL) is preferred, while
|
|
129
|
+
formats without them (such as CSV) and backends without them
|
|
130
|
+
(such as pylightxl for Excel) are still allowed.
|
|
131
|
+
"""
|
|
132
|
+
base = access_capabilities(FileAccess.CREATE, error_file=stderr_file)
|
|
133
|
+
return base._replace(filtered_data_range=CAP_IGNORABLE,
|
|
134
|
+
can_write_borders=CAP_IGNORABLE,
|
|
135
|
+
can_fmt_row=CAP_IGNORABLE,
|
|
136
|
+
can_fmt_value=CAP_IGNORABLE,
|
|
137
|
+
can_write_highlight=CAP_IGNORABLE)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _backlog_order(backlog: Backlog) -> list[str]:
|
|
141
|
+
"""Return the backlog column order, with extra fields appended."""
|
|
142
|
+
extra = sorted({name for item in backlog for name in item.extra_fields})
|
|
143
|
+
return BACKLOG_FIELDS + extra
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_table(tableio: TableIO,
|
|
147
|
+
section: tuple[str, DictData[ValueFmt], list[str]],
|
|
148
|
+
names: dict[str, str], rules: FormatRules) -> None:
|
|
149
|
+
"""Write one heading and one formatted, bordered table."""
|
|
150
|
+
heading, rows, column_order = section
|
|
151
|
+
tableio.write_heading(heading)
|
|
152
|
+
external_rows = [_rename(row, names) for row in rows]
|
|
153
|
+
external_order = [names.get(name, name) for name in column_order]
|
|
154
|
+
tableio.write_table_dictdata(external_rows, column_order=external_order,
|
|
155
|
+
missing_ok=True,
|
|
156
|
+
first_row_format=rules.first_row_format,
|
|
157
|
+
filtered_data_range=rules.filtered_data_range,
|
|
158
|
+
border_style=rules.border_style)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _ordered_sections(data: BacklogReleases, rules: FormatRules
|
|
162
|
+
) -> list[tuple[str, DictData[ValueFmt], list[str]]]:
|
|
163
|
+
"""Return the non-empty tables to write, in the requested order."""
|
|
164
|
+
backlog_rows = format_backlog(data.backlog, rules)
|
|
165
|
+
release_rows = format_releases(data.releases, rules)
|
|
166
|
+
backlog = (BACKLOG_HEADING, backlog_rows, _backlog_order(data.backlog))
|
|
167
|
+
releases = (RELEASE_HEADING, release_rows, RELEASE_FIELDS)
|
|
168
|
+
sections = [backlog, releases] if rules.backlog_first else \
|
|
169
|
+
[releases, backlog]
|
|
170
|
+
return [section for section in sections if section[1]]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_backlog_releases(data: BacklogReleases, data_file: PathOrStr,
|
|
174
|
+
config: OutputFormatConfig,
|
|
175
|
+
format_rules: Optional[FormatRules] = None,
|
|
176
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
177
|
+
"""Write a backlog, releases, or both to one file.
|
|
178
|
+
|
|
179
|
+
Each non-empty table is written with a heading before it, so several
|
|
180
|
+
tables can share one file. Internal field names are translated to
|
|
181
|
+
external column names through the output configuration. The format
|
|
182
|
+
rules decide the table order, the borders, the filter range and the
|
|
183
|
+
cell formatting; when omitted the default :class:`FormatRules` apply.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
data: The backlog and releases to write.
|
|
187
|
+
data_file: The data file to create.
|
|
188
|
+
config: The output configuration (format and column-name map).
|
|
189
|
+
format_rules: How to format the written data, or None for the
|
|
190
|
+
default format rules.
|
|
191
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
192
|
+
"""
|
|
193
|
+
rules = FormatRules() if format_rules is None else format_rules
|
|
194
|
+
capabilities = _write_capabilities(stderr_file)
|
|
195
|
+
sections = _ordered_sections(data, rules)
|
|
196
|
+
with tio_config_create(config=config.tableio, file_name=data_file,
|
|
197
|
+
file_access=FileAccess.CREATE,
|
|
198
|
+
capabilities=capabilities) as tableio:
|
|
199
|
+
for section in sections:
|
|
200
|
+
_write_table(tableio, section, config.to_external, rules)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Console wizard bridge that adds yes/no questions.
|
|
3
|
+
|
|
4
|
+
The workforce wizard asks yes/no questions through a
|
|
5
|
+
:class:`YesNoUiBridge`. This module provides the console implementation of
|
|
6
|
+
that bridge, built on the text-based ``WizardUiBridgeConsole`` of
|
|
7
|
+
``tableio_cfg_json``, so a command-line program can drive the wizard. A yes
|
|
8
|
+
or no answer is read as free text such as ``y`` or ``no``, and an empty
|
|
9
|
+
answer chooses the default.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
13
|
+
# MIT License
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from tableio_cfg_json import WizardUiBridgeConsole
|
|
17
|
+
from backlogops.available_teams_wizard import YesNoUiBridge
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConsoleYesNoUiBridge(WizardUiBridgeConsole, YesNoUiBridge):
|
|
21
|
+
"""Console wizard bridge that asks yes/no questions as free text."""
|
|
22
|
+
|
|
23
|
+
def ask_yes_no(self, question: str, default: bool) -> bool:
|
|
24
|
+
"""Ask a yes/no question, returning ``default`` for an empty answer.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
question: The yes/no question to ask.
|
|
28
|
+
default: The value to use when the user gives an empty answer.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The user's choice as a boolean.
|
|
32
|
+
"""
|
|
33
|
+
hint = 'Y/n' if default else 'y/N'
|
|
34
|
+
re_ask: Optional[str] = None
|
|
35
|
+
while True:
|
|
36
|
+
answer = self.ask(f'{question} ({hint})', re_ask)
|
|
37
|
+
text = answer if isinstance(answer, str) else str(answer)
|
|
38
|
+
if text == '':
|
|
39
|
+
return default
|
|
40
|
+
lowered = text.strip().lower()
|
|
41
|
+
if lowered in ('y', 'yes'):
|
|
42
|
+
return True
|
|
43
|
+
if lowered in ('n', 'no'):
|
|
44
|
+
return False
|
|
45
|
+
re_ask = "Please answer 'yes' or 'no'."
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Helpers for validating inclusive date ranges."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
from backlogops.backlog_helpers import report_bad_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_date_range(field_name: str, start: date, end: date,
|
|
14
|
+
stderr_file: TextIO = sys.stderr,
|
|
15
|
+
subject: str = 'Backlog item') -> None:
|
|
16
|
+
"""Check that an inclusive date range is not empty.
|
|
17
|
+
|
|
18
|
+
The range covers every day from ``start`` to ``end`` inclusive, so
|
|
19
|
+
``start`` must not be after ``end``.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
field_name: The name of the field that holds the range.
|
|
23
|
+
start: The first day of the range.
|
|
24
|
+
end: The last day of the range.
|
|
25
|
+
stderr_file: The file to report errors to.
|
|
26
|
+
subject: What owns the field, used to start error messages.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If ``start`` is after ``end``.
|
|
30
|
+
"""
|
|
31
|
+
if start > end:
|
|
32
|
+
report_bad_value(field_name, (start, end),
|
|
33
|
+
'start_date is after end_date', stderr_file, subject)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_no_overlap(field_name: str, ranges: list[tuple[date, date]],
|
|
37
|
+
stderr_file: TextIO = sys.stderr,
|
|
38
|
+
subject: str = 'Backlog item') -> None:
|
|
39
|
+
"""Check that inclusive date ranges do not share a day.
|
|
40
|
+
|
|
41
|
+
Each range must already be valid (start not after end). The ranges
|
|
42
|
+
are sorted by start day and each is compared with the next, so an
|
|
43
|
+
overlap is found in a single pass.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
field_name: The name of the field that holds the ranges.
|
|
47
|
+
ranges: The inclusive ``(start, end)`` ranges to check.
|
|
48
|
+
stderr_file: The file to report errors to.
|
|
49
|
+
subject: What owns the field, used to start error messages.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If two ranges share a day.
|
|
53
|
+
"""
|
|
54
|
+
ordered = sorted(ranges)
|
|
55
|
+
for earlier, later in zip(ordered, ordered[1:]):
|
|
56
|
+
if later[0] <= earlier[1]:
|
|
57
|
+
report_bad_value(field_name, later,
|
|
58
|
+
'overlaps an earlier date range', stderr_file,
|
|
59
|
+
subject)
|