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,166 @@
1
+ #! /usr/bin/env python3
2
+ """Reorder a backlog from a key list and extract keys by level."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ from typing import Optional, TextIO
8
+ from collections.abc import Sequence
9
+ import sys
10
+ from backlogops.backlog import Backlog, BacklogItem
11
+ from backlogops.backlog_helpers import report_bad_value
12
+ from backlogops.levels import DEFAULT_LEVELS, Levels, level_number_from_name
13
+
14
+
15
+ def _by_key(backlog: Backlog) -> dict[str, BacklogItem]:
16
+ """Return a mapping from each key to its backlog item."""
17
+ return {item.key: item for item in backlog}
18
+
19
+
20
+ def _validate_keys(keys: Sequence[str], by_key: dict[str, BacklogItem],
21
+ stderr_file: TextIO) -> None:
22
+ """Check that the keys are unique and present in the backlog."""
23
+ seen: set[str] = set()
24
+ for key in keys:
25
+ if key in seen:
26
+ report_bad_value('keys', key, 'duplicate key', stderr_file)
27
+ seen.add(key)
28
+ if key not in by_key:
29
+ message = f'Key not found in backlog: {key!r}'
30
+ print(message, file=stderr_file)
31
+ raise KeyError(message)
32
+
33
+
34
+ def _children_map(backlog: Backlog) -> dict[str, list[BacklogItem]]:
35
+ """Return the children of each key, in original backlog order."""
36
+ children: dict[str, list[BacklogItem]] = {}
37
+ for item in backlog:
38
+ if item.parent_key is not None:
39
+ children.setdefault(item.parent_key, []).append(item)
40
+ return children
41
+
42
+
43
+ def _front_order(backlog: Backlog, keys: Sequence[str]) -> list[str]:
44
+ """Return the leading keys: each named key after its descendant subtree.
45
+
46
+ Each named key is preceded by its descendants in post order, so that a
47
+ child comes right before its own parent and a parent right before the
48
+ grandparent. Siblings keep their original backlog order. A named
49
+ descendant is not pulled in, as it is placed by its own key. A
50
+ descendant is pulled in only when it appears after its named ancestor
51
+ in the backlog, so that no item is moved to a later position because
52
+ of an ancestor's key.
53
+ """
54
+ named = set(keys)
55
+ index = {item.key: position for position, item in enumerate(backlog)}
56
+ children = _children_map(backlog)
57
+ visited: set[str] = set()
58
+ front: list[str] = []
59
+
60
+ def emit_descendants(node_key: str, root_index: int) -> None:
61
+ visited.add(node_key)
62
+ for child in children.get(node_key, []):
63
+ if child.key in named or child.key in visited:
64
+ continue
65
+ emit_descendants(child.key, root_index)
66
+ if index[child.key] > root_index:
67
+ front.append(child.key)
68
+
69
+ for key in keys:
70
+ emit_descendants(key, index[key])
71
+ front.append(key)
72
+ return front
73
+
74
+
75
+ def move_keys_first(backlog: Backlog, keys: Sequence[str],
76
+ stderr_file: TextIO = sys.stderr) -> Backlog:
77
+ """Move the items named by ``keys`` to the front of the backlog.
78
+
79
+ A new backlog is returned; the argument is not modified. The named
80
+ items lead the backlog in the order of ``keys``. Each named item is
81
+ preceded by its descendants in post order: a child comes right before
82
+ its own parent, and that parent right before the grandparent, all the
83
+ way up to the named item. For example, if ``E`` is a parent of ``S1``
84
+ and ``S2`` and ``S2`` is a parent of ``T``, moving ``E`` first yields
85
+ ``S1, T, S2, E``. Siblings keep their original backlog order. A named
86
+ descendant is not pulled in this way; it is placed by its own key, so
87
+ it may end up after its named parent. A descendant is pulled to the
88
+ front only when it appears after its named ancestor in the backlog, so
89
+ that no item is moved to a later position because of an ancestor's
90
+ key. All items that are neither named nor pulled to the front keep
91
+ their original order after the front block.
92
+
93
+ Args:
94
+ backlog: The backlog to reorder. The argument is not modified.
95
+ keys: The keys to move to the front, in the wanted order. The keys
96
+ must be unique and must exist in the backlog.
97
+ stderr_file: The stream to report errors to.
98
+
99
+ Returns:
100
+ A new backlog with the named items moved to the front.
101
+
102
+ Raises:
103
+ KeyError: If a key is not found in the backlog.
104
+ ValueError: If a key is not unique.
105
+ """
106
+ by_key = _by_key(backlog)
107
+ _validate_keys(keys, by_key, stderr_file)
108
+ front = _front_order(backlog, keys)
109
+ front_set = set(front)
110
+ moved = [by_key[key] for key in front]
111
+ rest = [item for item in backlog if item.key not in front_set]
112
+ return moved + rest
113
+
114
+
115
+ def _level_sequence(only_levels: int | str | Sequence[int | str]
116
+ ) -> Sequence[int | str]:
117
+ """Return the requested levels as a sequence of single level values."""
118
+ if isinstance(only_levels, (int, str)):
119
+ return [only_levels]
120
+ if isinstance(only_levels, Sequence):
121
+ return only_levels
122
+ raise TypeError('only_levels must be an int, a str, or a sequence of '
123
+ 'ints or strs')
124
+
125
+
126
+ def _level_number(value: int | str, levels: Levels) -> int:
127
+ """Return the level number for one int or str level value."""
128
+ if isinstance(value, bool):
129
+ raise TypeError('a level must be an int or a str, not a bool')
130
+ if isinstance(value, int):
131
+ return value
132
+ if isinstance(value, str):
133
+ return level_number_from_name(value, levels)
134
+ raise TypeError('a level must be an int or a str')
135
+
136
+
137
+ def get_keys_in_order(backlog: Backlog,
138
+ only_levels: int | str | Sequence[int | str],
139
+ levels: Optional[Levels] = None) -> list[str]:
140
+ """Return the keys of the backlog items at the given levels, in order.
141
+
142
+ The keys are returned in the order they appear in the backlog, keeping
143
+ only the items whose level is among ``only_levels``. A level may be
144
+ given as a level number or as a level name or alias. A name or alias
145
+ is resolved through ``levels`` (the default levels when ``levels`` is
146
+ None). A level number is used as is and need not be one of ``levels``.
147
+
148
+ Args:
149
+ backlog: The backlog to take the keys from.
150
+ only_levels: The levels to keep, as a single int or str or as a
151
+ sequence of ints and strs.
152
+ levels: The levels used to resolve a level name or alias, or None
153
+ to use :data:`DEFAULT_LEVELS`.
154
+
155
+ Returns:
156
+ The keys of the matching items, in backlog order.
157
+
158
+ Raises:
159
+ TypeError: If ``only_levels`` is not an int, a str, or a sequence
160
+ of ints and strs.
161
+ ValueError: If a level name or alias matches no level.
162
+ """
163
+ chosen = DEFAULT_LEVELS if levels is None else levels
164
+ numbers = {_level_number(value, chosen)
165
+ for value in _level_sequence(only_levels)}
166
+ return [item.key for item in backlog if item.level in numbers]
@@ -0,0 +1,100 @@
1
+ #! /usr/local/bin/python3
2
+ """NoTextIO can be used as a TextIO object that does nothing."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ from typing import override
8
+ from collections.abc import Iterable
9
+ from types import TracebackType
10
+ import io
11
+
12
+
13
+ class NoTextIO(io.StringIO):
14
+ """NoTextIO can be used as a TextIO object that does nothing.
15
+
16
+ When a function expects a TextIO object for output, you can pass in
17
+ a NoTextIO object and no output will be produced.
18
+ The differrence compared to using StringIO to suppress output is that
19
+ the NoTextIO does not store any data, so no matter how much is
20
+ written to it, you do not risk running out of memory.
21
+ """
22
+
23
+ @override
24
+ def write(self, s: str) -> int:
25
+ """Write a string to the NoTextIO object.
26
+
27
+ This method does nothing and returns 0.
28
+ """
29
+ _ = s
30
+ return 0
31
+
32
+ @override
33
+ def writelines(self, # type: ignore[override]
34
+ lines: Iterable[str]) -> None:
35
+ """Write a list of strings to the NoTextIO object.
36
+
37
+ This method does nothing and returns None.
38
+ """
39
+ _ = lines
40
+
41
+ @override
42
+ def flush(self) -> None:
43
+ """Flush the NoTextIO object.
44
+
45
+ This method does nothing and returns None.
46
+ """
47
+
48
+ @override
49
+ def close(self) -> None:
50
+ """Close the NoTextIO object.
51
+
52
+ This method does nothing and returns None.
53
+ """
54
+
55
+ @override
56
+ def seek(self, offset: int, whence: int = io.SEEK_SET, /) -> int:
57
+ """Seek to a position in the NoTextIO object.
58
+
59
+ This method does nothing and returns 0.
60
+ """
61
+ _ = offset
62
+ _ = whence
63
+ return 0
64
+
65
+ @override
66
+ def tell(self) -> int:
67
+ """Get the current position in the NoTextIO object.
68
+
69
+ This method does nothing and returns 0.
70
+ """
71
+ return 0
72
+
73
+ @override
74
+ def truncate(self, size: int | None = None, /) -> int:
75
+ """Truncate the NoTextIO object.
76
+
77
+ This method does nothing and returns 0.
78
+ """
79
+ _ = size
80
+ return 0
81
+
82
+ @override
83
+ def __enter__(self) -> 'NoTextIO':
84
+ """Enter the NoTextIO object.
85
+
86
+ This method does nothing and returns the NoTextIO object.
87
+ """
88
+ return self
89
+
90
+ @override
91
+ def __exit__(self, exc_type: type[BaseException] | None,
92
+ exc_value: BaseException | None,
93
+ traceback: TracebackType | None) -> None:
94
+ """Exit the NoTextIO object.
95
+
96
+ This method does nothing.
97
+ """
98
+ _ = exc_type
99
+ _ = exc_value
100
+ _ = traceback
@@ -0,0 +1,381 @@
1
+ #! /usr/local/bin/python3
2
+ """Order a backlog by dependencies."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ import heapq
9
+ from enum import Enum, auto
10
+ from typing import Optional, TextIO
11
+ from collections.abc import Sequence
12
+ from backlogops.backlog import Backlog, BacklogItem, DEPENDENCY_FIELDS
13
+ from backlogops.backlog import build_dependency_graph, event_start
14
+
15
+
16
+ class DependencyMode(Enum):
17
+ """Mode to determine backlog position of items with dependencies.
18
+
19
+ EARLY: All items that take part in a dependency are placed as early
20
+ as possible, before the items that take part in no
21
+ dependency. This packs the dependency chains at the front and
22
+ leaves a buffer of independent items after the last dependent
23
+ item, to reduce the risk of delays in a chain of dependencies.
24
+ EVEN: Items that take part in a dependency are spread out so that
25
+ the dependency chains are as evenly distributed as possible
26
+ over the complete backlog. The independent items fill the gaps
27
+ between them. This may create a small time buffer between each
28
+ item in a dependency chain.
29
+ KEEP: Items that take part in no dependency keep their original
30
+ relative order, and only the items that take part in a
31
+ dependency are moved, and only as far as a dependency forces
32
+ them to move. The idea is that someone has already put work
33
+ into the order of the backlog, and we should not change it
34
+ without a good reason. This is the default behavior.
35
+ """
36
+
37
+ EARLY = auto()
38
+ EVEN = auto()
39
+ KEEP = auto()
40
+
41
+
42
+ def _normalize_space_around(space_around: Optional[str | Sequence[str]],
43
+ stderr_file: TextIO) -> list[str]:
44
+ """Return the space_around argument as a list of key strings.
45
+
46
+ A single key is wrapped in a one element list and a sequence of keys
47
+ is copied into a new list. ``None`` becomes an empty list.
48
+
49
+ Raises:
50
+ TypeError: If the argument is neither None, a string, nor a
51
+ sequence of strings.
52
+ """
53
+ if space_around is None:
54
+ return []
55
+ if isinstance(space_around, str):
56
+ return [space_around]
57
+ if isinstance(space_around, Sequence) and \
58
+ all(isinstance(key, str) for key in space_around):
59
+ return list(space_around)
60
+ message = 'space_around must be a string or a sequence of strings'
61
+ print(message, file=stderr_file)
62
+ raise TypeError(message)
63
+
64
+
65
+ def _space_around_limit(item_count: int) -> int:
66
+ """Return the largest allowed number of space_around items.
67
+
68
+ The limit is five items for a backlog of at least fifty items, and
69
+ ten percent of the backlog (but at least one) for a smaller backlog.
70
+ """
71
+ if item_count >= 50:
72
+ return 5
73
+ return max(1, item_count // 10)
74
+
75
+
76
+ def _check_space_around(keys: Sequence[str], backlog: Backlog,
77
+ stderr_file: TextIO) -> None:
78
+ """Check that the space_around keys exist and are not too many.
79
+
80
+ Raises:
81
+ KeyError: If a key is not the key of a backlog item.
82
+ RuntimeError: If there are more keys than the allowed limit.
83
+ """
84
+ present = {item.key for item in backlog}
85
+ for key in keys:
86
+ if key not in present:
87
+ message = f'space_around key not found in backlog: {key!r}'
88
+ print(message, file=stderr_file)
89
+ raise KeyError(message)
90
+ limit = _space_around_limit(len(backlog))
91
+ if len(keys) > limit:
92
+ message = (f'space_around has {len(keys)} keys, more than the '
93
+ f'allowed {limit}')
94
+ print(message, file=stderr_file)
95
+ raise RuntimeError(message)
96
+
97
+
98
+ def _item_dependencies(item: BacklogItem) -> list[str]:
99
+ """Return the keys that one item depends on (explicit and parent)."""
100
+ keys: list[str] = []
101
+ for dep_field in DEPENDENCY_FIELDS:
102
+ keys.extend(getattr(item, dep_field))
103
+ if item.parent_key is not None:
104
+ keys.append(item.parent_key)
105
+ return keys
106
+
107
+
108
+ def _has_dependencies(backlog: Backlog) -> bool:
109
+ """Tell whether any backlog item takes part in a dependency."""
110
+ return any(_item_dependencies(item) for item in backlog)
111
+
112
+
113
+ def _linked_items(backlog: Backlog) -> set[str]:
114
+ """Return the keys of items that take part in any dependency.
115
+
116
+ An item is linked when it depends on another item or is depended on
117
+ by another item, counting both the explicit dependency lists and the
118
+ parent relations.
119
+ """
120
+ linked: set[str] = set()
121
+ for item in backlog:
122
+ dependencies = _item_dependencies(item)
123
+ if dependencies:
124
+ linked.add(item.key)
125
+ linked.update(dependencies)
126
+ return linked
127
+
128
+
129
+ def _seed_events(backlog: Backlog) -> list[str]:
130
+ """Return all start and finish events in original backlog order."""
131
+ events: list[str] = []
132
+ for item in backlog:
133
+ events.append(event_start(item.key))
134
+ events.append(f'{item.key}:finish')
135
+ return events
136
+
137
+
138
+ def _forward_events(graph: dict[str, list[str]],
139
+ seed: Sequence[str]) -> list[str]:
140
+ """Order events with each prerequisite emitted just before its user.
141
+
142
+ A depth first search visits the events in original order and emits
143
+ every event after all the events it depends on. This pulls a
144
+ prerequisite to a position just before the dependent item, while the
145
+ dependent item keeps its original position as much as possible.
146
+ """
147
+ visited: set[str] = set()
148
+ order: list[str] = []
149
+
150
+ def visit(event: str) -> None:
151
+ if event in visited:
152
+ return
153
+ visited.add(event)
154
+ for dependency in graph.get(event, []):
155
+ visit(dependency)
156
+ order.append(event)
157
+ for event in seed:
158
+ visit(event)
159
+ return order
160
+
161
+
162
+ def _back_events(graph: dict[str, list[str]],
163
+ seed: Sequence[str]) -> list[str]:
164
+ """Order events delaying each dependent event as long as possible.
165
+
166
+ A stable topological sort always emits the ready event with the
167
+ smallest original position. This keeps the independent events early
168
+ and pushes a dependent event as late as its prerequisites allow.
169
+ """
170
+ rank = {event: index for index, event in enumerate(seed)}
171
+ pending = {event: set(graph.get(event, [])) for event in seed}
172
+ dependents: dict[str, list[str]] = {}
173
+ for event, dependencies in pending.items():
174
+ for dependency in dependencies:
175
+ dependents.setdefault(dependency, []).append(event)
176
+ ready = [(rank[event], event)
177
+ for event, deps in pending.items() if not deps]
178
+ heapq.heapify(ready)
179
+ order: list[str] = []
180
+ while ready:
181
+ _, event = heapq.heappop(ready)
182
+ order.append(event)
183
+ for dependent in dependents.get(event, []):
184
+ pending[dependent].discard(event)
185
+ if not pending[dependent]:
186
+ heapq.heappush(ready, (rank[dependent], dependent))
187
+ return order
188
+
189
+
190
+ def _topo_item_order(backlog: Backlog, later: bool) -> list[str]:
191
+ """Return the item keys in dependency order, projected from events.
192
+
193
+ The events of the backlog are topologically sorted, honoring the
194
+ direction given by ``later``, and the item order is read off from the
195
+ order of the start events. The backlog position of an item is the
196
+ order in which a team starts to work on it.
197
+ """
198
+ graph = build_dependency_graph(backlog)
199
+ seed = _seed_events(backlog)
200
+ events = _back_events(graph, seed) if later \
201
+ else _forward_events(graph, seed)
202
+ start_to_key = {event_start(item.key): item.key for item in backlog}
203
+ return [start_to_key[event] for event in events if event in start_to_key]
204
+
205
+
206
+ def _merge_even(linked: Sequence[str], unlinked: Sequence[str]) -> list[str]:
207
+ """Merge linked and unlinked keys, spreading linked keys evenly.
208
+
209
+ The linked keys keep their given order and the unlinked keys keep
210
+ their given order. The linked keys are placed at evenly spaced
211
+ positions over the whole result, and the unlinked keys fill the gaps.
212
+ """
213
+ total = len(linked) + len(unlinked)
214
+ if not linked:
215
+ return list(unlinked)
216
+ targets = [(index + 0.5) * total / len(linked)
217
+ for index in range(len(linked))]
218
+ result: list[str] = []
219
+ next_linked = 0
220
+ next_unlinked = 0
221
+ for position in range(total):
222
+ due = next_linked < len(linked) and \
223
+ targets[next_linked] <= position + 0.5
224
+ if (due or next_unlinked >= len(unlinked)) and \
225
+ next_linked < len(linked):
226
+ result.append(linked[next_linked])
227
+ next_linked += 1
228
+ else:
229
+ result.append(unlinked[next_unlinked])
230
+ next_unlinked += 1
231
+ return result
232
+
233
+
234
+ def _arrange_by_mode(topo: Sequence[str], backlog: Backlog,
235
+ mode: DependencyMode) -> list[str]:
236
+ """Place the dependency-ordered keys according to the chosen mode."""
237
+ if mode is DependencyMode.KEEP:
238
+ return list(topo)
239
+ linked = _linked_items(backlog)
240
+ linked_order = [key for key in topo if key in linked]
241
+ unlinked_order = [item.key for item in backlog
242
+ if item.key not in linked]
243
+ if mode is DependencyMode.EARLY:
244
+ return linked_order + unlinked_order
245
+ return _merge_even(linked_order, unlinked_order)
246
+
247
+
248
+ def _start_reachable(graph: dict[str, list[str]], item: BacklogItem,
249
+ backlog: Backlog) -> set[str]:
250
+ """Return the keys of items that must start before the given item.
251
+
252
+ The search follows the dependency edges from the start event of the
253
+ item. Reaching either the start or the finish event of another item
254
+ means that other item must start before this item.
255
+ """
256
+ start_to_key = {event_start(other.key): other.key for other in backlog}
257
+ finish_to_key = {f'{other.key}:finish': other.key for other in backlog}
258
+ seen: set[str] = set()
259
+ stack = [event_start(item.key)]
260
+ while stack:
261
+ event = stack.pop()
262
+ for dependency in graph.get(event, []):
263
+ if dependency not in seen:
264
+ seen.add(dependency)
265
+ stack.append(dependency)
266
+ keys: set[str] = set()
267
+ for event in seen:
268
+ key = start_to_key.get(event) or finish_to_key.get(event)
269
+ if key is not None and key != item.key:
270
+ keys.add(key)
271
+ return keys
272
+
273
+
274
+ def _precedence(backlog: Backlog) -> tuple[dict[str, set[str]],
275
+ dict[str, set[str]]]:
276
+ """Return the must-start-before and must-start-after item relations."""
277
+ graph = build_dependency_graph(backlog)
278
+ before = {item.key: _start_reachable(graph, item, backlog)
279
+ for item in backlog}
280
+ after: dict[str, set[str]] = {item.key: set() for item in backlog}
281
+ for key, prerequisites in before.items():
282
+ for prerequisite in prerequisites:
283
+ after[prerequisite].add(key)
284
+ return before, after
285
+
286
+
287
+ def _space_one(order: Sequence[str], key: str, prereqs: set[str],
288
+ dependents: set[str]) -> list[str]:
289
+ """Reposition one key to maximize the space to its dependencies.
290
+
291
+ The prerequisites of the key are moved as early as possible and the
292
+ items that depend on the key are moved as late as possible, keeping
293
+ their relative order. The key is then placed among the remaining
294
+ items: at the front when it has no prerequisite, at the back when it
295
+ has no dependent, and in the middle otherwise. This places as many
296
+ other items as possible between the key and its dependencies.
297
+ """
298
+ front = [item for item in order if item in prereqs]
299
+ back = [item for item in order if item in dependents]
300
+ middle = [item for item in order if item != key
301
+ and item not in prereqs and item not in dependents]
302
+ if not prereqs:
303
+ insert = 0
304
+ elif not dependents:
305
+ insert = len(middle)
306
+ else:
307
+ insert = len(middle) // 2
308
+ return front + middle[:insert] + [key] + middle[insert:] + back
309
+
310
+
311
+ def _apply_space_around(order: list[str], keys: Sequence[str],
312
+ backlog: Backlog) -> list[str]:
313
+ """Reposition each space_around key to the middle of its slack."""
314
+ before, after = _precedence(backlog)
315
+ for key in keys:
316
+ order = _space_one(order, key, before[key], after[key])
317
+ return order
318
+
319
+
320
+ def order_by_dependencies(backlog: Backlog, *, later: bool = False,
321
+ mode: DependencyMode = DependencyMode.KEEP,
322
+ space_around: Optional[str | Sequence[str]] = None,
323
+ stderr_file: TextIO = sys.stderr) -> Backlog:
324
+ """Order a backlog by dependencies.
325
+
326
+ A new backlog is returned; the argument is not modified. If no item
327
+ takes part in any dependency the argument backlog is returned
328
+ unchanged (the same object). The backlog is ordered so that a team
329
+ can start the items in backlog order without starting an item before
330
+ the items it depends on. The dependencies are taken from the start
331
+ and finish event graph of the backlog, which combines the explicit
332
+ dependency lists with the implicit parent relations. The backlog
333
+ position of an item is the order in which the team starts it, so only
334
+ a dependency that constrains the start of an item moves that item;
335
+ a finish-to-finish dependency, which only constrains completion, does
336
+ not move an item by itself.
337
+
338
+ Args:
339
+ backlog: The backlog to order. The argument is not modified.
340
+ later: How a dependency that is not yet satisfied is resolved.
341
+ If False (the default) the prerequisite item is pulled to a
342
+ position just before the dependent item, and the dependent
343
+ item keeps its position. If True the dependent item is pushed
344
+ to a position just after its prerequisites, and the
345
+ prerequisite items keep their position.
346
+ mode: How items that take part in a dependency are placed in
347
+ relation to items that take part in no dependency, as
348
+ documented for :class:`DependencyMode`. The default is KEEP.
349
+ space_around: Key or keys of items that should have as many other
350
+ items as possible placed between them and the items they
351
+ depend on, and between them and the items that depend on them.
352
+ For each named item the prerequisites are pulled as early as
353
+ possible and the items that depend on it are pushed as late as
354
+ possible, and the named item is centered among the remaining
355
+ items. This is useful when there is a big risk of delays in a
356
+ chain of dependencies. It only works well for one or very few
357
+ items. None means no item is treated this way.
358
+ stderr_file: The file to report errors to.
359
+
360
+ Returns:
361
+ A new backlog with the items ordered by dependencies, or the
362
+ argument backlog unchanged when no item takes part in a
363
+ dependency.
364
+
365
+ Raises:
366
+ TypeError: If space_around is neither None, a string, nor a
367
+ sequence of strings.
368
+ KeyError: If a space_around key is not the key of a backlog item.
369
+ RuntimeError: If space_around names more keys than allowed: more
370
+ than five, or more than ten percent of a backlog of fewer
371
+ than fifty items.
372
+ """
373
+ space_keys = _normalize_space_around(space_around, stderr_file)
374
+ _check_space_around(space_keys, backlog, stderr_file)
375
+ if not _has_dependencies(backlog):
376
+ return backlog
377
+ topo = _topo_item_order(backlog, later)
378
+ order = _arrange_by_mode(topo, backlog, mode)
379
+ order = _apply_space_around(order, space_keys, backlog)
380
+ by_key = {item.key: item for item in backlog}
381
+ return [by_key[key] for key in order]
backlogops/person.py ADDED
@@ -0,0 +1,25 @@
1
+ #! /usr/local/bin/python3
2
+ """Define a person including any exceptions in work hours."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ from dataclasses import dataclass, field
8
+ from backlogops.work_hours import ExceptionWorkHours
9
+
10
+
11
+ @dataclass
12
+ class Person:
13
+ """Define a person including any exceptions in work hours."""
14
+
15
+ name: str
16
+ """The name of the person."""
17
+
18
+ exceptions: list[ExceptionWorkHours] = field(default_factory=list)
19
+ """Any exceptions in work hours for the person.
20
+
21
+ These exceptions are used to mark personal vacation days,
22
+ and other planned days off. They can also mark any period
23
+ of time the person has other work hours, for instance periods
24
+ of part-time work or ordered over-time work.
25
+ """
backlogops/py.typed ADDED
File without changes