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,129 @@
1
+ #! /usr/local/bin/python3
2
+ """A demonstration backlog and releases for manual tests and examples.
3
+
4
+ The demo data has three level-2 items (epics), twenty level-1 items
5
+ (stories) and two level-0 items (tasks). The two tasks share the same
6
+ story as parent, and fifteen of the stories have an epic as parent. A few
7
+ dependencies are added between items. Two releases exist: ``Next`` with a
8
+ planned date one month ahead, and ``Later`` with no planned date. Five
9
+ items are assigned to ``Next`` and five to ``Later``; the rest have no
10
+ release. The items are returned in a deliberately mixed order, so the
11
+ backlog is neither dependency-ordered nor release-ordered, while still
12
+ passing all consistency checks.
13
+ """
14
+
15
+ # Copyright (c) 2026, Tom Björkholm
16
+ # MIT License
17
+
18
+ from calendar import monthrange
19
+ from datetime import date
20
+ from typing import Optional
21
+ from backlogops.backlog import BacklogItem, Status
22
+ from backlogops.backlog_releases import BacklogReleases
23
+ from backlogops.releases import Release
24
+
25
+ _POINTS = (1, 2, 3, 5, 8, 13)
26
+ """Story point values cycled over the demo items."""
27
+
28
+ _STATUSES = (Status.TODO, Status.IN_PROGRESS)
29
+ """Statuses cycled over the demo items."""
30
+
31
+ _NEXT_KEYS = ('E1', 'S2', 'S7', 'S16', 'T1')
32
+ """Demo items delivered in the ``Next`` release."""
33
+
34
+ _LATER_KEYS = ('E2', 'S3', 'S9', 'S18', 'T2')
35
+ """Demo items delivered in the ``Later`` release."""
36
+
37
+ _DEPENDENCIES = (
38
+ ('S2', 'depends_on_f2s', 'S3'),
39
+ ('S5', 'depends_on_s2s', 'S6'),
40
+ ('S10', 'depends_on_f2f', 'S11'),
41
+ ('T1', 'depends_on_f2s', 'T2'),
42
+ ('E3', 'depends_on_f2f', 'E1'))
43
+ """A few demo dependencies as (item key, dependency field, target key)."""
44
+
45
+
46
+ def _make_item(key: str, level: int, title: str, index: int,
47
+ parent_key: Optional[str] = None) -> BacklogItem:
48
+ """Build one demo backlog item with cycled points and status."""
49
+ return BacklogItem(key=key, level=level, title=title,
50
+ story_points=_POINTS[index % len(_POINTS)],
51
+ status=_STATUSES[index % len(_STATUSES)],
52
+ parent_key=parent_key)
53
+
54
+
55
+ def _epics() -> list[BacklogItem]:
56
+ """Return the three level-2 epics."""
57
+ return [_make_item(f'E{i + 1}', 2, f'Epic {i + 1}', i) for i in range(3)]
58
+
59
+
60
+ def _story_parent(index: int) -> Optional[str]:
61
+ """Return the epic parent for a story, or None for the last five."""
62
+ return f'E{index % 3 + 1}' if index < 15 else None
63
+
64
+
65
+ def _stories() -> list[BacklogItem]:
66
+ """Return the twenty level-1 stories, fifteen with an epic parent."""
67
+ return [_make_item(f'S{i + 1}', 1, f'Story {i + 1}', i, _story_parent(i))
68
+ for i in range(20)]
69
+
70
+
71
+ def _tasks() -> list[BacklogItem]:
72
+ """Return the two level-0 tasks, both children of story ``S1``."""
73
+ return [_make_item(f'T{i + 1}', 0, f'Task {i + 1}', i, 'S1')
74
+ for i in range(2)]
75
+
76
+
77
+ def _apply_releases(by_key: dict[str, BacklogItem]) -> None:
78
+ """Assign the ``Next`` and ``Later`` releases to five items each."""
79
+ for key in _NEXT_KEYS:
80
+ by_key[key].release = 'Next'
81
+ for key in _LATER_KEYS:
82
+ by_key[key].release = 'Later'
83
+
84
+
85
+ def _apply_dependencies(by_key: dict[str, BacklogItem]) -> None:
86
+ """Add the demo dependencies between items."""
87
+ for key, field_name, target in _DEPENDENCIES:
88
+ getattr(by_key[key], field_name).append(target)
89
+
90
+
91
+ def _mixed_order(epics: list[BacklogItem], stories: list[BacklogItem],
92
+ tasks: list[BacklogItem]) -> list[BacklogItem]:
93
+ """Interleave the items so they are neither level nor release sorted."""
94
+ items = list(stories)
95
+ items.insert(3, epics[0])
96
+ items.insert(9, tasks[0])
97
+ items.insert(14, epics[1])
98
+ items.insert(20, tasks[1])
99
+ items.insert(23, epics[2])
100
+ return items
101
+
102
+
103
+ def _one_month_ahead() -> date:
104
+ """Return the date one calendar month after today."""
105
+ today = date.today()
106
+ month = today.month % 12 + 1
107
+ year = today.year + (1 if today.month == 12 else 0)
108
+ return date(year, month, min(today.day, monthrange(year, month)[1]))
109
+
110
+
111
+ def get_demo_backlog() -> BacklogReleases:
112
+ """Return a demonstration backlog and its releases.
113
+
114
+ The returned data passes :meth:`BacklogReleases.check_consistency`.
115
+ It is useful for manual tests and for developers building
116
+ applications on top of this library.
117
+
118
+ Returns:
119
+ A backlog with epics, stories and tasks, and the ``Next`` and
120
+ ``Later`` releases.
121
+ """
122
+ epics, stories, tasks = _epics(), _stories(), _tasks()
123
+ by_key = {item.key: item for item in epics + stories + tasks}
124
+ _apply_releases(by_key)
125
+ _apply_dependencies(by_key)
126
+ backlog = _mixed_order(epics, stories, tasks)
127
+ releases = [Release(name='Next', planned_date=_one_month_ahead()),
128
+ Release(name='Later')]
129
+ return BacklogReleases(backlog=backlog, releases=releases)
@@ -0,0 +1,379 @@
1
+ #! /usr/local/bin/python3
2
+ """Estimate the ready date of backlog items."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ from dataclasses import dataclass, replace
9
+ from datetime import date, timedelta
10
+ from typing import Optional, TextIO
11
+ from backlogops.backlog import Backlog, BacklogItem, Status
12
+ from backlogops.available_teams import AvailableTeams, membership_fte_on
13
+ from backlogops.person import Person
14
+ from backlogops.team import Team
15
+ from backlogops.work_hours import CompanyWorkHours, ExceptionWorkHours, WeekDay
16
+
17
+ _ONE_DAY = timedelta(days=1)
18
+ """One calendar day, the step used when working through a schedule."""
19
+
20
+ _HORIZON = timedelta(days=366 * 100)
21
+ """How far ahead work is followed before it counts as never finished."""
22
+
23
+ _EPSILON = 1e-9
24
+ """Tolerance for treating accumulated story points as fully done."""
25
+
26
+
27
+ def _week_day(day: date) -> WeekDay:
28
+ """Return the WeekDay value of a calendar day (Monday is first)."""
29
+ return WeekDay(day.weekday() + 1)
30
+
31
+
32
+ def _exception_on(exceptions: list[ExceptionWorkHours],
33
+ day: date) -> Optional[ExceptionWorkHours]:
34
+ """Return the work-hours exception covering a day, or None.
35
+
36
+ The exceptions in one list do not overlap, so at most one of them
37
+ covers any given day.
38
+ """
39
+ for exception in exceptions:
40
+ if exception.start_date <= day <= exception.end_date:
41
+ return exception
42
+ return None
43
+
44
+
45
+ def _apply_exception(base: float, exception: ExceptionWorkHours) -> float:
46
+ """Return the work hours after applying an exception to a baseline.
47
+
48
+ On a day that is closed in the baseline the exception only adds
49
+ hours when its new_work_days flag is set; otherwise the closed day
50
+ stays closed.
51
+ """
52
+ if base > 0.0 or exception.new_work_days:
53
+ return exception.hours_per_day
54
+ return base
55
+
56
+
57
+ def _scheduled_hours(company: CompanyWorkHours, day: date) -> float:
58
+ """Return the company work hours on a day, with company exceptions."""
59
+ base = company.work_hours.get(_week_day(day), 0.0)
60
+ exception = _exception_on(company.exceptions, day)
61
+ if exception is None:
62
+ return base
63
+ return _apply_exception(base, exception)
64
+
65
+
66
+ def _person_hours(person: Person, company: CompanyWorkHours,
67
+ day: date) -> float:
68
+ """Return the work hours of one person on a day.
69
+
70
+ The company schedule, including the company exceptions, is the
71
+ person's baseline. A personal work-hours exception overrides that
72
+ baseline, modelling vacation, part-time or ordered over-time.
73
+ """
74
+ base = _scheduled_hours(company, day)
75
+ exception = _exception_on(person.exceptions, day)
76
+ if exception is None:
77
+ return base
78
+ return _apply_exception(base, exception)
79
+
80
+
81
+ @dataclass(frozen=True, order=True)
82
+ class _Cursor:
83
+ """A team's progress: the day it works and points spent that day.
84
+
85
+ Keeping the points already spent on the current day lets a team
86
+ finish several small items on the same day instead of losing the
87
+ rest of the day to one item. Cursors order by day and then by spent
88
+ points, so a smaller cursor is the team that is free earlier.
89
+ """
90
+
91
+ day: date
92
+ used: float
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class _Workforce:
97
+ """The workforce together with the length of a full work day.
98
+
99
+ The standard work day is the longest day in the company weekly
100
+ schedule. It is the reference a person's actual work hours are
101
+ measured against, so that a normal full day counts as one full-time
102
+ equivalent, a half day as one half, and ordered over-time as more.
103
+ """
104
+
105
+ teams: AvailableTeams
106
+ standard_hours: float
107
+
108
+ @staticmethod
109
+ def create(teams: AvailableTeams) -> '_Workforce':
110
+ """Create a workforce, deriving the standard full work day."""
111
+ hours = teams.company_work_hours.work_hours.values()
112
+ return _Workforce(teams, max(hours, default=0.0))
113
+
114
+ def _team_fte(self, team: Team, day: date) -> float:
115
+ """Return the team's effective full-time equivalent on a day.
116
+
117
+ Each member contributes the full-time equivalent it gives the
118
+ team that day, scaled by how much of a standard work day the
119
+ person actually works. Weekends, holidays and vacation make a
120
+ member contribute nothing.
121
+ """
122
+ if self.standard_hours <= 0.0:
123
+ return 0.0
124
+ company = self.teams.company_work_hours
125
+ total = 0.0
126
+ for member in team.members:
127
+ person = self.teams.persons.get(member.person_name.lower())
128
+ if person is None:
129
+ continue
130
+ fte = membership_fte_on(member, day)
131
+ if fte <= 0.0:
132
+ continue
133
+ hours = _person_hours(person, company, day)
134
+ total += fte * hours / self.standard_hours
135
+ return total
136
+
137
+ def points_on(self, team: Team, day: date) -> float:
138
+ """Return the story points the team completes on one day.
139
+
140
+ The team velocity is the story points done in one sprint at the
141
+ recorded summed full-time equivalent. It is rescaled by the
142
+ team's effective full-time equivalent on the day and spread over
143
+ the working days of a sprint.
144
+ """
145
+ if team.sprint_length <= 0 or team.sum_fte_at_velocity <= 0.0:
146
+ return 0.0
147
+ per_day = team.velocity / team.sprint_length
148
+ return per_day * self._team_fte(team, day) / team.sum_fte_at_velocity
149
+
150
+ def advance(self, team: Team, points: int,
151
+ cursor: _Cursor) -> Optional[tuple[date, _Cursor]]:
152
+ """Return the ready date and new cursor after doing some work.
153
+
154
+ The team works from the cursor, which is the day it is on and
155
+ the story points already spent on that day, so the day's leftover
156
+ capacity carries to the next item and several small items can
157
+ finish on the same day. The ready date is the day the work is
158
+ finished. Work with no story points is ready at the cursor day
159
+ and leaves the cursor unchanged. None is returned when the work
160
+ does not finish within the horizon, which means the team has no
161
+ capacity for it.
162
+ """
163
+ if points <= 0:
164
+ return cursor.day, cursor
165
+ remaining = float(points)
166
+ day, used = cursor.day, cursor.used
167
+ limit = day + _HORIZON
168
+ while day <= limit:
169
+ available = self.points_on(team, day) - used
170
+ if available > 0.0:
171
+ if remaining <= available + _EPSILON:
172
+ return day, _Cursor(day, used + remaining)
173
+ remaining -= available
174
+ day += _ONE_DAY
175
+ used = 0.0
176
+ return None
177
+
178
+
179
+ @dataclass
180
+ class _Estimator:
181
+ """Assign teams to backlog items and date the team's own work.
182
+
183
+ The estimator keeps, for each team, a cursor with the day and the
184
+ points spent that day. It dates the work a team itself does on an
185
+ item; lifting a parent's date to its children is done afterwards by
186
+ :class:`_ParentRollup`.
187
+ """
188
+
189
+ workforce: _Workforce
190
+ cursor: dict[str, _Cursor]
191
+ by_label: dict[str, Team]
192
+ stderr_file: TextIO
193
+
194
+ @staticmethod
195
+ def create(teams: AvailableTeams, start: date,
196
+ stderr_file: TextIO) -> '_Estimator':
197
+ """Create an estimator with every team free on the start date."""
198
+ cursor = {team.name: _Cursor(start, 0.0) for team in teams.teams}
199
+ by_label: dict[str, Team] = {}
200
+ for team in teams.teams:
201
+ for label in [team.name, *team.aliases]:
202
+ by_label[label.lower()] = team
203
+ return _Estimator(_Workforce.create(teams), cursor, by_label,
204
+ stderr_file)
205
+
206
+ def _warn(self, item: BacklogItem, reason: str) -> None:
207
+ """Report that an item cannot be dated and why."""
208
+ print(f'Cannot estimate {item.key!r}: {reason}', file=self.stderr_file)
209
+
210
+ def _earliest_team(self) -> Optional[Team]:
211
+ """Return the team that becomes free earliest, or None."""
212
+ teams = self.workforce.teams.teams
213
+ if not teams:
214
+ return None
215
+ best = teams[0]
216
+ for team in teams[1:]:
217
+ if self.cursor[team.name] < self.cursor[best.name]:
218
+ best = team
219
+ return best
220
+
221
+ def _team_for(self, item: BacklogItem) -> Optional[Team]:
222
+ """Return the team that works the item, or None when unknown."""
223
+ if item.team is None:
224
+ team = self._earliest_team()
225
+ if team is None:
226
+ self._warn(item, 'no team is available')
227
+ return team
228
+ team = self.by_label.get(item.team.lower())
229
+ if team is None:
230
+ self._warn(item, f'team {item.team!r} is not in the workforce')
231
+ return team
232
+
233
+ def own_date(self, item: BacklogItem) -> Optional[date]:
234
+ """Return the date the team finishes the item's own work.
235
+
236
+ Done and rejected items consume no team time and get no date.
237
+ Other items are worked by their assigned team, or by the team
238
+ that is free earliest, from where that team's cursor stands. When
239
+ the team has no capacity for the item, or no team is available,
240
+ the item gets no date and a warning is reported.
241
+ """
242
+ if item.status in (Status.DONE, Status.REJECTED):
243
+ return None
244
+ team = self._team_for(item)
245
+ if team is None:
246
+ return None
247
+ position = self.cursor[team.name]
248
+ result = self.workforce.advance(team, item.story_points, position)
249
+ if result is None:
250
+ self._warn(item, f'team {team.name!r} has no capacity for it')
251
+ return None
252
+ ready, moved = result
253
+ self.cursor[team.name] = moved
254
+ return ready
255
+
256
+
257
+ @dataclass
258
+ class _ParentRollup:
259
+ """Lift each parent's date to be no earlier than its children.
260
+
261
+ A parent cannot be ready before its latest child, even though the
262
+ work on the parent itself may be scheduled earlier. The effective
263
+ date of an item is therefore the latest of its own date and the
264
+ effective dates of its children, found recursively. Done and
265
+ rejected items keep no date and never delay their parent.
266
+ """
267
+
268
+ children: dict[str, list[str]]
269
+ own: dict[str, Optional[date]]
270
+ status: dict[str, Status]
271
+ memo: dict[str, Optional[date]]
272
+ active: set[str]
273
+
274
+ @staticmethod
275
+ def create(backlog: Backlog, own: dict[str, Optional[date]],
276
+ status: dict[str, Status]) -> '_ParentRollup':
277
+ """Create a rollup, grouping the item keys by their parent key."""
278
+ children: dict[str, list[str]] = {}
279
+ for item in backlog:
280
+ if item.parent_key is not None:
281
+ children.setdefault(item.parent_key, []).append(item.key)
282
+ return _ParentRollup(children, own, status, {}, set())
283
+
284
+ def effective(self, key: str) -> Optional[date]:
285
+ """Return the effective ready date of one item key."""
286
+ if key in self.memo:
287
+ return self.memo[key]
288
+ if self.status.get(key) in (Status.DONE, Status.REJECTED):
289
+ self.memo[key] = None
290
+ return None
291
+ if key in self.active:
292
+ return self.own.get(key)
293
+ self.active.add(key)
294
+ dates = [self.own.get(key)]
295
+ dates += [self.effective(child)
296
+ for child in self.children.get(key, [])]
297
+ self.active.discard(key)
298
+ known = [day for day in dates if day is not None]
299
+ result = max(known) if known else None
300
+ self.memo[key] = result
301
+ return result
302
+
303
+
304
+ def estimate_ready_date(backlog: Backlog, available_teams: AvailableTeams,
305
+ start_date: Optional[date] = None,
306
+ stderr_file: TextIO = sys.stderr) -> Backlog:
307
+ """Estimate the ready date of backlog items.
308
+
309
+ The teams start working on the start date, which defaults to today
310
+ when None is given. The backlog items are worked in their given
311
+ order. Each item is worked by its assigned team, or, when it names
312
+ no team, by the team that becomes free earliest. Only one team works
313
+ an item, and a team works one item at a time, in backlog order. When
314
+ a team's daily capacity covers more than one item, several items
315
+ finish on the same day, and the next item carries on from the
316
+ leftover capacity of the day the current one finished.
317
+
318
+ The story points an item still needs are turned into calendar time
319
+ from the team's velocity, rescaled by the team's effective capacity
320
+ on each day. That capacity follows every member's full-time
321
+ equivalent and actual work hours, so weekends, company holidays,
322
+ personal vacation, learning periods and ordered over-time all change
323
+ the pace. A standard work day is the longest day in the company
324
+ weekly schedule. The story points of TODO and IN_PROGRESS items are
325
+ all treated as still left to do; DONE and REJECTED items need no work
326
+ and get no estimated date. See also the Status enum.
327
+
328
+ A parent's estimated date is lifted to be no earlier than its latest
329
+ child's, applied through the whole hierarchy, because a parent cannot
330
+ be ready before its children even though work on the parent itself
331
+ may be scheduled earlier. A finished child does not delay its parent.
332
+
333
+ Dependencies between items are not considered; the backlog is assumed
334
+ to be ordered so that the teams can work the items in order. When an
335
+ item names a team that is not in the workforce, when no team is
336
+ available, or when the chosen team has no capacity for the item, the
337
+ item gets no estimated date and a warning is reported.
338
+
339
+ Args:
340
+ backlog: The backlog to estimate the ready date of. The argument
341
+ is not modified. The backlog must be ordered so that the
342
+ teams can work the items in order.
343
+ available_teams: The available teams used to estimate the ready
344
+ date, including absence, velocity and work hours.
345
+ start_date: The day the teams start working, or None for today.
346
+ stderr_file: The file to report warnings to.
347
+
348
+ Returns:
349
+ A new backlog whose items carry the estimated ready date. The
350
+ other fields are copied unchanged from the given items.
351
+ """
352
+ start = date.today() if start_date is None else start_date
353
+ estimator = _Estimator.create(available_teams, start, stderr_file)
354
+ own = {item.key: estimator.own_date(item) for item in backlog}
355
+ status = {item.key: item.status for item in backlog}
356
+ rollup = _ParentRollup.create(backlog, own, status)
357
+ return [replace(item, estimated_ready_date=rollup.effective(item.key))
358
+ for item in backlog]
359
+
360
+
361
+ def set_plan_from_estimate(backlog: Backlog,
362
+ stderr_file: TextIO = sys.stderr) -> Backlog:
363
+ """Set the planned ready dates from the estimated ready dates.
364
+
365
+ For each backlog item the planned ready date is set to the estimated
366
+ ready date, copying None when the estimated ready date is None.
367
+
368
+ Args:
369
+ backlog: The backlog to set the planned ready dates of. The
370
+ argument is not modified.
371
+ stderr_file: The file to report errors to.
372
+
373
+ Returns:
374
+ A new backlog whose items carry the planned ready date taken from
375
+ the estimated ready date. The other fields are copied unchanged.
376
+ """
377
+ _ = stderr_file
378
+ return [replace(item, planned_ready_date=item.estimated_ready_date)
379
+ for item in backlog]
@@ -0,0 +1,73 @@
1
+ #! /usr/local/bin/python3
2
+ """Rules for formatting table data."""
3
+
4
+ # Copyright (c) 2026 Tom Björkholm
5
+ # MIT License
6
+
7
+ from dataclasses import dataclass, field
8
+ from tableio import Fmt, Color, TableBorderStyle
9
+ from backlogops.backlog import Status
10
+
11
+
12
+ def default_status_format() -> dict[Status, Fmt]:
13
+ """Return the default format specification for the status column."""
14
+ return {
15
+ Status.TODO: Fmt(),
16
+ Status.IN_PROGRESS: Fmt(),
17
+ Status.DONE: Fmt(italic=True, highlight=Color.GREEN),
18
+ Status.REJECTED: Fmt(italic=True, highlight=Color.RED)}
19
+
20
+
21
+ @dataclass
22
+ class FormatRules: # pylint: disable=too-many-instance-attributes
23
+ """Rules for formatting table data."""
24
+
25
+ backlog_first: bool = True
26
+ """Whether to write the backlog before the releases."""
27
+
28
+ border_style: TableBorderStyle = \
29
+ TableBorderStyle.OUTER_FIRST_ROW_THICK_INNER_THIN
30
+ """The border style to apply to the written table."""
31
+
32
+ filtered_data_range: bool = True
33
+ """Whether to mark the written data as a filtered data range."""
34
+
35
+ first_row_format: Fmt = Fmt(bold=True)
36
+ """The format specification for the column names row."""
37
+
38
+ status_format: dict[Status, Fmt] = \
39
+ field(default_factory=default_status_format)
40
+ """The format specification for the status column."""
41
+
42
+ estimate_late: Fmt = Fmt(bold=True, highlight=Color.RED)
43
+ """The format for estimate values if later than planned."""
44
+
45
+ estimate_early: Fmt = Fmt()
46
+ """The format for estimate values if earlier than planned."""
47
+
48
+ estimate_eq_planned: Fmt = Fmt()
49
+ """The format for estimate values if equal to planned."""
50
+
51
+ def get_status_format(self, status: Status) -> Fmt:
52
+ """Return the format for a status."""
53
+ return self.status_format.get(status, Fmt())
54
+
55
+ def turn_off_cell_format(self) -> None:
56
+ """Turn off all cell formatting.
57
+
58
+ Make all cells plain, without any formatting.
59
+ This does not affect the border style or the filtered data range.
60
+ """
61
+ self.first_row_format = Fmt()
62
+ self.status_format = {status: Fmt() for status in Status}
63
+ self.estimate_late = Fmt()
64
+ self.estimate_early = Fmt()
65
+ self.estimate_eq_planned = Fmt()
66
+
67
+ def cell_format_used(self) -> bool:
68
+ """Return True if any cell formatting is used."""
69
+ return any([self.first_row_format != Fmt(),
70
+ any(fmt != Fmt() for fmt in self.status_format.values()),
71
+ self.estimate_late != Fmt(),
72
+ self.estimate_early != Fmt(),
73
+ self.estimate_eq_planned != Fmt()])