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 ADDED
@@ -0,0 +1,87 @@
1
+ #! /usr/local/bin/python3
2
+ """Library with backlog operations.
3
+
4
+ This package provides the data model for a backlog, the available
5
+ workforce and the calendar, together with the operations that build and
6
+ validate them. The names an application programmer is most likely to use
7
+ are re-exported here, so that they can be imported directly from
8
+ ``backlogops``.
9
+ """
10
+
11
+ # Copyright (c) 2026, Tom Björkholm
12
+ # MIT License
13
+
14
+ from backlogops.backlog import (
15
+ Backlog, BacklogItem, Status, get_backlog, get_backlog_item,
16
+ check_backlog_consistency, build_dependency_graph, item_dependency_edges,
17
+ event_start, event_finish)
18
+ from backlogops.backlog_helpers import find_cycle
19
+ from backlogops.levels import (
20
+ Level, Levels, DEFAULT_LEVELS, check_levels_consistency,
21
+ level_number_from_name)
22
+ from backlogops.person import Person
23
+ from backlogops.team import FteException, Membership, Team
24
+ from backlogops.releases import Release, Releases, get_release, get_releases
25
+ from backlogops.backlog_releases import BacklogReleases
26
+ from backlogops.demo_backlog import get_demo_backlog
27
+ from backlogops.available_teams import AvailableTeams
28
+ from backlogops.available_teams_config import (
29
+ AvailableTeamsConfig, read_available_teams, write_available_teams,
30
+ get_available_teams)
31
+ from backlogops.order_by_dependencies import (
32
+ order_by_dependencies, DependencyMode)
33
+ from backlogops.estimate_ready_date import (
34
+ estimate_ready_date, set_plan_from_estimate)
35
+ from backlogops.release_backlog_updates import (
36
+ ReleaseChange, ReleaseChanges, ReleaseDateChange, ReleaseDateChanges,
37
+ BacklogReleaseChange, ReleasesAndDateChanges, estimate_release_dates,
38
+ release_plan_on_estimate, adjust_release_content)
39
+ from backlogops.release_change_io import (
40
+ format_content_changes, format_date_changes, write_content_changes,
41
+ write_date_changes)
42
+ from backlogops.io_config import (
43
+ InputFormatConfig, OutputFormatConfig, resolve_input_config,
44
+ resolve_output_config, make_input_config, make_output_config)
45
+ from backlogops.backlog_releases_io import (
46
+ read_backlog_releases, write_backlog_releases)
47
+ from backlogops.table_rows import (
48
+ item_to_row, row_to_item, release_to_row, row_to_release)
49
+ from backlogops.format_rules import FormatRules
50
+ from backlogops.apply_format_rules import format_backlog, format_releases
51
+ from backlogops.move_keys_first import move_keys_first, get_keys_in_order
52
+ from backlogops.key_list_io import read_key_list, write_key_list
53
+ from backlogops.available_teams_wizard import (
54
+ available_teams_wizard, teams_config_wizard, YesNoUiBridge)
55
+ from backlogops.console_yes_no_bridge import ConsoleYesNoUiBridge
56
+ from backlogops.work_hours import (
57
+ WeekDay, ScheduleWorkHours, DEFAULT_WORK_WEEK, ExceptionWorkHours,
58
+ CompanyWorkHours)
59
+ from backlogops.date_ranges import check_date_range, check_no_overlap
60
+ from backlogops.no_text_io import NoTextIO
61
+
62
+ __all__ = [
63
+ 'Backlog', 'BacklogItem', 'Status', 'get_backlog', 'get_backlog_item',
64
+ 'check_backlog_consistency', 'build_dependency_graph',
65
+ 'item_dependency_edges', 'event_start', 'event_finish', 'find_cycle',
66
+ 'Level', 'Levels', 'DEFAULT_LEVELS', 'check_levels_consistency',
67
+ 'level_number_from_name', 'Person', 'FteException', 'Membership', 'Team',
68
+ 'Release', 'Releases', 'get_release', 'get_releases', 'BacklogReleases',
69
+ 'get_demo_backlog',
70
+ 'AvailableTeams', 'AvailableTeamsConfig', 'read_available_teams',
71
+ 'write_available_teams', 'get_available_teams', 'order_by_dependencies',
72
+ 'DependencyMode', 'InputFormatConfig', 'OutputFormatConfig',
73
+ 'resolve_input_config', 'resolve_output_config', 'make_input_config',
74
+ 'make_output_config', 'read_backlog_releases', 'write_backlog_releases',
75
+ 'item_to_row', 'row_to_item', 'release_to_row', 'row_to_release',
76
+ 'FormatRules', 'format_backlog', 'format_releases',
77
+ 'estimate_ready_date', 'set_plan_from_estimate',
78
+ 'ReleaseChange', 'ReleaseChanges', 'ReleaseDateChange',
79
+ 'ReleaseDateChanges', 'BacklogReleaseChange', 'ReleasesAndDateChanges',
80
+ 'estimate_release_dates', 'release_plan_on_estimate',
81
+ 'adjust_release_content', 'format_content_changes', 'format_date_changes',
82
+ 'write_content_changes', 'write_date_changes',
83
+ 'move_keys_first', 'get_keys_in_order', 'read_key_list', 'write_key_list',
84
+ 'available_teams_wizard', 'teams_config_wizard', 'YesNoUiBridge',
85
+ 'ConsoleYesNoUiBridge', 'WeekDay',
86
+ 'ScheduleWorkHours', 'DEFAULT_WORK_WEEK', 'ExceptionWorkHours',
87
+ 'CompanyWorkHours', 'check_date_range', 'check_no_overlap', 'NoTextIO']
@@ -0,0 +1,95 @@
1
+ #! /usr/local/bin/python3
2
+ """Apply format rules to backlog and release table data."""
3
+
4
+ # Copyright (c) 2026 Tom Björkholm
5
+ # MIT License
6
+
7
+ from datetime import date
8
+ from typing import Optional
9
+ from tableio import DictData, Fmt, ValueFmt
10
+ from backlogops.backlog import Backlog, BacklogItem
11
+ from backlogops.releases import Release, Releases
12
+ from backlogops.format_rules import FormatRules
13
+ from backlogops.table_rows import item_to_row, release_to_row
14
+
15
+
16
+ def _estimate_format(estimated: Optional[date], planned: Optional[date],
17
+ rules: FormatRules) -> Fmt:
18
+ """Return the estimate-cell format from estimated versus planned date.
19
+
20
+ A missing estimated or planned date leaves the cell unformatted, as
21
+ there is then nothing to compare.
22
+ """
23
+ if estimated is None or planned is None:
24
+ return Fmt()
25
+ if estimated > planned:
26
+ return rules.estimate_late
27
+ if estimated < planned:
28
+ return rules.estimate_early
29
+ return rules.estimate_eq_planned
30
+
31
+
32
+ def _item_cell_format(name: str, item: BacklogItem, estimate: Fmt,
33
+ rules: FormatRules) -> Fmt:
34
+ """Return the format for one backlog cell named by its field."""
35
+ if name == 'status':
36
+ return rules.get_status_format(item.status)
37
+ if name == 'estimated_ready_date':
38
+ return estimate
39
+ return Fmt()
40
+
41
+
42
+ def _format_item(item: BacklogItem, rules: FormatRules) -> dict[str, ValueFmt]:
43
+ """Return one backlog item as a formatted row of cells."""
44
+ estimate = _estimate_format(item.estimated_ready_date,
45
+ item.planned_ready_date, rules)
46
+ return {name: ValueFmt(value=value,
47
+ fmt=_item_cell_format(name, item, estimate, rules))
48
+ for name, value in item_to_row(item).items()}
49
+
50
+
51
+ def format_backlog(backlog: Backlog,
52
+ format_rules: FormatRules) -> DictData[ValueFmt]:
53
+ """Format the backlog according to the format rules.
54
+
55
+ Each backlog item becomes one row of formatted cells, keyed by the
56
+ internal field name. The status cell is formatted by its status, and
57
+ the estimated-ready-date cell by its relation to the planned-ready
58
+ date; all other cells are left unformatted.
59
+
60
+ Args:
61
+ backlog: The backlog to format.
62
+ format_rules: The format rules to apply.
63
+
64
+ Returns:
65
+ The formatted backlog rows, ready for TableIO.
66
+ """
67
+ return [_format_item(item, format_rules) for item in backlog]
68
+
69
+
70
+ def _format_release(release: Release,
71
+ rules: FormatRules) -> dict[str, ValueFmt]:
72
+ """Return one release as a formatted row of cells."""
73
+ estimate = _estimate_format(release.estimated_date, release.planned_date,
74
+ rules)
75
+ return {name: ValueFmt(value=value,
76
+ fmt=estimate if name == 'estimated_date' else Fmt())
77
+ for name, value in release_to_row(release).items()}
78
+
79
+
80
+ def format_releases(releases: Releases,
81
+ format_rules: FormatRules) -> DictData[ValueFmt]:
82
+ """Format the releases according to the format rules.
83
+
84
+ Each release becomes one row of formatted cells, keyed by the internal
85
+ field name. The estimated-date cell is formatted by its relation to the
86
+ planned date; the other cells are left unformatted.
87
+
88
+ Args:
89
+ releases: The releases to format.
90
+ format_rules: The format rules to apply.
91
+
92
+ Returns:
93
+ The formatted release rows, ready for TableIO.
94
+ """
95
+ return [_format_release(release, format_rules) for release in releases]
@@ -0,0 +1,205 @@
1
+ #! /usr/local/bin/python3
2
+ """Define the available workforce: persons and teams."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, timedelta
10
+ from typing import TextIO
11
+ from backlogops.backlog_helpers import check_field_types, report_bad_value
12
+ from backlogops.backlog_helpers import report_unknown_reference
13
+ from backlogops.date_ranges import check_no_overlap
14
+ from backlogops.person import Person
15
+ from backlogops.team import Membership, Team
16
+ from backlogops.work_hours import CompanyWorkHours
17
+
18
+
19
+ def membership_fte_on(membership: Membership, day: date) -> float:
20
+ """Return the full-time equivalent of a membership on a given day.
21
+
22
+ Days outside the membership date range give 0.0. A day covered by an
23
+ fte_exception gives that exception's full-time equivalent. Otherwise
24
+ the membership's base full-time equivalent applies.
25
+
26
+ Args:
27
+ membership: The membership to evaluate.
28
+ day: The day to evaluate the membership on.
29
+
30
+ Returns:
31
+ The full-time equivalent the person gives to the team on the day.
32
+ """
33
+ if membership.start_date is not None and day < membership.start_date:
34
+ return 0.0
35
+ if membership.end_date is not None and day > membership.end_date:
36
+ return 0.0
37
+ for exception in membership.fte_exceptions:
38
+ if exception.start_date <= day <= exception.end_date:
39
+ return exception.fte
40
+ return membership.fte
41
+
42
+
43
+ def candidate_days(memberships: list[Membership]) -> set[date]:
44
+ """Return the days where the summed full-time equivalent can change.
45
+
46
+ The summed full-time equivalent is constant between the start and end
47
+ boundaries of the memberships and their fte_exceptions, so checking
48
+ those boundary days is enough to find its maximum. When there are no
49
+ boundaries (all memberships are fully open) a single day is returned,
50
+ on which every membership contributes its base full-time equivalent.
51
+
52
+ Args:
53
+ memberships: The memberships of one person across all teams.
54
+
55
+ Returns:
56
+ The set of days on which to evaluate the summed full-time
57
+ equivalent.
58
+ """
59
+ days: set[date] = set()
60
+ for membership in memberships:
61
+ if membership.start_date is not None:
62
+ days.add(membership.start_date)
63
+ if membership.end_date is not None:
64
+ days.add(membership.end_date + timedelta(days=1))
65
+ for exception in membership.fte_exceptions:
66
+ days.add(exception.start_date)
67
+ days.add(exception.end_date + timedelta(days=1))
68
+ return days or {date.min}
69
+
70
+
71
+ def check_person_capacity(person_name: str, memberships: list[Membership],
72
+ stderr_file: TextIO = sys.stderr) -> None:
73
+ """Check a person is not allocated more than full time on any day.
74
+
75
+ The summed full-time equivalent over all of the person's memberships
76
+ is evaluated on every boundary day and must not exceed 1.0.
77
+
78
+ Args:
79
+ person_name: The name of the person, for error messages.
80
+ memberships: The memberships of the person across all teams.
81
+ stderr_file: The file to report errors to.
82
+
83
+ Raises:
84
+ ValueError: If the summed full-time equivalent exceeds 1.0 on any
85
+ day.
86
+ """
87
+ for day in candidate_days(memberships):
88
+ total: float = sum((membership_fte_on(m, day) for m in memberships),
89
+ 0.0)
90
+ if total > 1.0 + 1e-9:
91
+ report_bad_value('fte', total,
92
+ f'person {person_name!r} is allocated {total} '
93
+ f'FTE (more than 1.0) on {day.isoformat()}',
94
+ stderr_file, 'Person')
95
+
96
+
97
+ @dataclass
98
+ class AvailableTeams:
99
+ """Define the available workforce that can do work.
100
+
101
+ The persons registry holds every person once, keyed by the lower-case
102
+ person name, so that personal availability is entered in a single
103
+ place. Teams reference their members by person name into this
104
+ registry. This lets a person move between teams or split time across
105
+ teams without duplicating the person.
106
+
107
+ Fields:
108
+ persons: The registry of persons, keyed by lower-case person name.
109
+ teams: The list of teams that are available to do work.
110
+ company_work_hours: The company work hours that apply to everyone.
111
+ """
112
+
113
+ persons: dict[str, Person]
114
+ teams: list[Team]
115
+ company_work_hours: CompanyWorkHours = field(
116
+ default_factory=CompanyWorkHours)
117
+
118
+ def _check_persons(self, stderr_file: TextIO) -> None:
119
+ """Check each person's key, name and work hour exceptions."""
120
+ for key, person in self.persons.items():
121
+ check_field_types(person, stderr_file, 'Person')
122
+ if person.name == '':
123
+ report_bad_value('name', person.name, 'must not be empty',
124
+ stderr_file, 'Person')
125
+ if key != person.name.lower():
126
+ report_bad_value('persons', key,
127
+ f'key does not match person name '
128
+ f'{person.name!r}', stderr_file,
129
+ 'Available teams')
130
+ for exception in person.exceptions:
131
+ exception.check_consistency(stderr_file)
132
+ check_no_overlap('exceptions',
133
+ [(e.start_date, e.end_date)
134
+ for e in person.exceptions], stderr_file,
135
+ 'Person')
136
+
137
+ def _add_team_label(self, label: str, seen_labels: dict[str, str],
138
+ stderr_file: TextIO) -> None:
139
+ """Add one team label and reject a case-insensitive duplicate."""
140
+ if label == '':
141
+ report_bad_value('aliases', label, 'must not be empty',
142
+ stderr_file, 'Team')
143
+ lowered = label.lower()
144
+ if lowered in seen_labels:
145
+ report_bad_value('name', label,
146
+ f'duplicates team label '
147
+ f'{seen_labels[lowered]!r} (case-insensitive)',
148
+ stderr_file, 'Team')
149
+ seen_labels[lowered] = label
150
+
151
+ def _check_teams(self, stderr_file: TextIO) -> None:
152
+ """Check every team and that names and aliases are unique."""
153
+ seen_labels: dict[str, str] = {}
154
+ for team in self.teams:
155
+ team.check_consistency(stderr_file)
156
+ for label in [team.name, *team.aliases]:
157
+ self._add_team_label(label, seen_labels, stderr_file)
158
+
159
+ def _check_member_refs(self, stderr_file: TextIO) -> None:
160
+ """Check each membership references a known person."""
161
+ for team in self.teams:
162
+ for member in team.members:
163
+ if member.person_name.lower() not in self.persons:
164
+ report_unknown_reference('person_name', team.name,
165
+ member.person_name, stderr_file,
166
+ 'Team')
167
+
168
+ def _memberships_by_person(self) -> dict[str, list[Membership]]:
169
+ """Group memberships across all teams by lower-case person name."""
170
+ result: dict[str, list[Membership]] = {}
171
+ for team in self.teams:
172
+ for member in team.members:
173
+ key = member.person_name.lower()
174
+ result.setdefault(key, []).append(member)
175
+ return result
176
+
177
+ def _check_capacity(self, stderr_file: TextIO) -> None:
178
+ """Check no person is allocated more than full time on any day."""
179
+ for person_name, memberships in self._memberships_by_person().items():
180
+ check_person_capacity(person_name, memberships, stderr_file)
181
+
182
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
183
+ """Check the consistency of the available workforce.
184
+
185
+ Field types are verified, the company work hours and every person
186
+ and team are checked, team names and aliases are checked to be
187
+ unique case-insensitively across all teams, every membership is
188
+ checked to reference a known person, and no person is allocated
189
+ more than full time on any day.
190
+
191
+ Args:
192
+ stderr_file: The file to report errors to.
193
+
194
+ Raises:
195
+ TypeError: If a field has the wrong type.
196
+ ValueError: If a field value violates a constraint, if team
197
+ labels are not unique, or if a person is over-allocated.
198
+ KeyError: If a membership references an unknown person.
199
+ """
200
+ check_field_types(self, stderr_file, 'Available teams')
201
+ self.company_work_hours.check_consistency(stderr_file)
202
+ self._check_persons(stderr_file)
203
+ self._check_teams(stderr_file)
204
+ self._check_member_refs(stderr_file)
205
+ self._check_capacity(stderr_file)