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
backlogops/table_rows.py
ADDED
|
@@ -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)
|
backlogops/work_hours.py
ADDED
|
@@ -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')
|