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/backlog.py ADDED
@@ -0,0 +1,465 @@
1
+ #! /usr/local/bin/python3
2
+ """Internal representation of a backlog."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ from datetime import date
9
+ from dataclasses import dataclass, field, fields
10
+ from enum import IntEnum, auto
11
+ from typing import Optional, TextIO
12
+ from backlogops.levels import Levels, DEFAULT_LEVELS, level_number_from_name
13
+ from backlogops.backlog_helpers import build_item_kwargs, check_key_syntax
14
+ from backlogops.backlog_helpers import construct, field_type_hints, find_cycle
15
+ from backlogops.backlog_helpers import report_bad_value, check_field_types
16
+ from backlogops.backlog_helpers import report_unknown_reference
17
+
18
+ DEPENDENCY_FIELDS = ('depends_on_f2s', 'depends_on_f2f', 'depends_on_s2s')
19
+ """Names of the fields that hold dependency keys of a backlog item."""
20
+
21
+
22
+ class Status(IntEnum):
23
+ """Status of a backlog item.
24
+
25
+ The meaning of each status is:
26
+ - TODO: The backlog item is not started yet. It will be done
27
+ sometimes in the future. The complete story points on
28
+ the item consumes FTE time.
29
+ - IN_PROGRESS: The backlog item is in progress. We do not know
30
+ how much of it is done, so the complete story points on
31
+ the item consumes FTE time.
32
+ - DONE: The backlog item is finished. No work left to do.
33
+ The story points on the item will not consume any more
34
+ FTE time.
35
+ - REJECTED: The backlog item is rejected. The work will not be done.
36
+ This is only present in the backlog to record the explicit
37
+ decision not to do the work.
38
+ The story points on the item will not consume any more
39
+ FTE time.
40
+ """
41
+
42
+ TODO = auto()
43
+ IN_PROGRESS = auto()
44
+ DONE = auto()
45
+ REJECTED = auto()
46
+
47
+
48
+ @dataclass
49
+ class BacklogItem: # pylint: disable=too-many-instance-attributes
50
+ """Internal representation of a backlog item.
51
+
52
+ The backlog item has a number of defined fields that are used
53
+ by the backlog operations. In addition, it has a number of extra
54
+ fields that store useful information (like descriptions) that are
55
+ not used by the backlog operations.
56
+
57
+ Fields:
58
+ key: The key of the backlog item. Required. Must be unique.
59
+ Must not be empty, must not contain whitespace and must
60
+ not contain any of the characters , . ; : ( ) [ ] { }.
61
+ level: The level of the backlog item. Required. Must be an integer.
62
+ title: The title of the backlog item. Required.
63
+ story_points: The story points of the backlog item.
64
+ status: The status of the backlog item.
65
+ parent_key: The key of the parent backlog item. Optional.
66
+ Must exist as a key in the backlog.
67
+ Parent keys are used to build the hierarchy of the backlog.
68
+ The parent key must be at a higher level than the current
69
+ item. Parent keys introduce implicit dependencies between
70
+ items: the current item cannot start before the parent
71
+ item starts, and the parent item cannot finish before
72
+ all its children have finished.
73
+ release: The release of the backlog item. Optional.
74
+ Follows the same character rules as the key.
75
+ Must not be empty string.
76
+ team: The team responsible for the backlog item. Optional.
77
+ Must not be empty string. Must be a valid team name.
78
+ If None the item can be done by any team. If not None.
79
+ the item can only be done by the specified team.
80
+ depends_on_f2s: The list of keys of the backlog items that must
81
+ have been finished before the current item can
82
+ start. May be empty.
83
+ depends_on_f2f: The list of keys of the backlog items that must
84
+ have been finished before the current item can
85
+ finish. May be empty.
86
+ depends_on_s2s: The list of keys of the backlog items that must
87
+ have been started before the current item can
88
+ start. May be empty.
89
+ planned_ready_date: The planned ready date of the backlog item.
90
+ The date that is communicated to the
91
+ customer. Optional.
92
+ estimated_ready_date: The estimated ready date of the backlog
93
+ item. Optional.
94
+ extra_fields: Additional input fields not used by the backlog
95
+ operations, stored by name.
96
+ """
97
+
98
+ key: str
99
+ level: int
100
+ title: str
101
+ story_points: int
102
+ status: Status
103
+ parent_key: Optional[str] = None
104
+ release: Optional[str] = None
105
+ team: Optional[str] = None
106
+ depends_on_f2s: list[str] = field(default_factory=list)
107
+ depends_on_f2f: list[str] = field(default_factory=list)
108
+ depends_on_s2s: list[str] = field(default_factory=list)
109
+ planned_ready_date: Optional[date] = None
110
+ estimated_ready_date: Optional[date] = None
111
+ extra_fields: dict[str, object] = field(default_factory=dict)
112
+
113
+ def __getitem__(self, field_name: str) -> object:
114
+ """Return a mandatory or extra field by name."""
115
+ # pylint: disable-next=no-member
116
+ if field_name in self.__dataclass_fields__:
117
+ return getattr(self, field_name)
118
+ return self.extra_fields[field_name]
119
+
120
+ def __setitem__(self, field_name: str, value: object) -> None:
121
+ """Set a mandatory or extra field by name."""
122
+ # pylint: disable-next=no-member
123
+ if field_name in self.__dataclass_fields__:
124
+ setattr(self, field_name, value)
125
+ else:
126
+ self.extra_fields[field_name] = value
127
+
128
+ def __contains__(self, field_name: str) -> bool:
129
+ """Check if a mandatory or extra field exists by name."""
130
+ # pylint: disable-next=no-member
131
+ return field_name in self.__dataclass_fields__ or \
132
+ field_name in self.extra_fields
133
+
134
+ def to_dict(self) -> dict[str, object]:
135
+ """Return a dictionary representation of the backlog item."""
136
+ result = {}
137
+ # pylint: disable-next=no-member
138
+ for field_name in self.__dataclass_fields__:
139
+ result[field_name] = getattr(self, field_name)
140
+ for field_name, value in self.extra_fields.items():
141
+ result[field_name] = value
142
+ return result
143
+
144
+ def _check_field_types(self, stderr_file: TextIO) -> None:
145
+ """Check that every field holds a value of its declared type."""
146
+ check_field_types(self, stderr_file)
147
+
148
+ def _check_key_constraints(self, stderr_file: TextIO) -> None:
149
+ """Check the key, release and dependency keys for valid syntax."""
150
+ check_key_syntax('key', self.key, stderr_file)
151
+ if self.release is not None:
152
+ check_key_syntax('release', self.release, stderr_file)
153
+ for dep_field in DEPENDENCY_FIELDS:
154
+ for index, dep_key in enumerate(getattr(self, dep_field)):
155
+ check_key_syntax(f'{dep_field}[{index}]', dep_key, stderr_file)
156
+
157
+ def _check_no_field_shadow(self, stderr_file: TextIO) -> None:
158
+ """Check that no extra field shadows a named field."""
159
+ # pylint: disable-next=no-member
160
+ named = set(self.__dataclass_fields__)
161
+ for extra_key in self.extra_fields:
162
+ if extra_key in named:
163
+ report_bad_value('extra_fields', extra_key,
164
+ 'shadows a named backlog item field',
165
+ stderr_file)
166
+
167
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
168
+ """Check the internal consistency of the backlog item.
169
+
170
+ The documented constraints are checked on all member variables.
171
+ Field types are verified, the key, release and dependency keys
172
+ are checked for valid syntax, and the extra fields are checked
173
+ not to shadow a named field. References between items are not
174
+ checked here; that is done by :func:`check_backlog_consistency`.
175
+
176
+ Args:
177
+ stderr_file: The file to report errors to.
178
+
179
+ Raises:
180
+ TypeError: If a field has the wrong type.
181
+ ValueError: If a field value violates a constraint, or if an
182
+ extra field shadows a named field.
183
+ """
184
+ self._check_field_types(stderr_file)
185
+ self._check_key_constraints(stderr_file)
186
+ self._check_no_field_shadow(stderr_file)
187
+
188
+
189
+ type Backlog = list[BacklogItem]
190
+ """Internal representation of a backlog."""
191
+
192
+
193
+ def prepare_item_data(data: dict[str, object], levels: Levels,
194
+ stderr_file: TextIO = sys.stderr) -> dict[str, object]:
195
+ """Return item data with a string level resolved to its number.
196
+
197
+ A ``level`` given as a string is matched against the level names and
198
+ aliases in ``levels`` and replaced by the level number. Integer
199
+ levels and absent levels are returned unchanged, so that type and
200
+ missing-field checks happen as usual when the data is converted.
201
+
202
+ Args:
203
+ data: The raw input data for one backlog item.
204
+ levels: The levels used to resolve a string level.
205
+ stderr_file: The file to report errors to.
206
+
207
+ Returns:
208
+ The input data, with a string level replaced by its number.
209
+
210
+ Raises:
211
+ ValueError: If a string level matches no level name or alias.
212
+ """
213
+ level_value = data.get('level')
214
+ if not isinstance(level_value, str):
215
+ return data
216
+ number = level_number_from_name(level_value, levels, stderr_file)
217
+ return {**data, 'level': number}
218
+
219
+
220
+ def get_backlog_item(data: dict[str, object], levels: Optional[Levels] = None,
221
+ stderr_file: TextIO = sys.stderr) -> BacklogItem:
222
+ """Get a backlog item from a dictionary.
223
+
224
+ The dictionary is expected to hold the mandatory fields of the
225
+ BacklogItem dataclass and any number of extra fields. Field values
226
+ are converted to their declared types (for example ISO date strings
227
+ to ``date`` and status names to ``Status``) and checked. A ``level``
228
+ given as a string is resolved to its level number using ``levels``.
229
+ When ``levels`` is None the default levels are used. Errors are
230
+ reported to the given file object.
231
+
232
+ Args:
233
+ data: The dictionary to get the backlog item from.
234
+ levels: The levels used to resolve a string level, or None to
235
+ use :data:`DEFAULT_LEVELS`.
236
+ stderr_file: The file to report errors to.
237
+
238
+ Raises:
239
+ KeyError: If a mandatory field is missing.
240
+ TypeError: If a field has a type that cannot be converted.
241
+ ValueError: If a string level matches no level name or alias.
242
+
243
+ Returns:
244
+ The backlog item.
245
+ """
246
+ chosen_levels = DEFAULT_LEVELS if levels is None else levels
247
+ field_types = field_type_hints(BacklogItem)
248
+ prepared = prepare_item_data(data, chosen_levels, stderr_file)
249
+ item_kwargs = build_item_kwargs(fields(BacklogItem), field_types, prepared,
250
+ stderr_file)
251
+ return construct(BacklogItem, item_kwargs)
252
+
253
+
254
+ def get_backlog(datalist: list[dict[str, object]],
255
+ levels: Optional[Levels] = None,
256
+ stderr_file: TextIO = sys.stderr) -> Backlog:
257
+ """Get a backlog from a list of dictionaries.
258
+
259
+ Each dictionary is converted to a backlog item as documented for
260
+ :func:`get_backlog_item`.
261
+
262
+ Args:
263
+ datalist: The list of dictionaries to get the backlog from.
264
+ levels: The levels used to convert level names to level numbers,
265
+ or None to use :data:`DEFAULT_LEVELS`.
266
+ stderr_file: The file to report errors to.
267
+
268
+ Raises:
269
+ KeyError: If a mandatory field is missing.
270
+ TypeError: If a field has a type that cannot be converted.
271
+ ValueError: If a string level matches no level name or alias.
272
+
273
+ Returns:
274
+ The backlog.
275
+ """
276
+ return [get_backlog_item(data, levels, stderr_file) for data in datalist]
277
+
278
+
279
+ def check_unique_keys(backlog: Backlog,
280
+ stderr_file: TextIO = sys.stderr) -> set[str]:
281
+ """Check that all backlog item keys are unique.
282
+
283
+ Args:
284
+ backlog: The backlog to check.
285
+ stderr_file: The file to report errors to.
286
+
287
+ Returns:
288
+ The set of all keys, for reuse by later checks.
289
+
290
+ Raises:
291
+ ValueError: If two items share the same key.
292
+ """
293
+ keys: set[str] = set()
294
+ for item in backlog:
295
+ if item.key in keys:
296
+ report_bad_value('key', item.key, 'duplicate backlog item key',
297
+ stderr_file)
298
+ keys.add(item.key)
299
+ return keys
300
+
301
+
302
+ def check_key_references(backlog: Backlog, known_keys: set[str],
303
+ stderr_file: TextIO = sys.stderr) -> None:
304
+ """Check that parent and dependency keys reference existing items.
305
+
306
+ Args:
307
+ backlog: The backlog to check.
308
+ known_keys: The set of keys that exist in the backlog.
309
+ stderr_file: The file to report errors to.
310
+
311
+ Raises:
312
+ KeyError: If a parent_key or dependency key is unknown.
313
+ """
314
+ for item in backlog:
315
+ if item.parent_key is not None and \
316
+ item.parent_key not in known_keys:
317
+ report_unknown_reference('parent_key', item.key, item.parent_key,
318
+ stderr_file)
319
+ for dep_field in DEPENDENCY_FIELDS:
320
+ for dep_key in getattr(item, dep_field):
321
+ if dep_key not in known_keys:
322
+ report_unknown_reference(dep_field, item.key, dep_key,
323
+ stderr_file)
324
+
325
+
326
+ def event_start(key: str) -> str:
327
+ """Return the start-event node name for a backlog item key."""
328
+ return f'{key}:start'
329
+
330
+
331
+ def event_finish(key: str) -> str:
332
+ """Return the finish-event node name for a backlog item key."""
333
+ return f'{key}:finish'
334
+
335
+
336
+ def item_dependency_edges(item: BacklogItem) -> list[tuple[str, str]]:
337
+ """Return the directed scheduling edges implied by one item.
338
+
339
+ Each item has a start event and a finish event. An edge ``a -> b``
340
+ means that event ``a`` cannot happen before event ``b`` (``a``
341
+ depends on ``b``). An item finish depends on its own start. The
342
+ dependency lists add finish-to-start, finish-to-finish and
343
+ start-to-start edges. A parent relation adds two implicit edges: the
344
+ child cannot start before the parent starts, and the parent cannot
345
+ finish before the child finishes.
346
+
347
+ Args:
348
+ item: The backlog item to take the edges from.
349
+
350
+ Returns:
351
+ The directed (source, target) event edges of the item.
352
+ """
353
+ start = event_start(item.key)
354
+ finish = event_finish(item.key)
355
+ edges = [(finish, start)]
356
+ edges += [(start, event_finish(dep)) for dep in item.depends_on_f2s]
357
+ edges += [(finish, event_finish(dep)) for dep in item.depends_on_f2f]
358
+ edges += [(start, event_start(dep)) for dep in item.depends_on_s2s]
359
+ if item.parent_key is not None:
360
+ edges.append((start, event_start(item.parent_key)))
361
+ edges.append((event_finish(item.parent_key), finish))
362
+ return edges
363
+
364
+
365
+ def build_dependency_graph(backlog: Backlog) -> dict[str, list[str]]:
366
+ """Return the scheduling-event dependency graph of a backlog.
367
+
368
+ The nodes are the start and finish events of the items, named
369
+ ``'<key>:start'`` and ``'<key>:finish'`` (``:`` cannot appear in a
370
+ key). The edges combine the explicit dependency lists with the
371
+ implicit parent relations, as described in
372
+ :func:`item_dependency_edges`. A cycle in this graph is an
373
+ unsatisfiable set of scheduling constraints.
374
+
375
+ Args:
376
+ backlog: The backlog to build the graph from.
377
+
378
+ Returns:
379
+ A mapping from each event node to the events it depends on.
380
+ """
381
+ graph: dict[str, list[str]] = {}
382
+ for item in backlog:
383
+ for source, target in item_dependency_edges(item):
384
+ graph.setdefault(source, []).append(target)
385
+ return graph
386
+
387
+
388
+ def check_no_cycles(backlog: Backlog,
389
+ stderr_file: TextIO = sys.stderr) -> None:
390
+ """Check that the scheduling-event graph of a backlog has no cycles.
391
+
392
+ The graph combines the explicit dependencies with the implicit
393
+ parent relations. A self dependency is treated as a cycle of length
394
+ one. A valid parent and child nesting is not a cycle, because the
395
+ parent and child start and finish events stay distinct.
396
+
397
+ Args:
398
+ backlog: The backlog to check.
399
+ stderr_file: The file to report errors to.
400
+
401
+ Raises:
402
+ ValueError: If a dependency cycle is found.
403
+ """
404
+ cycle = find_cycle(build_dependency_graph(backlog))
405
+ if cycle is not None:
406
+ message = 'Dependency cycle in backlog: ' + ' -> '.join(cycle)
407
+ print(message, file=stderr_file)
408
+ raise ValueError(message)
409
+
410
+
411
+ def check_parent_levels(backlog: Backlog, items_by_key: dict[str, BacklogItem],
412
+ stderr_file: TextIO = sys.stderr) -> None:
413
+ """Check that each parent is at a higher level than its child.
414
+
415
+ A parent is a bigger backlog item than its children, so its level
416
+ number must be strictly higher than the item that references it. The
417
+ parent references are assumed to exist, as already checked by
418
+ :func:`check_key_references`.
419
+
420
+ Args:
421
+ backlog: The backlog to check.
422
+ items_by_key: A mapping from each key to its backlog item.
423
+ stderr_file: The file to report errors to.
424
+
425
+ Raises:
426
+ ValueError: If a parent is not at a higher level than its child.
427
+ """
428
+ for item in backlog:
429
+ if item.parent_key is None:
430
+ continue
431
+ parent = items_by_key[item.parent_key]
432
+ if parent.level <= item.level:
433
+ report_bad_value('parent_key', item.parent_key,
434
+ f'parent level {parent.level} is not higher '
435
+ f'than item level {item.level}', stderr_file)
436
+
437
+
438
+ def check_backlog_consistency(backlog: Backlog,
439
+ stderr_file: TextIO = sys.stderr) -> None:
440
+ """Check the consistency of a backlog.
441
+
442
+ Every item is checked for internal consistency, all keys are checked
443
+ for uniqueness, parent and dependency keys are checked to reference
444
+ existing items, each parent is checked to be at a higher level than
445
+ its child, and the scheduling-event graph is checked to be free of
446
+ cycles.
447
+
448
+ Args:
449
+ backlog: The backlog to check.
450
+ stderr_file: The file to report errors to.
451
+
452
+ Raises:
453
+ KeyError: If a key reference is invalid.
454
+ TypeError: If a field has the wrong type.
455
+ ValueError: If a field value violates a constraint, if keys are
456
+ not unique, if a parent is not at a higher level than its
457
+ child, or if there is a dependency cycle.
458
+ """
459
+ for item in backlog:
460
+ item.check_consistency(stderr_file)
461
+ known_keys = check_unique_keys(backlog, stderr_file)
462
+ items_by_key = {item.key: item for item in backlog}
463
+ check_key_references(backlog, known_keys, stderr_file)
464
+ check_parent_levels(backlog, items_by_key, stderr_file)
465
+ check_no_cycles(backlog, stderr_file)