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,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