backlogops 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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