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,125 @@
1
+ #! /usr/local/bin/python3
2
+ """Convert backlog items and releases to and from table rows.
3
+
4
+ A backlog item or a release is represented in a table as one row keyed by
5
+ its internal field name. These conversions are shared by the file IO and
6
+ by the formatting of table data, so they live in their own module to keep
7
+ the dependency order between those parts simple.
8
+ """
9
+
10
+ # Copyright (c) 2026, Tom Björkholm
11
+ # MIT License
12
+
13
+ import sys
14
+ from collections.abc import Mapping
15
+ from dataclasses import fields
16
+ from datetime import date
17
+ from typing import Optional, TextIO
18
+ from tableio import Value
19
+ from backlogops.backlog import BacklogItem, DEPENDENCY_FIELDS, Status, \
20
+ get_backlog_item
21
+ from backlogops.levels import Levels
22
+ from backlogops.releases import Release, get_release
23
+
24
+ BACKLOG_FIELDS = [item_field.name for item_field in fields(BacklogItem)
25
+ if item_field.name != 'extra_fields']
26
+ """Internal backlog column names, in a stable write order."""
27
+
28
+ RELEASE_FIELDS = [item_field.name for item_field in fields(Release)]
29
+ """Internal release column names, in a stable write order."""
30
+
31
+
32
+ def _is_empty(value: object) -> bool:
33
+ """Return whether a cell value should be treated as absent."""
34
+ return value is None or value == ''
35
+
36
+
37
+ def _date_cell(value: Optional[date]) -> Value:
38
+ """Return a date as an ISO string cell, or None when absent."""
39
+ return value.isoformat() if value is not None else None
40
+
41
+
42
+ def _cell_from_field(name: str, value: object) -> Value:
43
+ """Return the cell value for one named backlog item field."""
44
+ if name == 'status':
45
+ assert isinstance(value, Status)
46
+ return value.name
47
+ if name in DEPENDENCY_FIELDS:
48
+ assert isinstance(value, list)
49
+ return ' '.join(value)
50
+ if isinstance(value, date):
51
+ return value.isoformat()
52
+ assert value is None or isinstance(value, (str, int, float, bool))
53
+ return value
54
+
55
+
56
+ def _extra_cell(value: object) -> Value:
57
+ """Return an extra field value as a cell value."""
58
+ if isinstance(value, date):
59
+ return value.isoformat()
60
+ if value is None or isinstance(value, (str, int, float, bool)):
61
+ return value
62
+ return str(value)
63
+
64
+
65
+ def item_to_row(item: BacklogItem) -> dict[str, Value]:
66
+ """Return one backlog item as a row keyed by internal field name."""
67
+ row: dict[str, Value] = {}
68
+ for name in BACKLOG_FIELDS:
69
+ row[name] = _cell_from_field(name, getattr(item, name))
70
+ for key, value in item.extra_fields.items():
71
+ row[key] = _extra_cell(value)
72
+ return row
73
+
74
+
75
+ def release_to_row(release: Release) -> dict[str, Value]:
76
+ """Return one release as a row keyed by internal field name."""
77
+ return {'name': release.name,
78
+ 'planned_date': _date_cell(release.planned_date),
79
+ 'estimated_date': _date_cell(release.estimated_date)}
80
+
81
+
82
+ def _split_deps(value: object) -> list[str]:
83
+ """Return the dependency keys parsed from one space separated cell."""
84
+ if value is None:
85
+ return []
86
+ text = str(value).strip()
87
+ return text.split() if text else []
88
+
89
+
90
+ def _maybe_int(value: object) -> object:
91
+ """Return an integer when a numeric cell should be one, else the value."""
92
+ if isinstance(value, bool):
93
+ return value
94
+ if isinstance(value, float) and value.is_integer():
95
+ return int(value)
96
+ if isinstance(value, str):
97
+ try:
98
+ return int(value.strip())
99
+ except ValueError:
100
+ return value
101
+ return value
102
+
103
+
104
+ def _present_cells(row: Mapping[str, object]) -> dict[str, object]:
105
+ """Return the row without cells that are absent (None or empty)."""
106
+ return {key: value for key, value in row.items() if not _is_empty(value)}
107
+
108
+
109
+ def row_to_item(row: Mapping[str, object], levels: Optional[Levels] = None,
110
+ stderr_file: TextIO = sys.stderr) -> BacklogItem:
111
+ """Return a backlog item from a row keyed by internal field name."""
112
+ prepared = _present_cells(row)
113
+ for name in DEPENDENCY_FIELDS:
114
+ if name in prepared:
115
+ prepared[name] = _split_deps(row[name])
116
+ for name in ('story_points', 'level'):
117
+ if name in prepared:
118
+ prepared[name] = _maybe_int(row[name])
119
+ return get_backlog_item(prepared, levels, stderr_file)
120
+
121
+
122
+ def row_to_release(row: Mapping[str, object],
123
+ stderr_file: TextIO = sys.stderr) -> Release:
124
+ """Return a release from a row keyed by internal field name."""
125
+ return get_release(_present_cells(row), stderr_file, strict=False)
backlogops/team.py ADDED
@@ -0,0 +1,190 @@
1
+ #! /usr/local/bin/python3
2
+ """Define a team, its memberships and their availability over time."""
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
10
+ from typing import Optional, TextIO
11
+ from backlogops.backlog_helpers import check_field_types, report_bad_value
12
+ from backlogops.date_ranges import check_date_range, check_no_overlap
13
+
14
+
15
+ @dataclass
16
+ class FteException:
17
+ """Define a full-time equivalent exception.
18
+
19
+ The full-time equivalent exception is used to override the default
20
+ full-time equivalent for a specific period. This can be used to mark
21
+ a learning period for a new team member, or a period of time when the
22
+ team member works part-time outside of this team.
23
+
24
+ Fields:
25
+ start_date: The first day of the exception (inclusive).
26
+ end_date: The last day of the exception (inclusive). Must not be
27
+ before start_date.
28
+ fte: The full-time equivalent during the exception. Must not be
29
+ negative.
30
+ """
31
+
32
+ start_date: date
33
+ end_date: date
34
+ fte: float
35
+
36
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
37
+ """Check the consistency of the full-time equivalent exception.
38
+
39
+ Field types are verified, the date range must be non-empty, and
40
+ the full-time equivalent must not be negative.
41
+
42
+ Args:
43
+ stderr_file: The file to report errors to.
44
+
45
+ Raises:
46
+ TypeError: If a field has the wrong type.
47
+ ValueError: If the range is empty or the fte is negative.
48
+ """
49
+ check_field_types(self, stderr_file, 'FTE exception')
50
+ check_date_range('fte exception', self.start_date, self.end_date,
51
+ stderr_file, 'FTE exception')
52
+ if self.fte < 0.0:
53
+ report_bad_value('fte', self.fte, 'must not be negative',
54
+ stderr_file, 'FTE exception')
55
+
56
+
57
+ @dataclass
58
+ class Membership:
59
+ """Define how a person belongs to a team over a period of time.
60
+
61
+ A membership links a person, by name, to the team that holds it. The
62
+ person name is looked up in the central person registry of
63
+ :class:`~backlogops.available_teams.AvailableTeams`. A person may have
64
+ several memberships, in the same or in different teams, which models a
65
+ person moving between teams or splitting time across teams over time.
66
+
67
+ Fields:
68
+ person_name: The name of the person, used as a key into the
69
+ person registry. Compared case-insensitively. Must
70
+ not be empty.
71
+ fte: The full-time equivalent the person gives to this team
72
+ outside of any fte_exceptions. 1.0 means full time. Must not
73
+ be negative.
74
+ start_date: The first day of the membership (inclusive), or None
75
+ for a membership that is open at the start.
76
+ end_date: The last day of the membership (inclusive), or None for
77
+ a membership that is open at the end.
78
+ fte_exceptions: Periods with a full-time equivalent that differs
79
+ from fte, for example a learning period or a period
80
+ of part-time work in another team. The periods must
81
+ not overlap.
82
+ """
83
+
84
+ person_name: str
85
+ fte: float = 1.0
86
+ start_date: Optional[date] = None
87
+ end_date: Optional[date] = None
88
+ fte_exceptions: list[FteException] = field(default_factory=list)
89
+
90
+ def _check_values(self, stderr_file: TextIO) -> None:
91
+ """Check the person name, full-time equivalent and date range."""
92
+ if self.person_name == '':
93
+ report_bad_value('person_name', self.person_name,
94
+ 'must not be empty', stderr_file, 'Membership')
95
+ if self.fte < 0.0:
96
+ report_bad_value('fte', self.fte, 'must not be negative',
97
+ stderr_file, 'Membership')
98
+ if self.start_date is not None and self.end_date is not None:
99
+ check_date_range('membership', self.start_date, self.end_date,
100
+ stderr_file, 'Membership')
101
+
102
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
103
+ """Check the consistency of the membership.
104
+
105
+ Field types are verified, the person name must not be empty, the
106
+ full-time equivalent must not be negative, the membership date
107
+ range (when both ends are given) must be non-empty, every
108
+ fte_exception must be consistent, and the fte_exceptions must not
109
+ overlap.
110
+
111
+ Args:
112
+ stderr_file: The file to report errors to.
113
+
114
+ Raises:
115
+ TypeError: If a field has the wrong type.
116
+ ValueError: If a value is invalid or two fte_exceptions
117
+ overlap.
118
+ """
119
+ check_field_types(self, stderr_file, 'Membership')
120
+ self._check_values(stderr_file)
121
+ for exception in self.fte_exceptions:
122
+ exception.check_consistency(stderr_file)
123
+ check_no_overlap('fte_exceptions',
124
+ [(e.start_date, e.end_date)
125
+ for e in self.fte_exceptions], stderr_file,
126
+ 'Membership')
127
+
128
+
129
+ @dataclass
130
+ class Team:
131
+ """Define a team.
132
+
133
+ Fields:
134
+ name: The name of the team. Compared case-insensitively. Must be
135
+ unique across all teams and must not be empty.
136
+ velocity: The velocity of the team. Must not be negative.
137
+ sum_fte_at_velocity: The sum of the full-time equivalents of the
138
+ team members when velocity was measured. Used
139
+ to rescale the velocity when the team capacity
140
+ changes. Must be positive.
141
+ sprint_length: The length of the sprint in working days. Must be
142
+ positive.
143
+ aliases: The aliases for the team. A backlog might refer to the
144
+ team using the team name or an alias. Compared
145
+ case-insensitively. Each alias must be unique and not
146
+ empty.
147
+ members: The list of memberships of the team.
148
+ """
149
+
150
+ name: str
151
+ velocity: float
152
+ sum_fte_at_velocity: float
153
+ sprint_length: int
154
+ aliases: list[str] = field(default_factory=list)
155
+ members: list[Membership] = field(default_factory=list)
156
+
157
+ def _check_values(self, stderr_file: TextIO) -> None:
158
+ """Check the name, velocity, capacity and sprint length."""
159
+ if self.name == '':
160
+ report_bad_value('name', self.name, 'must not be empty',
161
+ stderr_file, 'Team')
162
+ if self.velocity < 0.0:
163
+ report_bad_value('velocity', self.velocity, 'must not be negative',
164
+ stderr_file, 'Team')
165
+ if self.sum_fte_at_velocity <= 0.0:
166
+ report_bad_value('sum_fte_at_velocity', self.sum_fte_at_velocity,
167
+ 'must be positive', stderr_file, 'Team')
168
+ if self.sprint_length <= 0:
169
+ report_bad_value('sprint_length', self.sprint_length,
170
+ 'must be positive', stderr_file, 'Team')
171
+
172
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
173
+ """Check the consistency of the team.
174
+
175
+ Field types are verified, the numeric fields must be within their
176
+ documented ranges, and every membership must be consistent.
177
+ Uniqueness of the name and aliases across teams is checked by
178
+ :meth:`AvailableTeams.check_consistency`, not here.
179
+
180
+ Args:
181
+ stderr_file: The file to report errors to.
182
+
183
+ Raises:
184
+ TypeError: If a field has the wrong type.
185
+ ValueError: If a field value violates a constraint.
186
+ """
187
+ check_field_types(self, stderr_file, 'Team')
188
+ self._check_values(stderr_file)
189
+ for member in self.members:
190
+ member.check_consistency(stderr_file)
@@ -0,0 +1,155 @@
1
+ #! /usr/local/bin/python3
2
+ """Work hours schedule and exceptions."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from enum import IntEnum, auto
10
+ from datetime import date
11
+ from typing import TextIO
12
+ from backlogops.backlog_helpers import check_field_types, report_bad_value
13
+ from backlogops.date_ranges import check_date_range, check_no_overlap
14
+
15
+
16
+ class WeekDay(IntEnum):
17
+ """Week day."""
18
+
19
+ MONDAY = auto()
20
+ TUESDAY = auto()
21
+ WEDNESDAY = auto()
22
+ THURSDAY = auto()
23
+ FRIDAY = auto()
24
+ SATURDAY = auto()
25
+ SUNDAY = auto()
26
+
27
+
28
+ type ScheduleWorkHours = dict[WeekDay, float]
29
+ """Work hours schedule by week day."""
30
+
31
+ DEFAULT_WORK_WEEK: ScheduleWorkHours = {
32
+ WeekDay.MONDAY: 8.0,
33
+ WeekDay.TUESDAY: 8.0,
34
+ WeekDay.WEDNESDAY: 8.0,
35
+ WeekDay.THURSDAY: 8.0,
36
+ WeekDay.FRIDAY: 8.0,
37
+ WeekDay.SATURDAY: 0.0,
38
+ WeekDay.SUNDAY: 0.0,
39
+ }
40
+ """The default work week."""
41
+
42
+
43
+ @dataclass
44
+ class ExceptionWorkHours:
45
+ """Exception work hours for a specific period.
46
+
47
+ The exception work hours are used to override the default work hours
48
+ for a specific period. This can be used to mark holidays or other days
49
+ with different work hours. It can also be used to mark a period with
50
+ ordered over-time work.
51
+ When used for an individual employee, the company exceptions are seen
52
+ as part of the schedule.
53
+
54
+ Fields:
55
+ start_date: The first day of the exception (inclusive).
56
+ end_date: The last day of the exception (inclusive). Must not be
57
+ before start_date.
58
+ hours_per_day: The work hours per day during the exception. Must
59
+ not be negative.
60
+ new_work_days: If True, the exception adds new work days compared
61
+ to the schedule. That is the hours per day also
62
+ applies to days with no work hours in the schedule.
63
+ If False, the exception does not add new work days.
64
+ That is the hours per day only applies to days with
65
+ work hours in the schedule.
66
+ If an individual employee has an exception to work
67
+ during days the company is closed, the new_work_days
68
+ flag must be True.
69
+
70
+ Exceptions in one list (a company or a person) must not overlap, so
71
+ that the work hours of any day are defined by at most one exception.
72
+ """
73
+
74
+ start_date: date
75
+ end_date: date
76
+ hours_per_day: float
77
+ new_work_days: bool = False
78
+
79
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
80
+ """Check the consistency of the exception work hours.
81
+
82
+ Field types are verified, the date range must be non-empty, and
83
+ the work hours per day must not be negative.
84
+
85
+ Args:
86
+ stderr_file: The file to report errors to.
87
+
88
+ Raises:
89
+ TypeError: If a field has the wrong type.
90
+ ValueError: If the range is empty or the hours are negative.
91
+ """
92
+ check_field_types(self, stderr_file, 'Work hours exception')
93
+ check_date_range('exception', self.start_date, self.end_date,
94
+ stderr_file, 'Work hours exception')
95
+ if self.hours_per_day < 0.0:
96
+ report_bad_value('hours_per_day', self.hours_per_day,
97
+ 'must not be negative', stderr_file,
98
+ 'Work hours exception')
99
+
100
+
101
+ @dataclass
102
+ class CompanyWorkHours:
103
+ """Company work hours.
104
+
105
+ The company work hours are used to define the work hours for a company.
106
+
107
+ Fields:
108
+ work_hours: The work hours schedule for the company. Every week
109
+ day must have non-negative work hours.
110
+ exceptions: The list of exception work hours for the company.
111
+ This should list national holidays and other days with
112
+ different work hours. This should also include any days
113
+ the company is closed for any reason (such as company
114
+ wide vacations). The exceptions must not overlap.
115
+ """
116
+
117
+ work_hours: ScheduleWorkHours = field(
118
+ default_factory=lambda: dict(DEFAULT_WORK_WEEK))
119
+ exceptions: list[ExceptionWorkHours] = field(default_factory=list)
120
+
121
+ def _check_schedule(self, stderr_file: TextIO) -> None:
122
+ """Check every week day has non-negative work hours defined."""
123
+ for week_day in WeekDay:
124
+ if week_day not in self.work_hours:
125
+ report_bad_value('work_hours', week_day,
126
+ 'missing work hours for week day',
127
+ stderr_file, 'Company work hours')
128
+ elif self.work_hours[week_day] < 0.0:
129
+ report_bad_value('work_hours', self.work_hours[week_day],
130
+ 'must not be negative', stderr_file,
131
+ 'Company work hours')
132
+
133
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
134
+ """Check the consistency of the company work hours.
135
+
136
+ Field types are verified, the schedule must define non-negative
137
+ work hours for every week day, every exception must be consistent,
138
+ and the exceptions must not overlap.
139
+
140
+ Args:
141
+ stderr_file: The file to report errors to.
142
+
143
+ Raises:
144
+ TypeError: If a field has the wrong type.
145
+ ValueError: If the schedule or an exception is invalid, or if
146
+ two exceptions overlap.
147
+ """
148
+ check_field_types(self, stderr_file, 'Company work hours')
149
+ self._check_schedule(stderr_file)
150
+ for exception in self.exceptions:
151
+ exception.check_consistency(stderr_file)
152
+ check_no_overlap('exceptions',
153
+ [(e.start_date, e.end_date)
154
+ for e in self.exceptions], stderr_file,
155
+ 'Company work hours')