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,239 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Interaction between releases and backlogs."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from typing import NamedTuple, Optional
|
|
10
|
+
from backlogops.backlog import Backlog, BacklogItem
|
|
11
|
+
from backlogops.releases import Releases
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ReleaseChange:
|
|
16
|
+
"""A change of the release a backlog item is delivered in.
|
|
17
|
+
|
|
18
|
+
Both releases are optional, because a backlog item may carry no
|
|
19
|
+
release before the change and may end up with no release after it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
backlog_key: str
|
|
23
|
+
old_release: Optional[str]
|
|
24
|
+
new_release: Optional[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
type ReleaseChanges = list[ReleaseChange]
|
|
28
|
+
"""Changes of releases for a backlog items."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BacklogReleaseChange(NamedTuple):
|
|
32
|
+
"""A change of a backlog with changes to releases."""
|
|
33
|
+
|
|
34
|
+
backlog: Backlog
|
|
35
|
+
release_changes: ReleaseChanges
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ReleaseDateChange:
|
|
40
|
+
"""A change of a release date.
|
|
41
|
+
|
|
42
|
+
Both dates are optional, because a release may have had no date
|
|
43
|
+
before the change and may have no date after it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
release: str
|
|
47
|
+
old_date: Optional[date]
|
|
48
|
+
new_date: Optional[date]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
type ReleaseDateChanges = list[ReleaseDateChange]
|
|
52
|
+
"""Changes of release dates for a release."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReleasesAndDateChanges(NamedTuple):
|
|
56
|
+
"""Releases and their date changes."""
|
|
57
|
+
|
|
58
|
+
releases: Releases
|
|
59
|
+
date_changes: ReleaseDateChanges
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_buffer(buffer: timedelta) -> None:
|
|
63
|
+
"""Raise ``ValueError`` when the buffer is negative.
|
|
64
|
+
|
|
65
|
+
A buffer is a slack added to a date, so a negative buffer would mean
|
|
66
|
+
negative slack and is rejected.
|
|
67
|
+
"""
|
|
68
|
+
if buffer < timedelta(0):
|
|
69
|
+
raise ValueError('buffer must not be negative')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _latest_per_release(backlog: Backlog) -> dict[str, date]:
|
|
73
|
+
"""Return the latest estimated ready date assigned to each release.
|
|
74
|
+
|
|
75
|
+
A backlog item adds to the result only when it names a release and
|
|
76
|
+
carries an estimated ready date. A release named by no such item is
|
|
77
|
+
absent from the result.
|
|
78
|
+
"""
|
|
79
|
+
latest: dict[str, date] = {}
|
|
80
|
+
for item in backlog:
|
|
81
|
+
ready = item.estimated_ready_date
|
|
82
|
+
if item.release is None or ready is None:
|
|
83
|
+
continue
|
|
84
|
+
current = latest.get(item.release)
|
|
85
|
+
if current is None or ready > current:
|
|
86
|
+
latest[item.release] = ready
|
|
87
|
+
return latest
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def estimate_release_dates(releases: Releases, backlog: Backlog) \
|
|
91
|
+
-> ReleasesAndDateChanges:
|
|
92
|
+
"""Find estimated release dates from backlog item estimates.
|
|
93
|
+
|
|
94
|
+
For each release, the estimated date is set to the latest estimated
|
|
95
|
+
ready date of the backlog items assigned to the release. A release
|
|
96
|
+
with no assigned item that carries an estimated ready date gets no
|
|
97
|
+
estimated date (``None``). A change is recorded only for a release
|
|
98
|
+
whose estimated date actually changes.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
releases: The releases to find the estimated dates for.
|
|
102
|
+
The argument is not modified.
|
|
103
|
+
backlog: The already estimated backlog to find the estimated dates
|
|
104
|
+
from. The argument is not modified.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The releases with updated estimated dates and a record of how
|
|
108
|
+
the estimated release dates were changed.
|
|
109
|
+
"""
|
|
110
|
+
latest = _latest_per_release(backlog)
|
|
111
|
+
new_releases: Releases = []
|
|
112
|
+
changes: ReleaseDateChanges = []
|
|
113
|
+
for release in releases:
|
|
114
|
+
new_date = latest.get(release.name)
|
|
115
|
+
if new_date != release.estimated_date:
|
|
116
|
+
changes.append(ReleaseDateChange(release.name,
|
|
117
|
+
release.estimated_date, new_date))
|
|
118
|
+
new_releases.append(replace(release, estimated_date=new_date))
|
|
119
|
+
return ReleasesAndDateChanges(new_releases, changes)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def release_plan_on_estimate(releases: Releases, buffer: timedelta) \
|
|
123
|
+
-> ReleasesAndDateChanges:
|
|
124
|
+
"""Set the planned release dates from the estimated release dates.
|
|
125
|
+
|
|
126
|
+
For each release the planned date is set to the estimated date plus
|
|
127
|
+
the buffer. A release with no estimated date gets no planned date
|
|
128
|
+
(``None``), as there is nothing to base the plan on. A change is
|
|
129
|
+
recorded only for a release whose planned date actually changes.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
releases: The releases to set the planned release dates for.
|
|
133
|
+
The argument is not modified.
|
|
134
|
+
buffer: The buffer or slack to add to the estimated release dates
|
|
135
|
+
to get the planned release dates. Must not be negative.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The releases with updated planned release dates and a record of
|
|
139
|
+
how the planned release dates were changed.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If the buffer is negative.
|
|
143
|
+
"""
|
|
144
|
+
_check_buffer(buffer)
|
|
145
|
+
new_releases: Releases = []
|
|
146
|
+
changes: ReleaseDateChanges = []
|
|
147
|
+
for release in releases:
|
|
148
|
+
estimated = release.estimated_date
|
|
149
|
+
new_date = None if estimated is None else estimated + buffer
|
|
150
|
+
if new_date != release.planned_date:
|
|
151
|
+
changes.append(ReleaseDateChange(release.name,
|
|
152
|
+
release.planned_date, new_date))
|
|
153
|
+
new_releases.append(replace(release, planned_date=new_date))
|
|
154
|
+
return ReleasesAndDateChanges(new_releases, changes)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _dated_releases(releases: Releases) -> list[tuple[date, int, str]]:
|
|
158
|
+
"""Return the planned releases as ``(date, order, name)``, sorted.
|
|
159
|
+
|
|
160
|
+
Only releases that carry a planned date take part, because a release
|
|
161
|
+
with no planned date offers no deadline to fit an item into. The
|
|
162
|
+
order index keeps the sort stable for releases that share a date.
|
|
163
|
+
"""
|
|
164
|
+
dated = [(release.planned_date, index, release.name)
|
|
165
|
+
for index, release in enumerate(releases)
|
|
166
|
+
if release.planned_date is not None]
|
|
167
|
+
return sorted(dated)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fitting_release(dated: list[tuple[date, int, str]],
|
|
171
|
+
fit_date: date) -> Optional[str]:
|
|
172
|
+
"""Return the earliest planned release that the fit date reaches.
|
|
173
|
+
|
|
174
|
+
The earliest release whose planned date is on or after ``fit_date``
|
|
175
|
+
is returned, or ``None`` when no planned release is late enough.
|
|
176
|
+
"""
|
|
177
|
+
for planned, _order, name in dated:
|
|
178
|
+
if planned >= fit_date:
|
|
179
|
+
return name
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _new_release_for(item: BacklogItem, dated: list[tuple[date, int, str]],
|
|
184
|
+
buffer: timedelta) -> Optional[str]:
|
|
185
|
+
"""Return the release the item belongs in for its current estimate.
|
|
186
|
+
|
|
187
|
+
An item with no estimated ready date keeps its current release, as
|
|
188
|
+
there is no basis to place it. Otherwise the item is placed in the
|
|
189
|
+
earliest planned release that its estimated ready date plus the buffer
|
|
190
|
+
reaches, regardless of its current release, so an item with no release
|
|
191
|
+
yet is assigned to the release it is ready in time for. The item is
|
|
192
|
+
placed in no release when no planned release is late enough.
|
|
193
|
+
"""
|
|
194
|
+
if item.estimated_ready_date is None:
|
|
195
|
+
return item.release
|
|
196
|
+
return _fitting_release(dated, item.estimated_ready_date + buffer)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def adjust_release_content(releases: Releases, backlog: Backlog,
|
|
200
|
+
buffer: timedelta) -> BacklogReleaseChange:
|
|
201
|
+
"""Adjust the release content to fit the planned release dates.
|
|
202
|
+
|
|
203
|
+
Each backlog item that carries an estimated ready date is placed in
|
|
204
|
+
the earliest release whose planned date is on or after the item's
|
|
205
|
+
estimated ready date plus the buffer. This pushes an item to a later
|
|
206
|
+
release when it no longer fits its current one, pulls it to an earlier
|
|
207
|
+
release when it now fits sooner, and assigns an item that has no
|
|
208
|
+
release yet to the release it is ready in time for. An item that no
|
|
209
|
+
planned release is late enough for is left out of every release (its
|
|
210
|
+
release becomes ``None``). An item with no estimated ready date keeps
|
|
211
|
+
its current release, as there is no basis to place it. A change is
|
|
212
|
+
recorded only for an item whose release actually changes.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
releases: The releases to fit the items into. The argument is not
|
|
216
|
+
modified.
|
|
217
|
+
backlog: The already estimated backlog to adjust. The argument is
|
|
218
|
+
not modified.
|
|
219
|
+
buffer: The buffer or slack added to the estimated ready dates to
|
|
220
|
+
gain confidence that an item fits a release. Must not be
|
|
221
|
+
negative.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The backlog with updated release content and a record of how the
|
|
225
|
+
release content was changed.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If the buffer is negative.
|
|
229
|
+
"""
|
|
230
|
+
_check_buffer(buffer)
|
|
231
|
+
dated = _dated_releases(releases)
|
|
232
|
+
new_backlog: Backlog = []
|
|
233
|
+
changes: ReleaseChanges = []
|
|
234
|
+
for item in backlog:
|
|
235
|
+
new_release = _new_release_for(item, dated, buffer)
|
|
236
|
+
if new_release != item.release:
|
|
237
|
+
changes.append(ReleaseChange(item.key, item.release, new_release))
|
|
238
|
+
new_backlog.append(replace(item, release=new_release))
|
|
239
|
+
return BacklogReleaseChange(new_backlog, changes)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Print and write release-change records as text or table files.
|
|
3
|
+
|
|
4
|
+
A release-change record is the small log produced by the release update
|
|
5
|
+
operations: which backlog item moved between releases
|
|
6
|
+
(:class:`ReleaseChange`) and how a release date moved
|
|
7
|
+
(:class:`ReleaseDateChange`). These functions render such a log as text
|
|
8
|
+
for the console and write it to a one-table file with TableIO, choosing
|
|
9
|
+
the file format from the file name extension.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
13
|
+
# MIT License
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional, Sequence, TextIO
|
|
19
|
+
from config_as_json import PathOrStr
|
|
20
|
+
from tableio import Value
|
|
21
|
+
from backlogops.table_create import create_output_table
|
|
22
|
+
from backlogops.release_backlog_updates import ReleaseChanges, \
|
|
23
|
+
ReleaseDateChanges
|
|
24
|
+
|
|
25
|
+
CONTENT_HEADER = ['backlog_key', 'old_release', 'new_release']
|
|
26
|
+
"""Column names of a release content change table."""
|
|
27
|
+
|
|
28
|
+
DATE_HEADER = ['release', 'old_date', 'new_date']
|
|
29
|
+
"""Column names of a release date change table."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _text(value: Optional[str]) -> str:
|
|
33
|
+
"""Return a release name for display, or ``(none)`` when absent."""
|
|
34
|
+
return value if value is not None else '(none)'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _date_text(value: Optional[date]) -> str:
|
|
38
|
+
"""Return a date for display, or ``(none)`` when absent."""
|
|
39
|
+
return value.isoformat() if value is not None else '(none)'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _listing(title: str, empty: str, rows: Sequence[str]) -> str:
|
|
43
|
+
"""Return a titled multi line listing, or the empty message."""
|
|
44
|
+
if not rows:
|
|
45
|
+
return empty
|
|
46
|
+
return '\n'.join([title, *rows])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_content_changes(changes: ReleaseChanges) -> str:
|
|
50
|
+
"""Return release content changes as text for the console."""
|
|
51
|
+
rows = [f' {change.backlog_key}: {_text(change.old_release)} -> '
|
|
52
|
+
f'{_text(change.new_release)}' for change in changes]
|
|
53
|
+
return _listing('Release content changes:', 'No release content changes.',
|
|
54
|
+
rows)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_date_changes(changes: ReleaseDateChanges) -> str:
|
|
58
|
+
"""Return release date changes as text for the console."""
|
|
59
|
+
rows = [f' {change.release}: {_date_text(change.old_date)} -> '
|
|
60
|
+
f'{_date_text(change.new_date)}' for change in changes]
|
|
61
|
+
return _listing('Release date changes:', 'No release date changes.', rows)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _date_cell(value: Optional[date]) -> Value:
|
|
65
|
+
"""Return a date as an ISO string cell, or None when absent."""
|
|
66
|
+
return value.isoformat() if value is not None else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ensure_absent(file_name: PathOrStr, stderr_file: TextIO) -> None:
|
|
70
|
+
"""Raise ``FileExistsError`` when the target file already exists."""
|
|
71
|
+
if Path(file_name).exists():
|
|
72
|
+
message = f'File already exists: {file_name}'
|
|
73
|
+
print(message, file=stderr_file)
|
|
74
|
+
raise FileExistsError(message)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_table(header: list[str], rows: list[list[Value]],
|
|
78
|
+
file_name: PathOrStr, stderr_file: TextIO) -> None:
|
|
79
|
+
"""Write a header row and the change rows as a one table file.
|
|
80
|
+
|
|
81
|
+
The rows are written with list writing, so the header is the first
|
|
82
|
+
data row. An empty change list still writes the header row, recording
|
|
83
|
+
that there were no changes.
|
|
84
|
+
"""
|
|
85
|
+
_ensure_absent(file_name, stderr_file)
|
|
86
|
+
with create_output_table(file_name, stderr_file) as tableio:
|
|
87
|
+
tableio.write_table_listdata([header, *rows])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_content_changes(changes: ReleaseChanges, file_name: PathOrStr,
|
|
91
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
92
|
+
"""Write release content changes to a one table file.
|
|
93
|
+
|
|
94
|
+
The file format is chosen from the file name extension, as for any
|
|
95
|
+
TableIO table. The single table has the columns ``backlog_key``,
|
|
96
|
+
``old_release`` and ``new_release``; an absent release is an empty
|
|
97
|
+
cell.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
changes: The release content changes to write, in order.
|
|
101
|
+
file_name: The file to create.
|
|
102
|
+
stderr_file: The stream to report errors to.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
FileExistsError: If the file already exists.
|
|
106
|
+
ValueError: If the extension is not a supported table format.
|
|
107
|
+
"""
|
|
108
|
+
rows: list[list[Value]] = \
|
|
109
|
+
[[change.backlog_key, change.old_release, change.new_release]
|
|
110
|
+
for change in changes]
|
|
111
|
+
_write_table(CONTENT_HEADER, rows, file_name, stderr_file)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_date_changes(changes: ReleaseDateChanges, file_name: PathOrStr,
|
|
115
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
116
|
+
"""Write release date changes to a one table file.
|
|
117
|
+
|
|
118
|
+
The file format is chosen from the file name extension, as for any
|
|
119
|
+
TableIO table. The single table has the columns ``release``,
|
|
120
|
+
``old_date`` and ``new_date``; an absent date is an empty cell.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
changes: The release date changes to write, in order.
|
|
124
|
+
file_name: The file to create.
|
|
125
|
+
stderr_file: The stream to report errors to.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
FileExistsError: If the file already exists.
|
|
129
|
+
ValueError: If the extension is not a supported table format.
|
|
130
|
+
"""
|
|
131
|
+
rows: list[list[Value]] = \
|
|
132
|
+
[[change.release, _date_cell(change.old_date),
|
|
133
|
+
_date_cell(change.new_date)] for change in changes]
|
|
134
|
+
_write_table(DATE_HEADER, rows, file_name, stderr_file)
|
backlogops/releases.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Releases related to a backlog."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, fields
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import NoReturn, Optional, TextIO
|
|
10
|
+
import sys
|
|
11
|
+
from backlogops.backlog_helpers import build_item_kwargs, check_field_types
|
|
12
|
+
from backlogops.backlog_helpers import check_key_syntax, construct
|
|
13
|
+
from backlogops.backlog_helpers import field_type_hints, report_bad_value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Release:
|
|
18
|
+
"""A release of some BacklogItems.
|
|
19
|
+
|
|
20
|
+
A release groups backlog items that are delivered together. A
|
|
21
|
+
backlog item refers to its release by name through its ``release``
|
|
22
|
+
field, so the release name must follow the same syntax rules as a
|
|
23
|
+
backlog item key.
|
|
24
|
+
|
|
25
|
+
Fields:
|
|
26
|
+
name: The name of the release. Required. Must be unique among
|
|
27
|
+
the releases. Must not be empty, must not contain
|
|
28
|
+
whitespace and must not contain any of the characters
|
|
29
|
+
, . ; : ( ) [ ] { }.
|
|
30
|
+
planned_date: The planned date of the release. Optional.
|
|
31
|
+
The date that is communicated to the customer.
|
|
32
|
+
estimated_date: The estimated date of the release. Optional.
|
|
33
|
+
The date that the content of the release is
|
|
34
|
+
estimated to be ready. The estimated date and
|
|
35
|
+
the planned date are independent of each other;
|
|
36
|
+
no ordering between them is required.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
planned_date: Optional[date] = None
|
|
41
|
+
estimated_date: Optional[date] = None
|
|
42
|
+
|
|
43
|
+
def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
|
|
44
|
+
"""Check the internal consistency of the release.
|
|
45
|
+
|
|
46
|
+
The field types are verified and the name is checked to be a
|
|
47
|
+
well formed key (a non-empty string with no whitespace and none
|
|
48
|
+
of the forbidden separator characters). Uniqueness of the name
|
|
49
|
+
among several releases is not checked here; that is done by
|
|
50
|
+
:func:`check_releases`.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
stderr_file: The file to report errors to.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TypeError: If a field has the wrong type.
|
|
57
|
+
ValueError: If the name violates the key syntax constraint.
|
|
58
|
+
"""
|
|
59
|
+
check_field_types(self, stderr_file, subject='Release')
|
|
60
|
+
check_key_syntax('name', self.name, stderr_file, subject='Release')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
type Releases = list[Release]
|
|
64
|
+
"""A list of releases related to a Backlog."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def report_unknown_keys(unknown: set[str],
|
|
68
|
+
stderr_file: TextIO = sys.stderr) -> NoReturn:
|
|
69
|
+
"""Report unknown release input keys and raise ``KeyError``.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
unknown: The input keys that match no field of :class:`Release`.
|
|
73
|
+
stderr_file: The file to report the error to.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
KeyError: Always, after reporting the message.
|
|
77
|
+
"""
|
|
78
|
+
names = ', '.join(sorted(unknown))
|
|
79
|
+
message = f'Release has unknown field(s): {names}'
|
|
80
|
+
print(message, file=stderr_file)
|
|
81
|
+
raise KeyError(message)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_release(data: dict[str, object], stderr_file: TextIO = sys.stderr,
|
|
85
|
+
strict: bool = True) -> Release:
|
|
86
|
+
"""Get a release from a dictionary.
|
|
87
|
+
|
|
88
|
+
The dictionary is expected to hold the mandatory ``name`` field and
|
|
89
|
+
may hold the optional ``planned_date`` and ``estimated_date``
|
|
90
|
+
fields. Date fields given as ISO 8601 strings (such as
|
|
91
|
+
``'2026-06-12'``) are converted to ``date`` objects.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
data: The dictionary to get the release from.
|
|
95
|
+
stderr_file: The file to report errors to.
|
|
96
|
+
strict: When True (the default), any input key that matches no
|
|
97
|
+
field of :class:`Release` is an error. When False such
|
|
98
|
+
keys are silently ignored.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The release.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
KeyError: If the mandatory ``name`` field is missing, or if
|
|
105
|
+
``strict`` is True and the data has a key that is not a
|
|
106
|
+
release field.
|
|
107
|
+
TypeError: If a field has a type that cannot be converted.
|
|
108
|
+
"""
|
|
109
|
+
field_types = field_type_hints(Release)
|
|
110
|
+
if strict:
|
|
111
|
+
unknown = set(data) - set(field_types)
|
|
112
|
+
if unknown:
|
|
113
|
+
report_unknown_keys(unknown, stderr_file)
|
|
114
|
+
item_kwargs = build_item_kwargs(fields(Release), field_types, data,
|
|
115
|
+
stderr_file)
|
|
116
|
+
return construct(Release, item_kwargs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_releases(datalist: list[dict[str, object]],
|
|
120
|
+
stderr_file: TextIO = sys.stderr,
|
|
121
|
+
strict: bool = True) -> Releases:
|
|
122
|
+
"""Get a list of releases from a list of dictionaries.
|
|
123
|
+
|
|
124
|
+
Each dictionary is converted to a release as documented for
|
|
125
|
+
:func:`get_release`, with the same ``strict`` handling of keys that
|
|
126
|
+
do not match a release field.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
datalist: The list of dictionaries to get the releases from.
|
|
130
|
+
stderr_file: The file to report errors to.
|
|
131
|
+
strict: Passed to :func:`get_release` for each dictionary. When
|
|
132
|
+
True (the default), unknown keys are an error; when
|
|
133
|
+
False they are ignored.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The list of releases.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
KeyError: If a mandatory ``name`` field is missing, or if
|
|
140
|
+
``strict`` is True and a dictionary has a key that is not a
|
|
141
|
+
release field.
|
|
142
|
+
TypeError: If a field has a type that cannot be converted.
|
|
143
|
+
"""
|
|
144
|
+
return [get_release(data, stderr_file, strict) for data in datalist]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_releases(releases: Releases,
|
|
148
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
149
|
+
"""Check the internal consistency of a list of releases.
|
|
150
|
+
|
|
151
|
+
Every release is checked for internal consistency as documented for
|
|
152
|
+
:meth:`Release.check_consistency`, and the release names are checked
|
|
153
|
+
to be unique.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
releases: The list of releases to check.
|
|
157
|
+
stderr_file: The file to report errors to.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
TypeError: If a field has the wrong type.
|
|
161
|
+
ValueError: If a name violates the key syntax constraint, or if
|
|
162
|
+
two releases share the same name.
|
|
163
|
+
"""
|
|
164
|
+
seen: set[str] = set()
|
|
165
|
+
for release in releases:
|
|
166
|
+
release.check_consistency(stderr_file)
|
|
167
|
+
if release.name in seen:
|
|
168
|
+
report_bad_value('name', release.name, 'duplicate release name',
|
|
169
|
+
stderr_file, subject='Release')
|
|
170
|
+
seen.add(release.name)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Open a TableIO file for creating a single table output.
|
|
3
|
+
|
|
4
|
+
Several writers create a file that holds one table whose format follows
|
|
5
|
+
the file name extension (a key list, a list of changes, and so on). They
|
|
6
|
+
all resolve the output configuration from the file name, request CREATE
|
|
7
|
+
capabilities, and open a TableIO context. This helper holds that shared
|
|
8
|
+
setup so each writer only describes the rows it writes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
12
|
+
# MIT License
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import TextIO
|
|
18
|
+
from config_as_json import PathOrStr
|
|
19
|
+
from tableio import FileAccess, TableIO, access_capabilities, \
|
|
20
|
+
tio_config_create
|
|
21
|
+
from backlogops.io_config import resolve_output_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def create_output_table(file_name: PathOrStr,
|
|
26
|
+
stderr_file: TextIO = sys.stderr) -> Iterator[TableIO]:
|
|
27
|
+
"""Yield a TableIO opened to create a one table file.
|
|
28
|
+
|
|
29
|
+
The output format is resolved from the file name extension and the
|
|
30
|
+
file is opened with CREATE access. The yielded TableIO is used to
|
|
31
|
+
write the table inside the ``with`` block.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
file_name: The file to create.
|
|
35
|
+
stderr_file: The stream to report errors to.
|
|
36
|
+
|
|
37
|
+
Yields:
|
|
38
|
+
The TableIO ready to write one table to the file.
|
|
39
|
+
"""
|
|
40
|
+
config = resolve_output_config(None, data_file=file_name,
|
|
41
|
+
stderr_file=stderr_file).tableio
|
|
42
|
+
capabilities = access_capabilities(FileAccess.CREATE,
|
|
43
|
+
error_file=stderr_file)
|
|
44
|
+
with tio_config_create(config=config, file_name=file_name,
|
|
45
|
+
file_access=FileAccess.CREATE,
|
|
46
|
+
capabilities=capabilities) as tableio:
|
|
47
|
+
yield tableio
|