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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Store and load AvailableTeams as a config-as-json configuration.
|
|
3
|
+
|
|
4
|
+
This module bridges the framework-neutral workforce data model in
|
|
5
|
+
:mod:`backlogops.available_teams` to the ``config_as_json`` library, in
|
|
6
|
+
the same way ``tableio_cfg_json`` bridges TableIO ``ConfigData`` with
|
|
7
|
+
``TioJsonConfig``. Each neutral data class gets a small bridge class that
|
|
8
|
+
multiply inherits from the data class and from ``Config``. The bridge
|
|
9
|
+
classes add JSON reading, writing, and validation, while the neutral data
|
|
10
|
+
classes stay the single source of truth for the data shape and the
|
|
11
|
+
consistency rules.
|
|
12
|
+
|
|
13
|
+
Application code that only wants to persist an ``AvailableTeams`` can use
|
|
14
|
+
:func:`write_available_teams` and :func:`read_available_teams` and never
|
|
15
|
+
touch the bridge classes directly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
19
|
+
# MIT License
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from datetime import date
|
|
25
|
+
from typing import Optional, Sequence, TextIO, override
|
|
26
|
+
from config_as_json import CallingWholeConfigValidator, Config, \
|
|
27
|
+
ConfigNesting, ConfigNestingKind, ConfigPath, JsonType, \
|
|
28
|
+
MemberValidationStep, MemberValidator, NestedConfigs, ParseConverter, \
|
|
29
|
+
PathOrStr, ReadOldConfiguration, SerializeConverter, SerializeConverters, \
|
|
30
|
+
ValidationPlan, WholeConfigValidationStep
|
|
31
|
+
from backlogops.available_teams import AvailableTeams
|
|
32
|
+
from backlogops.io_config import InputFormatConfig, OutputFormatConfig
|
|
33
|
+
from backlogops.backlog_helpers import convert_to_date, convert_to_enum, \
|
|
34
|
+
report_wrong_type
|
|
35
|
+
from backlogops.person import Person
|
|
36
|
+
from backlogops.team import FteException, Membership, Team
|
|
37
|
+
from backlogops.work_hours import CompanyWorkHours, ExceptionWorkHours, \
|
|
38
|
+
ScheduleWorkHours, WeekDay
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _date_to_iso(value: object, *, path_text: str, stderr_file: TextIO,
|
|
42
|
+
**_extra: object) -> JsonType:
|
|
43
|
+
"""Convert a date member into an ISO 8601 string for JSON output."""
|
|
44
|
+
_ = path_text, stderr_file
|
|
45
|
+
assert isinstance(value, date)
|
|
46
|
+
return value.isoformat()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _week_day_name(day: object) -> str:
|
|
50
|
+
"""Return the JSON name used for one work-hours schedule key."""
|
|
51
|
+
if isinstance(day, WeekDay):
|
|
52
|
+
return day.name
|
|
53
|
+
return str(day)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _schedule_to_json(value: object, *, path_text: str, stderr_file: TextIO,
|
|
57
|
+
**_extra: object) -> JsonType:
|
|
58
|
+
"""Convert a week-day schedule into a name-keyed JSON object."""
|
|
59
|
+
_ = path_text, stderr_file
|
|
60
|
+
assert isinstance(value, dict)
|
|
61
|
+
return {_week_day_name(day): float(hours)
|
|
62
|
+
for day, hours in value.items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _as_hours(member_name: str, value: object, stderr_file: TextIO) -> float:
|
|
66
|
+
"""Return work hours as a float, rejecting non-numeric values."""
|
|
67
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
68
|
+
report_wrong_type(member_name, value, float, stderr_file,
|
|
69
|
+
'Company work hours')
|
|
70
|
+
return float(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# pylint: disable-next=too-few-public-methods
|
|
74
|
+
class _IsoDateMember(MemberValidator):
|
|
75
|
+
"""Convert an ISO date string member into a ``datetime.date``."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, optional: bool) -> None:
|
|
78
|
+
"""Remember whether an empty (``None``) value is allowed."""
|
|
79
|
+
super().__init__()
|
|
80
|
+
self._optional = optional
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
def validate_member(self, config: Config, member_name: str,
|
|
84
|
+
member_value: object,
|
|
85
|
+
stderr_file: TextIO = sys.stderr) -> Optional[object]:
|
|
86
|
+
"""Return the member value as a date, or ``None`` when optional."""
|
|
87
|
+
_ = config
|
|
88
|
+
if member_value is None and self._optional:
|
|
89
|
+
return None
|
|
90
|
+
return convert_to_date(member_name, member_value, stderr_file)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# pylint: disable-next=too-few-public-methods
|
|
94
|
+
class _ScheduleMember(MemberValidator):
|
|
95
|
+
"""Normalize a work-hours schedule to ``WeekDay`` keyed floats."""
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
def validate_member(self, config: Config, member_name: str,
|
|
99
|
+
member_value: object,
|
|
100
|
+
stderr_file: TextIO = sys.stderr) -> Optional[object]:
|
|
101
|
+
"""Return the schedule keyed by ``WeekDay`` with float hours."""
|
|
102
|
+
_ = config
|
|
103
|
+
if not isinstance(member_value, dict):
|
|
104
|
+
report_wrong_type(member_name, member_value, dict, stderr_file,
|
|
105
|
+
'Company work hours')
|
|
106
|
+
result: ScheduleWorkHours = {}
|
|
107
|
+
for day, hours in member_value.items():
|
|
108
|
+
week_day = convert_to_enum(member_name, day, WeekDay, stderr_file)
|
|
109
|
+
assert isinstance(week_day, WeekDay)
|
|
110
|
+
result[week_day] = _as_hours(member_name, hours, stderr_file)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _BridgeConfig(Config):
|
|
115
|
+
"""Shared behavior for the AvailableTeams bridge classes."""
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def parse_converters(self) -> dict[str, ParseConverter]:
|
|
119
|
+
"""Use member validators instead of read-side scalar conversions."""
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
def __init__(self, from_json_data_text: Optional[str],
|
|
123
|
+
from_json_filename: Optional[PathOrStr],
|
|
124
|
+
stderr_file: TextIO) -> None:
|
|
125
|
+
"""Run the Config lifecycle for a bridge instance.
|
|
126
|
+
|
|
127
|
+
Each bridge first creates its data class attributes, then calls
|
|
128
|
+
this constructor to read JSON, apply defaults, and validate.
|
|
129
|
+
"""
|
|
130
|
+
Config.__init__(self, from_json_data_text=from_json_data_text,
|
|
131
|
+
from_json_filename=from_json_filename,
|
|
132
|
+
stderr_file=stderr_file)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _consistency() -> WholeConfigValidationStep:
|
|
136
|
+
"""Return the step that calls the data class consistency check."""
|
|
137
|
+
return WholeConfigValidationStep(
|
|
138
|
+
validator=CallingWholeConfigValidator('check_consistency'))
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _date_step(names: Sequence[str],
|
|
142
|
+
optional: bool) -> MemberValidationStep:
|
|
143
|
+
"""Return a step that parses ISO date members into dates."""
|
|
144
|
+
return MemberValidationStep(member_names=list(names),
|
|
145
|
+
validator=_IsoDateMember(optional))
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _date_writers(names: Sequence[str]) -> SerializeConverters:
|
|
149
|
+
"""Return write-side converters that format dates as ISO strings."""
|
|
150
|
+
converter = SerializeConverter(value_type=date, func=_date_to_iso,
|
|
151
|
+
args={})
|
|
152
|
+
return {name: converter for name in names}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FteExceptionConfig(FteException, _BridgeConfig):
|
|
156
|
+
"""JSON bridge for one full-time-equivalent exception."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
159
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
160
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
161
|
+
"""Create placeholder defaults, then read one FTE exception."""
|
|
162
|
+
FteException.__init__(self, start_date=date(2000, 1, 1),
|
|
163
|
+
end_date=date(2000, 1, 1), fte=1.0)
|
|
164
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
165
|
+
stderr_file)
|
|
166
|
+
|
|
167
|
+
@override
|
|
168
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
169
|
+
"""Parse the date members, then check exception consistency."""
|
|
170
|
+
_ = stderr_file
|
|
171
|
+
return [self._date_step(['start_date', 'end_date'], False),
|
|
172
|
+
self._consistency()]
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def serialize_converters(self) -> SerializeConverters:
|
|
176
|
+
"""Format the date members as ISO strings on write."""
|
|
177
|
+
return self._date_writers(['start_date', 'end_date'])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ExceptionWorkHoursConfig(ExceptionWorkHours, _BridgeConfig):
|
|
181
|
+
"""JSON bridge for one work-hours exception (holiday or special)."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
184
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
185
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
186
|
+
"""Create placeholder defaults, then read one work-hours exception."""
|
|
187
|
+
ExceptionWorkHours.__init__(self, start_date=date(2000, 1, 1),
|
|
188
|
+
end_date=date(2000, 1, 1),
|
|
189
|
+
hours_per_day=0.0)
|
|
190
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
191
|
+
stderr_file)
|
|
192
|
+
|
|
193
|
+
@override
|
|
194
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
195
|
+
"""Parse the date members, then check exception consistency."""
|
|
196
|
+
_ = stderr_file
|
|
197
|
+
return [self._date_step(['start_date', 'end_date'], False),
|
|
198
|
+
self._consistency()]
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
def serialize_converters(self) -> SerializeConverters:
|
|
202
|
+
"""Format the date members as ISO strings on write."""
|
|
203
|
+
return self._date_writers(['start_date', 'end_date'])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class MembershipConfig(Membership, _BridgeConfig):
|
|
207
|
+
"""JSON bridge for one team membership."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
210
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
211
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
212
|
+
"""Create placeholder defaults, then read one membership."""
|
|
213
|
+
Membership.__init__(self, person_name='person')
|
|
214
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
215
|
+
stderr_file)
|
|
216
|
+
|
|
217
|
+
@override
|
|
218
|
+
def nested_configs(self) -> NestedConfigs:
|
|
219
|
+
"""Declare the fte_exceptions list as nested Config objects."""
|
|
220
|
+
return {'fte_exceptions': ConfigNesting(
|
|
221
|
+
kind=ConfigNestingKind.LIST_ELEMENT,
|
|
222
|
+
config_type=FteExceptionConfig)}
|
|
223
|
+
|
|
224
|
+
@override
|
|
225
|
+
def _omit_none_from_json(self) -> list[str]:
|
|
226
|
+
"""Allow the optional membership date range to be omitted."""
|
|
227
|
+
return ['start_date', 'end_date']
|
|
228
|
+
|
|
229
|
+
@override
|
|
230
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
231
|
+
"""Parse the optional dates, then check membership consistency."""
|
|
232
|
+
_ = stderr_file
|
|
233
|
+
return [self._date_step(['start_date', 'end_date'], True),
|
|
234
|
+
self._consistency()]
|
|
235
|
+
|
|
236
|
+
@override
|
|
237
|
+
def serialize_converters(self) -> SerializeConverters:
|
|
238
|
+
"""Format the date members as ISO strings on write."""
|
|
239
|
+
return self._date_writers(['start_date', 'end_date'])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TeamConfig(Team, _BridgeConfig):
|
|
243
|
+
"""JSON bridge for one team."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
246
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
247
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
248
|
+
"""Create placeholder defaults, then read one team."""
|
|
249
|
+
Team.__init__(self, name='team', velocity=0.0, sum_fte_at_velocity=1.0,
|
|
250
|
+
sprint_length=1)
|
|
251
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
252
|
+
stderr_file)
|
|
253
|
+
|
|
254
|
+
@override
|
|
255
|
+
def nested_configs(self) -> NestedConfigs:
|
|
256
|
+
"""Declare the members list as nested Config objects."""
|
|
257
|
+
return {'members': ConfigNesting(kind=ConfigNestingKind.LIST_ELEMENT,
|
|
258
|
+
config_type=MembershipConfig)}
|
|
259
|
+
|
|
260
|
+
@override
|
|
261
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
262
|
+
"""Check the team consistency."""
|
|
263
|
+
_ = stderr_file
|
|
264
|
+
return [self._consistency()]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class PersonConfig(Person, _BridgeConfig):
|
|
268
|
+
"""JSON bridge for one person."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
271
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
272
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
273
|
+
"""Create placeholder defaults, then read one person."""
|
|
274
|
+
Person.__init__(self, name='person')
|
|
275
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
276
|
+
stderr_file)
|
|
277
|
+
|
|
278
|
+
@override
|
|
279
|
+
def nested_configs(self) -> NestedConfigs:
|
|
280
|
+
"""Declare the work-hour exceptions as nested Config objects."""
|
|
281
|
+
return {'exceptions': ConfigNesting(
|
|
282
|
+
kind=ConfigNestingKind.LIST_ELEMENT,
|
|
283
|
+
config_type=ExceptionWorkHoursConfig)}
|
|
284
|
+
|
|
285
|
+
@override
|
|
286
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
287
|
+
"""No extra person-level checks beyond the nested exceptions."""
|
|
288
|
+
_ = stderr_file
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class CompanyWorkHoursConfig(CompanyWorkHours, _BridgeConfig):
|
|
293
|
+
"""JSON bridge for the company work hours."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
296
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
297
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
298
|
+
"""Create defaults, then read the company work hours."""
|
|
299
|
+
CompanyWorkHours.__init__(self)
|
|
300
|
+
self._unchecked_dicts = ['work_hours']
|
|
301
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
302
|
+
stderr_file)
|
|
303
|
+
|
|
304
|
+
@override
|
|
305
|
+
def nested_configs(self) -> NestedConfigs:
|
|
306
|
+
"""Declare the work-hour exceptions as nested Config objects."""
|
|
307
|
+
return {'exceptions': ConfigNesting(
|
|
308
|
+
kind=ConfigNestingKind.LIST_ELEMENT,
|
|
309
|
+
config_type=ExceptionWorkHoursConfig)}
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
313
|
+
"""Normalize the week-day schedule, then check consistency."""
|
|
314
|
+
_ = stderr_file
|
|
315
|
+
return [MemberValidationStep(member_names=['work_hours'],
|
|
316
|
+
validator=_ScheduleMember()),
|
|
317
|
+
self._consistency()]
|
|
318
|
+
|
|
319
|
+
@override
|
|
320
|
+
def serialize_converters(self) -> SerializeConverters:
|
|
321
|
+
"""Write the week-day schedule with day-name keys."""
|
|
322
|
+
return {'work_hours': SerializeConverter(value_type=dict,
|
|
323
|
+
func=_schedule_to_json,
|
|
324
|
+
args={})}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class _TeamsReadOldConfig(ReadOldConfiguration):
|
|
328
|
+
"""Fill the input/output preset maps when an old file omits them.
|
|
329
|
+
|
|
330
|
+
The named input and output configuration presets were added to the
|
|
331
|
+
workforce file after the first released file shape. Files written
|
|
332
|
+
before that addition have neither member. This supplies an empty
|
|
333
|
+
preset map for each missing member so old files keep loading.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def get_missing_path_values(self) -> dict[ConfigPath, object]:
|
|
337
|
+
"""Return empty preset maps for the members old files may omit."""
|
|
338
|
+
return {('input_configs',): {}, ('output_configs',): {}}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class AvailableTeamsConfig(AvailableTeams, _BridgeConfig):
|
|
342
|
+
"""JSON bridge for the available workforce (persons and teams)."""
|
|
343
|
+
|
|
344
|
+
# pylint: disable-next=super-init-not-called
|
|
345
|
+
def __init__(self, *, neutral: Optional[AvailableTeams] = None,
|
|
346
|
+
from_json_data_text: Optional[str] = None,
|
|
347
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
348
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
349
|
+
"""Create the bridge from a neutral workforce or from JSON.
|
|
350
|
+
|
|
351
|
+
``AvailableTeams.__init__`` is intentionally not invoked because
|
|
352
|
+
it requires ``persons`` and ``teams`` arguments that the bridge
|
|
353
|
+
does not duplicate. ``Config.copy_initial_data`` establishes the
|
|
354
|
+
schema from the supplied or default neutral workforce instead.
|
|
355
|
+
The named input and output TableIO presets are not part of the
|
|
356
|
+
neutral workforce; they are added here as the bridge's own
|
|
357
|
+
members.
|
|
358
|
+
"""
|
|
359
|
+
if neutral is None:
|
|
360
|
+
neutral = AvailableTeams(persons={}, teams=[])
|
|
361
|
+
Config.copy_initial_data(neutral, self)
|
|
362
|
+
self.input_configs: dict[str, InputFormatConfig] = {}
|
|
363
|
+
self.output_configs: dict[str, OutputFormatConfig] = {}
|
|
364
|
+
_BridgeConfig.__init__(self, from_json_data_text, from_json_filename,
|
|
365
|
+
stderr_file)
|
|
366
|
+
|
|
367
|
+
@override
|
|
368
|
+
def _get_read_old_config(self) -> ReadOldConfiguration:
|
|
369
|
+
"""Accept old files written before the preset members existed."""
|
|
370
|
+
return _TeamsReadOldConfig()
|
|
371
|
+
|
|
372
|
+
@override
|
|
373
|
+
def nested_configs(self) -> NestedConfigs:
|
|
374
|
+
"""Declare the persons, teams, work hours and TableIO presets."""
|
|
375
|
+
return {
|
|
376
|
+
'persons': ConfigNesting(kind=ConfigNestingKind.DICT_VALUE,
|
|
377
|
+
config_type=PersonConfig),
|
|
378
|
+
'teams': ConfigNesting(kind=ConfigNestingKind.LIST_ELEMENT,
|
|
379
|
+
config_type=TeamConfig),
|
|
380
|
+
'company_work_hours': ConfigNesting(
|
|
381
|
+
kind=ConfigNestingKind.MEMBER,
|
|
382
|
+
config_type=CompanyWorkHoursConfig),
|
|
383
|
+
'input_configs': ConfigNesting(kind=ConfigNestingKind.DICT_VALUE,
|
|
384
|
+
config_type=InputFormatConfig),
|
|
385
|
+
'output_configs': ConfigNesting(kind=ConfigNestingKind.DICT_VALUE,
|
|
386
|
+
config_type=OutputFormatConfig)}
|
|
387
|
+
|
|
388
|
+
@override
|
|
389
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
390
|
+
"""Check the whole-workforce consistency."""
|
|
391
|
+
_ = stderr_file
|
|
392
|
+
return [self._consistency()]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def write_available_teams(teams: AvailableTeams, filename: PathOrStr,
|
|
396
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
397
|
+
"""Validate and write an available workforce to a JSON file.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
teams: The workforce to store.
|
|
401
|
+
filename: Destination JSON configuration file.
|
|
402
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
403
|
+
"""
|
|
404
|
+
config = AvailableTeamsConfig(neutral=teams, stderr_file=stderr_file)
|
|
405
|
+
config.write(to_json_filename=filename, stderr_file=stderr_file)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def read_available_teams(filename: PathOrStr, stderr_file: TextIO = sys.stderr
|
|
409
|
+
) -> AvailableTeamsConfig:
|
|
410
|
+
"""Read an available workforce from a JSON configuration file.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
filename: Source JSON configuration file.
|
|
414
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The loaded workforce. The returned object is an ``AvailableTeams``.
|
|
418
|
+
"""
|
|
419
|
+
return AvailableTeamsConfig(from_json_filename=filename,
|
|
420
|
+
stderr_file=stderr_file)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# pylint: disable-next=too-few-public-methods
|
|
424
|
+
class _TeamsStore:
|
|
425
|
+
"""Hold the most recently loaded workforce for reuse in a process.
|
|
426
|
+
|
|
427
|
+
The current workforce is kept in RAM so that a later call to
|
|
428
|
+
:func:`get_available_teams` without a filename can reuse it instead
|
|
429
|
+
of reading a file again.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
current: Optional[AvailableTeamsConfig] = None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _config_from_named_file() -> Optional[Path]:
|
|
436
|
+
"""Return the config file named by $BACKLOGOPS_CFG, if that is set."""
|
|
437
|
+
named = os.environ.get('BACKLOGOPS_CFG')
|
|
438
|
+
if named is None:
|
|
439
|
+
return None
|
|
440
|
+
path = Path(named)
|
|
441
|
+
if not path.is_file():
|
|
442
|
+
raise FileNotFoundError(f'$BACKLOGOPS_CFG file not found: {named}')
|
|
443
|
+
return path
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _config_from_named_dir() -> Optional[Path]:
|
|
447
|
+
"""Return backlogops.cfg in $BACKLOGOPS_DIR, if that directory is set."""
|
|
448
|
+
named = os.environ.get('BACKLOGOPS_DIR')
|
|
449
|
+
if named is None:
|
|
450
|
+
return None
|
|
451
|
+
directory = Path(named)
|
|
452
|
+
if not directory.is_dir():
|
|
453
|
+
raise NotADirectoryError(f'$BACKLOGOPS_DIR not found: {named}')
|
|
454
|
+
path = directory / 'backlogops.cfg'
|
|
455
|
+
return path if path.is_file() else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _config_from_home() -> Optional[Path]:
|
|
459
|
+
"""Return $HOME/.backlogops.cfg if that file exists."""
|
|
460
|
+
path = Path.home() / '.backlogops.cfg'
|
|
461
|
+
return path if path.is_file() else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _searched_locations() -> str:
|
|
465
|
+
"""Describe the locations searched for a configuration file."""
|
|
466
|
+
named_dir = os.environ.get('BACKLOGOPS_DIR')
|
|
467
|
+
in_dir = (str(Path(named_dir) / 'backlogops.cfg') if named_dir is not None
|
|
468
|
+
else '$BACKLOGOPS_DIR (not set)')
|
|
469
|
+
return (' $BACKLOGOPS_CFG (not set)\n'
|
|
470
|
+
f' {in_dir}\n'
|
|
471
|
+
f' {Path.home() / ".backlogops.cfg"}')
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _config_path_from_env() -> Path:
|
|
475
|
+
"""Return the configuration file found by the documented precedence.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
FileNotFoundError: If $BACKLOGOPS_CFG is set but the file is
|
|
479
|
+
missing.
|
|
480
|
+
NotADirectoryError: If $BACKLOGOPS_DIR is set but is not a
|
|
481
|
+
directory.
|
|
482
|
+
RuntimeError: If no configuration file is found.
|
|
483
|
+
"""
|
|
484
|
+
path = _config_from_named_file()
|
|
485
|
+
if path is not None:
|
|
486
|
+
return path
|
|
487
|
+
path = _config_from_named_dir()
|
|
488
|
+
if path is not None:
|
|
489
|
+
return path
|
|
490
|
+
path = _config_from_home()
|
|
491
|
+
if path is not None:
|
|
492
|
+
return path
|
|
493
|
+
raise RuntimeError('No teams configuration file found. Looked for:\n'
|
|
494
|
+
+ _searched_locations())
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def get_available_teams(filename: Optional[PathOrStr],
|
|
498
|
+
stderr_file: TextIO = sys.stderr
|
|
499
|
+
) -> AvailableTeamsConfig:
|
|
500
|
+
"""Convinience get the AvailableTeamsConfig to use.
|
|
501
|
+
|
|
502
|
+
If a filename is provided, the file is read and the AvailableTeamsConfig
|
|
503
|
+
is stored and returned.
|
|
504
|
+
If no filename is provided and there is a stored AvailableTeamsConfig,
|
|
505
|
+
it is returned.
|
|
506
|
+
If no filename is provided and there is no stored AvailableTeamsConfig,
|
|
507
|
+
this function will look for these in order of precedence:
|
|
508
|
+
- File named in $BACKLOGOPS_CFG environment variable
|
|
509
|
+
- File backlogops.cfg in folder specified by $BACKLOGOPS_DIR
|
|
510
|
+
environment variable
|
|
511
|
+
- $HOME/.backlogops.cfg
|
|
512
|
+
If a file is found, it is read and the AvailableTeamsConfig is stored and
|
|
513
|
+
returned. If no file is found, an exception is raised.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
filename: Source JSON configuration file.
|
|
517
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
FileNotFoundError: If $BACKLOGOPS_CFG is set but the file does not
|
|
521
|
+
exist.
|
|
522
|
+
NotADirectoryError: If $BACKLOGOPS_DIR is set but the directory
|
|
523
|
+
does not exist.
|
|
524
|
+
RuntimeError: If no filename is provided and no stored
|
|
525
|
+
AvailableTeamsConfig is found and no file is found in
|
|
526
|
+
the order of precedence.
|
|
527
|
+
Returns:
|
|
528
|
+
The loaded workforce. The returned object is an
|
|
529
|
+
``AvailableTeamsConfig``.
|
|
530
|
+
"""
|
|
531
|
+
if filename is not None:
|
|
532
|
+
_TeamsStore.current = read_available_teams(filename, stderr_file)
|
|
533
|
+
return _TeamsStore.current
|
|
534
|
+
if _TeamsStore.current is not None:
|
|
535
|
+
return _TeamsStore.current
|
|
536
|
+
path = _config_path_from_env()
|
|
537
|
+
_TeamsStore.current = read_available_teams(path, stderr_file)
|
|
538
|
+
return _TeamsStore.current
|