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,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]
|
backlogops/no_text_io.py
ADDED
|
@@ -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
|