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
backlogops/io_config.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Configuration for reading and writing tables with TableIO.
|
|
3
|
+
|
|
4
|
+
A backlog and its releases are read from and written to tabular files
|
|
5
|
+
(Excel, ODS, CSV, and more) using TableIO. The durable TableIO settings
|
|
6
|
+
for one input or one output are stored as a :class:`TioJsonConfig`. On
|
|
7
|
+
top of that this module adds a per-endpoint column-name map, so the
|
|
8
|
+
columns shown to a user can have other names than the internal field
|
|
9
|
+
names of the data model.
|
|
10
|
+
|
|
11
|
+
An input endpoint is described by an :class:`InputFormatConfig` and an
|
|
12
|
+
output endpoint by an :class:`OutputFormatConfig`. Both wrap one
|
|
13
|
+
``TioJsonConfig`` and one direction-specific name map:
|
|
14
|
+
|
|
15
|
+
* an input map (``to_internal``) translates an external column name to
|
|
16
|
+
an internal field name, and several external names may map to the same
|
|
17
|
+
internal field;
|
|
18
|
+
* an output map (``to_external``) translates an internal field name to
|
|
19
|
+
the external column name to write.
|
|
20
|
+
|
|
21
|
+
:func:`resolve_input_config` and :func:`resolve_output_config` turn a
|
|
22
|
+
command-line value into such a configuration. The value may be empty
|
|
23
|
+
(then the format is inferred from the data file name extension), a preset
|
|
24
|
+
name (looked up among named presets stored elsewhere, typically in the
|
|
25
|
+
teams configuration file), or the name of a stand-alone configuration
|
|
26
|
+
file.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
30
|
+
# MIT License
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import ClassVar, Optional, TextIO, override
|
|
37
|
+
from config_as_json import Config, ConfigNesting, ConfigNestingKind, \
|
|
38
|
+
DictKeyValueTypesValidator, MemberValidationStep, NestedConfigs, \
|
|
39
|
+
PathOrStr, ValidationPlan
|
|
40
|
+
from tableio import Capabilities, FileAccess, access_capabilities
|
|
41
|
+
from tableio_cfg_json import TioJsonConfig, tio_json_config_default
|
|
42
|
+
|
|
43
|
+
EXTENSION_FORMATS: dict[str, str] = {
|
|
44
|
+
'.csv': 'CSV', '.xlsx': 'Excel', '.xls': 'Excel', '.ods': 'ODS',
|
|
45
|
+
'.html': 'HTML', '.htm': 'HTML', '.tex': 'LaTeX', '.md': 'md',
|
|
46
|
+
'.docx': 'docx', '.odt': 'odt', '.pdf': 'pdf', '.rst': 'reST',
|
|
47
|
+
'.rtf': 'rtf', '.txt': 'txt'}
|
|
48
|
+
"""Map a data file name extension to a TableIO format name."""
|
|
49
|
+
|
|
50
|
+
PRESET_NAME_RE = re.compile(r'^[A-Za-z0-9]+$')
|
|
51
|
+
"""A configuration value made only of letters and digits is a preset."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _capabilities(file_access: FileAccess, stderr_file: TextIO
|
|
55
|
+
) -> Capabilities:
|
|
56
|
+
"""Return the TableIO capabilities implied by a file access mode."""
|
|
57
|
+
return access_capabilities(file_access, error_file=stderr_file)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tio_default(file_access: FileAccess, format_name: Optional[str] = None,
|
|
61
|
+
stderr_file: TextIO = sys.stderr) -> TioJsonConfig:
|
|
62
|
+
"""Return a default TableIO config for a format and file access."""
|
|
63
|
+
return tio_json_config_default(
|
|
64
|
+
capabilities=_capabilities(file_access, stderr_file),
|
|
65
|
+
file_access=file_access, format_name=format_name,
|
|
66
|
+
stderr_file=stderr_file)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tio_from_json(file_access: FileAccess, from_json_data_text: Optional[str],
|
|
70
|
+
from_json_filename: Optional[PathOrStr],
|
|
71
|
+
stderr_file: TextIO) -> TioJsonConfig:
|
|
72
|
+
"""Return a TableIO config read from JSON for a file access mode."""
|
|
73
|
+
return TioJsonConfig(capabilities=_capabilities(file_access, stderr_file),
|
|
74
|
+
file_access=file_access,
|
|
75
|
+
from_json_data_text=from_json_data_text,
|
|
76
|
+
from_json_filename=from_json_filename,
|
|
77
|
+
stderr_file=stderr_file)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _FormatConfig(Config):
|
|
81
|
+
"""Shared behavior for one input or output TableIO endpoint config.
|
|
82
|
+
|
|
83
|
+
A concrete subclass fixes the file access mode and the name of its
|
|
84
|
+
column-name map member, and declares that map member before calling
|
|
85
|
+
the constructor. The wrapped ``TioJsonConfig`` is created here and
|
|
86
|
+
declared as a nested configuration so it reads and writes itself.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_FILE_ACCESS: ClassVar[FileAccess]
|
|
90
|
+
_MAP_NAME: ClassVar[str]
|
|
91
|
+
|
|
92
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
93
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
94
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
95
|
+
"""Create default settings or read them from a JSON source."""
|
|
96
|
+
self.tableio: TioJsonConfig = _tio_default(self._FILE_ACCESS,
|
|
97
|
+
stderr_file=stderr_file)
|
|
98
|
+
self._unchecked_dicts = [self._MAP_NAME]
|
|
99
|
+
Config.__init__(self, from_json_data_text=from_json_data_text,
|
|
100
|
+
from_json_filename=from_json_filename,
|
|
101
|
+
stderr_file=stderr_file)
|
|
102
|
+
|
|
103
|
+
def _tio_factory(self, *, from_json_data_text: Optional[str] = None,
|
|
104
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
105
|
+
stderr_file: TextIO = sys.stderr) -> TioJsonConfig:
|
|
106
|
+
"""Construct the nested TableIO config from JSON when reading."""
|
|
107
|
+
return _tio_from_json(self._FILE_ACCESS, from_json_data_text,
|
|
108
|
+
from_json_filename, stderr_file)
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
def nested_configs(self) -> NestedConfigs:
|
|
112
|
+
"""Declare the wrapped TableIO config as a nested configuration."""
|
|
113
|
+
return {'tableio': ConfigNesting(kind=ConfigNestingKind.MEMBER,
|
|
114
|
+
config_type=TioJsonConfig,
|
|
115
|
+
factory_function=self._tio_factory)}
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def get_validation_plan(self, stderr_file: TextIO) -> ValidationPlan:
|
|
119
|
+
"""Check that the column-name map is a mapping of string to string."""
|
|
120
|
+
_ = stderr_file
|
|
121
|
+
return [MemberValidationStep(
|
|
122
|
+
member_names=[self._MAP_NAME],
|
|
123
|
+
validator=DictKeyValueTypesValidator(str, str))]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class InputFormatConfig(_FormatConfig):
|
|
127
|
+
"""TableIO input endpoint with an external-to-internal column map."""
|
|
128
|
+
|
|
129
|
+
_FILE_ACCESS = FileAccess.READ
|
|
130
|
+
_MAP_NAME = 'to_internal'
|
|
131
|
+
to_internal: dict[str, str]
|
|
132
|
+
|
|
133
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
134
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
135
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
136
|
+
"""Create the input map default, then run the shared constructor."""
|
|
137
|
+
self.to_internal = {}
|
|
138
|
+
_FormatConfig.__init__(self, from_json_data_text=from_json_data_text,
|
|
139
|
+
from_json_filename=from_json_filename,
|
|
140
|
+
stderr_file=stderr_file)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class OutputFormatConfig(_FormatConfig):
|
|
144
|
+
"""TableIO output endpoint with an internal-to-external column map."""
|
|
145
|
+
|
|
146
|
+
_FILE_ACCESS = FileAccess.CREATE
|
|
147
|
+
_MAP_NAME = 'to_external'
|
|
148
|
+
to_external: dict[str, str]
|
|
149
|
+
|
|
150
|
+
def __init__(self, from_json_data_text: Optional[str] = None,
|
|
151
|
+
from_json_filename: Optional[PathOrStr] = None,
|
|
152
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
153
|
+
"""Create the output map default, then run the shared constructor."""
|
|
154
|
+
self.to_external = {}
|
|
155
|
+
_FormatConfig.__init__(self, from_json_data_text=from_json_data_text,
|
|
156
|
+
from_json_filename=from_json_filename,
|
|
157
|
+
stderr_file=stderr_file)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def make_input_config(tableio: TioJsonConfig, to_internal: dict[str, str],
|
|
161
|
+
stderr_file: TextIO = sys.stderr) -> InputFormatConfig:
|
|
162
|
+
"""Return an input config from a TableIO config and a column map."""
|
|
163
|
+
config = InputFormatConfig(stderr_file=stderr_file)
|
|
164
|
+
config.tableio = tableio
|
|
165
|
+
config.to_internal = dict(to_internal)
|
|
166
|
+
return config
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def make_output_config(tableio: TioJsonConfig, to_external: dict[str, str],
|
|
170
|
+
stderr_file: TextIO = sys.stderr) -> OutputFormatConfig:
|
|
171
|
+
"""Return an output config from a TableIO config and a column map."""
|
|
172
|
+
config = OutputFormatConfig(stderr_file=stderr_file)
|
|
173
|
+
config.tableio = tableio
|
|
174
|
+
config.to_external = dict(to_external)
|
|
175
|
+
return config
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_from_suffix(data_file: PathOrStr) -> str:
|
|
179
|
+
"""Return the TableIO format name implied by a data file extension."""
|
|
180
|
+
suffix = Path(data_file).suffix.lower()
|
|
181
|
+
format_name = EXTENSION_FORMATS.get(suffix)
|
|
182
|
+
if format_name is None:
|
|
183
|
+
known = ', '.join(sorted(EXTENSION_FORMATS))
|
|
184
|
+
raise ValueError(f'Cannot infer a TableIO format from {suffix!r}. '
|
|
185
|
+
f'Known extensions: {known}.')
|
|
186
|
+
return format_name
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _default_input(data_file: PathOrStr,
|
|
190
|
+
stderr_file: TextIO) -> InputFormatConfig:
|
|
191
|
+
"""Return an input config with the format inferred from the file name."""
|
|
192
|
+
config = InputFormatConfig(stderr_file=stderr_file)
|
|
193
|
+
config.tableio = _tio_default(FileAccess.READ,
|
|
194
|
+
_format_from_suffix(data_file), stderr_file)
|
|
195
|
+
return config
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _default_output(data_file: PathOrStr,
|
|
199
|
+
stderr_file: TextIO) -> OutputFormatConfig:
|
|
200
|
+
"""Return an output config with the format inferred from the file name."""
|
|
201
|
+
config = OutputFormatConfig(stderr_file=stderr_file)
|
|
202
|
+
config.tableio = _tio_default(FileAccess.CREATE,
|
|
203
|
+
_format_from_suffix(data_file), stderr_file)
|
|
204
|
+
return config
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _preset(value: str, presets: Optional[Mapping[str, _FormatConfig]]
|
|
208
|
+
) -> _FormatConfig:
|
|
209
|
+
"""Return the named preset, or raise when it cannot be found."""
|
|
210
|
+
if presets is None or value not in presets:
|
|
211
|
+
available = ', '.join(sorted(presets)) if presets else 'none'
|
|
212
|
+
raise ValueError(f'Unknown configuration preset {value!r}. '
|
|
213
|
+
f'Available presets: {available}.')
|
|
214
|
+
return presets[value]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_input_config(
|
|
218
|
+
value: Optional[str], *, data_file: PathOrStr,
|
|
219
|
+
presets: Optional[dict[str, InputFormatConfig]] = None,
|
|
220
|
+
stderr_file: TextIO = sys.stderr) -> InputFormatConfig:
|
|
221
|
+
"""Resolve a command-line input config value to an input config.
|
|
222
|
+
|
|
223
|
+
An empty ``value`` infers the format from ``data_file``. A value of
|
|
224
|
+
only letters and digits is a preset name looked up in ``presets``.
|
|
225
|
+
Any other value is the path of a stand-alone input config file.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
value: The ``--input-config`` value, or None for inference.
|
|
229
|
+
data_file: The input data file, used for format inference.
|
|
230
|
+
presets: Named input presets, typically from the teams config.
|
|
231
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The resolved input configuration.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ValueError: The format cannot be inferred or the preset is unknown.
|
|
238
|
+
"""
|
|
239
|
+
if value is None:
|
|
240
|
+
return _default_input(data_file, stderr_file)
|
|
241
|
+
if PRESET_NAME_RE.match(value):
|
|
242
|
+
preset = _preset(value, presets)
|
|
243
|
+
assert isinstance(preset, InputFormatConfig)
|
|
244
|
+
return preset
|
|
245
|
+
return InputFormatConfig(from_json_filename=value, stderr_file=stderr_file)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_output_config(
|
|
249
|
+
value: Optional[str], *, data_file: PathOrStr,
|
|
250
|
+
presets: Optional[dict[str, OutputFormatConfig]] = None,
|
|
251
|
+
stderr_file: TextIO = sys.stderr) -> OutputFormatConfig:
|
|
252
|
+
"""Resolve a command-line output config value to an output config.
|
|
253
|
+
|
|
254
|
+
An empty ``value`` infers the format from ``data_file``. A value of
|
|
255
|
+
only letters and digits is a preset name looked up in ``presets``.
|
|
256
|
+
Any other value is the path of a stand-alone output config file.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
value: The ``--output-config`` value, or None for inference.
|
|
260
|
+
data_file: The output data file, used for format inference.
|
|
261
|
+
presets: Named output presets, typically from the teams config.
|
|
262
|
+
stderr_file: Stream used for user-facing diagnostics.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The resolved output configuration.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: The format cannot be inferred or the preset is unknown.
|
|
269
|
+
"""
|
|
270
|
+
if value is None:
|
|
271
|
+
return _default_output(data_file, stderr_file)
|
|
272
|
+
if PRESET_NAME_RE.match(value):
|
|
273
|
+
preset = _preset(value, presets)
|
|
274
|
+
assert isinstance(preset, OutputFormatConfig)
|
|
275
|
+
return preset
|
|
276
|
+
return OutputFormatConfig(from_json_filename=value,
|
|
277
|
+
stderr_file=stderr_file)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
"""Read and write a key list as its own file.
|
|
3
|
+
|
|
4
|
+
A key list is an ordered list of backlog item keys stored on its own,
|
|
5
|
+
separate from the backlog. The file format is chosen from the file name
|
|
6
|
+
extension: a ``.txt`` or ``.dat`` file is plain UTF-8 text, and any
|
|
7
|
+
extension that TableIO supports (such as ``.csv``, ``.ods`` or ``.xlsx``)
|
|
8
|
+
is a one column table.
|
|
9
|
+
|
|
10
|
+
Two options apply to both shapes and describe whether the file carries a
|
|
11
|
+
column name. ``skip_column_names`` tells the reader that the first
|
|
12
|
+
row/line is a column name to skip, and ``add_column_name`` tells the
|
|
13
|
+
writer to write such a column name (``Keys``).
|
|
14
|
+
|
|
15
|
+
For a text file without a column name every whitespace separated word is
|
|
16
|
+
a key, in the order the words appear; with a column name the heading line
|
|
17
|
+
is skipped and every following non empty line holds exactly one key.
|
|
18
|
+
|
|
19
|
+
For a table file without a column name every row is data (read with list
|
|
20
|
+
reading); with a column name the first row names the column (read with
|
|
21
|
+
dict reading). A single column table usually has no column name, but it
|
|
22
|
+
may have one. Either way the table must have exactly one column.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
26
|
+
# MIT License
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TextIO
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
import sys
|
|
32
|
+
from config_as_json import PathOrStr
|
|
33
|
+
from tableio import DictData, FileAccess, ListData, Value, \
|
|
34
|
+
access_capabilities, tio_config_create
|
|
35
|
+
from backlogops.backlog_helpers import report_bad_value
|
|
36
|
+
from backlogops.io_config import resolve_input_config
|
|
37
|
+
from backlogops.table_create import create_output_table
|
|
38
|
+
|
|
39
|
+
TEXT_EXTENSIONS = {'.txt', '.dat'}
|
|
40
|
+
"""File name extensions read and written as plain UTF-8 text."""
|
|
41
|
+
|
|
42
|
+
KEY_COLUMN_NAME = 'Keys'
|
|
43
|
+
"""Column name of the single column of a key list table."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_text(file_name: PathOrStr) -> bool:
|
|
47
|
+
"""Return whether a key list file is plain text rather than a table."""
|
|
48
|
+
return Path(file_name).suffix.lower() in TEXT_EXTENSIONS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _column_keys(text: str, stderr_file: TextIO) -> list[str]:
|
|
52
|
+
"""Return one key per line of a column text file, after the heading.
|
|
53
|
+
|
|
54
|
+
The first line is the column heading and is skipped. Every following
|
|
55
|
+
non empty line must hold exactly one word, which is the key.
|
|
56
|
+
"""
|
|
57
|
+
keys: list[str] = []
|
|
58
|
+
for line in text.splitlines()[1:]:
|
|
59
|
+
words = line.split()
|
|
60
|
+
if not words:
|
|
61
|
+
continue
|
|
62
|
+
if len(words) > 1:
|
|
63
|
+
report_bad_value('key', line.strip(),
|
|
64
|
+
'a column key list allows only one key per line',
|
|
65
|
+
stderr_file, 'Key list')
|
|
66
|
+
keys.append(words[0])
|
|
67
|
+
return keys
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_text(file_name: PathOrStr, skip_column_names: bool,
|
|
71
|
+
stderr_file: TextIO) -> list[str]:
|
|
72
|
+
"""Return the keys of a plain text key list file."""
|
|
73
|
+
text = Path(file_name).read_text(encoding='utf-8')
|
|
74
|
+
if skip_column_names:
|
|
75
|
+
return _column_keys(text, stderr_file)
|
|
76
|
+
return text.split()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_one_column(width: int, stderr_file: TextIO) -> None:
|
|
80
|
+
"""Report a table that does not have exactly one column."""
|
|
81
|
+
if width > 1:
|
|
82
|
+
report_bad_value('columns', width,
|
|
83
|
+
'a key list table must have exactly one column',
|
|
84
|
+
stderr_file, 'Key list')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cell_keys(values: list[Value]) -> list[str]:
|
|
88
|
+
"""Return the non empty cell values of one table column as strings."""
|
|
89
|
+
return [str(value) for value in values
|
|
90
|
+
if value is not None and str(value) != '']
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _keys_from_dict(rows: DictData[Value], stderr_file: TextIO) -> list[str]:
|
|
94
|
+
"""Return the keys of a one column table read with dict reading."""
|
|
95
|
+
if not rows:
|
|
96
|
+
return []
|
|
97
|
+
_check_one_column(len(rows[0]), stderr_file)
|
|
98
|
+
column = next(iter(rows[0]))
|
|
99
|
+
return _cell_keys([row[column] for row in rows])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _keys_from_list(rows: ListData[Value], stderr_file: TextIO) -> list[str]:
|
|
103
|
+
"""Return the keys of a one column table read with list reading."""
|
|
104
|
+
if not rows:
|
|
105
|
+
return []
|
|
106
|
+
_check_one_column(len(rows[0]), stderr_file)
|
|
107
|
+
return _cell_keys([row[0] for row in rows])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_table(file_name: PathOrStr, skip_column_names: bool,
|
|
111
|
+
stderr_file: TextIO) -> list[str]:
|
|
112
|
+
"""Return the keys of a key list stored as a one column table."""
|
|
113
|
+
config = resolve_input_config(None, data_file=file_name,
|
|
114
|
+
stderr_file=stderr_file).tableio
|
|
115
|
+
capabilities = access_capabilities(FileAccess.READ, error_file=stderr_file)
|
|
116
|
+
with tio_config_create(config=config, file_name=file_name,
|
|
117
|
+
file_access=FileAccess.READ,
|
|
118
|
+
capabilities=capabilities) as tableio:
|
|
119
|
+
if skip_column_names:
|
|
120
|
+
keys = _keys_from_dict(tableio.read_table_dictdata().data,
|
|
121
|
+
stderr_file)
|
|
122
|
+
else:
|
|
123
|
+
keys = _keys_from_list(tableio.read_table_listdata().data,
|
|
124
|
+
stderr_file)
|
|
125
|
+
return keys
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_key_list(file_name: PathOrStr, *, skip_column_names: bool = False,
|
|
129
|
+
stderr_file: TextIO = sys.stderr) -> list[str]:
|
|
130
|
+
"""Read a key list from a file.
|
|
131
|
+
|
|
132
|
+
The file type is chosen from the file name extension. A ``.txt`` or
|
|
133
|
+
``.dat`` file is read as UTF-8 text; any other extension is read as a
|
|
134
|
+
TableIO table whose single column holds the keys.
|
|
135
|
+
|
|
136
|
+
``skip_column_names`` tells whether the file starts with a column
|
|
137
|
+
name. For a text file, when it is False the file is a free word list
|
|
138
|
+
and every whitespace separated word is a key, in the order the words
|
|
139
|
+
appear; when it is True the first line is a column heading and is
|
|
140
|
+
skipped, and every following non empty line must hold exactly one
|
|
141
|
+
word, which is a key. For a table file, when it is False every row is
|
|
142
|
+
data (list reading); when it is True the first row names the column
|
|
143
|
+
and is skipped (dict reading). A table must have exactly one column.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
file_name: The file to read the key list from.
|
|
147
|
+
skip_column_names: Whether the file starts with a column name to
|
|
148
|
+
skip.
|
|
149
|
+
stderr_file: The stream to report errors to.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The keys in the order they appear in the file.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
FileNotFoundError: If the file does not exist.
|
|
156
|
+
IsADirectoryError: If the file is a directory.
|
|
157
|
+
PermissionError: If the file is not readable.
|
|
158
|
+
UnicodeDecodeError: If a text file is not valid UTF-8.
|
|
159
|
+
ValueError: If a column text line holds more than one word, if a
|
|
160
|
+
table has more than one column, or if the extension is not a
|
|
161
|
+
supported table format.
|
|
162
|
+
"""
|
|
163
|
+
if _is_text(file_name):
|
|
164
|
+
return _read_text(file_name, skip_column_names, stderr_file)
|
|
165
|
+
return _read_table(file_name, skip_column_names, stderr_file)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ensure_absent(file_name: PathOrStr, stderr_file: TextIO) -> None:
|
|
169
|
+
"""Raise ``FileExistsError`` when the target file already exists."""
|
|
170
|
+
if Path(file_name).exists():
|
|
171
|
+
message = f'File already exists: {file_name}'
|
|
172
|
+
print(message, file=stderr_file)
|
|
173
|
+
raise FileExistsError(message)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _write_text(key_list: Sequence[str], file_name: PathOrStr,
|
|
177
|
+
add_column_name: bool) -> None:
|
|
178
|
+
"""Write a key list as plain text, one key per line."""
|
|
179
|
+
lines = [KEY_COLUMN_NAME, *key_list] if add_column_name else list(key_list)
|
|
180
|
+
text = ''.join(f'{line}\n' for line in lines)
|
|
181
|
+
Path(file_name).write_text(text, encoding='utf-8')
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_table(key_list: Sequence[str], file_name: PathOrStr,
|
|
185
|
+
add_column_name: bool, stderr_file: TextIO) -> None:
|
|
186
|
+
"""Write a key list as a one column TableIO table."""
|
|
187
|
+
with create_output_table(file_name, stderr_file) as tableio:
|
|
188
|
+
if add_column_name:
|
|
189
|
+
dict_rows: DictData[Value] = [{KEY_COLUMN_NAME: key}
|
|
190
|
+
for key in key_list]
|
|
191
|
+
tableio.write_table_dictdata(dict_rows,
|
|
192
|
+
column_order=[KEY_COLUMN_NAME])
|
|
193
|
+
else:
|
|
194
|
+
tableio.write_table_listdata([[key] for key in key_list])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_key_list(key_list: Sequence[str], file_name: PathOrStr, *,
|
|
198
|
+
add_column_name: bool = False,
|
|
199
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
200
|
+
"""Write a key list to a file.
|
|
201
|
+
|
|
202
|
+
The file type is chosen from the file name extension. A ``.txt`` or
|
|
203
|
+
``.dat`` file is written as UTF-8 text with one key per line; any
|
|
204
|
+
other extension is written as a TableIO table with a single column.
|
|
205
|
+
|
|
206
|
+
``add_column_name`` decides whether the column name ``Keys`` is
|
|
207
|
+
written before the keys: as a heading line for a text file, and as a
|
|
208
|
+
header row for a table file. When it is False a text file holds only
|
|
209
|
+
the keys and a table file holds only data rows (list writing).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
key_list: The keys to write, in order.
|
|
213
|
+
file_name: The file to create.
|
|
214
|
+
add_column_name: Whether to write the column name ``Keys`` first.
|
|
215
|
+
stderr_file: The stream to report errors to.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
FileExistsError: If the file already exists.
|
|
219
|
+
IsADirectoryError: If the file is a directory.
|
|
220
|
+
PermissionError: If the file is not writable.
|
|
221
|
+
ValueError: If the extension is not a supported table format.
|
|
222
|
+
"""
|
|
223
|
+
_ensure_absent(file_name, stderr_file)
|
|
224
|
+
if _is_text(file_name):
|
|
225
|
+
_write_text(key_list, file_name, add_column_name)
|
|
226
|
+
else:
|
|
227
|
+
_write_table(key_list, file_name, add_column_name, stderr_file)
|
backlogops/levels.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Levels of a backlog item."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
import sys
|
|
9
|
+
from typing import NoReturn, TextIO
|
|
10
|
+
from backlogops.backlog_helpers import check_key_syntax, report_bad_value
|
|
11
|
+
from backlogops.backlog_helpers import report_wrong_type
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Level:
|
|
16
|
+
"""A level of a backlog item.
|
|
17
|
+
|
|
18
|
+
Fields:
|
|
19
|
+
level: The level of the backlog item. Required. Must be an integer.
|
|
20
|
+
Usually a positive integer, but can be negative for special
|
|
21
|
+
cases. A higher level means a bigger backlog item.
|
|
22
|
+
A lower level means a smaller, more detailed backlog item.
|
|
23
|
+
name: The name of the level. Required. Must be a string.
|
|
24
|
+
Must not be empty, must not contain whitespace and must
|
|
25
|
+
not contain any of the characters , . ; : ( ) [ ] { }.
|
|
26
|
+
Must be unique within the Levels.
|
|
27
|
+
aliases: The aliases of the level. Optional. Must be a list of strings.
|
|
28
|
+
For instance if level 1 is called "Story", it may have the
|
|
29
|
+
aliases "Task" and "Bug".
|
|
30
|
+
The aliases are used if a backlog item is converted from a
|
|
31
|
+
tool that have different names for the same level.
|
|
32
|
+
Each alias must not be empty, must not contain whitespace and
|
|
33
|
+
must not contain any of the characters , . ; : ( ) [ ] { }.
|
|
34
|
+
Must be unique within the Levels and must not be the same
|
|
35
|
+
as any name used in the Levels.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
level: int
|
|
39
|
+
name: str
|
|
40
|
+
aliases: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def _check_level_type(self, stderr_file: TextIO) -> None:
|
|
43
|
+
"""Check that the level number is an integer (not a bool)."""
|
|
44
|
+
if not isinstance(self.level, int) or isinstance(self.level, bool):
|
|
45
|
+
report_wrong_type('level', self.level, int, stderr_file, 'Level')
|
|
46
|
+
|
|
47
|
+
def _check_labels(self, stderr_file: TextIO) -> None:
|
|
48
|
+
"""Check the name and each alias for valid label syntax."""
|
|
49
|
+
check_key_syntax('name', self.name, stderr_file, 'Level')
|
|
50
|
+
if not isinstance(self.aliases, list):
|
|
51
|
+
report_wrong_type('aliases', self.aliases, list, stderr_file,
|
|
52
|
+
'Level')
|
|
53
|
+
for index, alias in enumerate(self.aliases):
|
|
54
|
+
check_key_syntax(f'aliases[{index}]', alias, stderr_file, 'Level')
|
|
55
|
+
|
|
56
|
+
def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
|
|
57
|
+
"""Check the consistency of the level.
|
|
58
|
+
|
|
59
|
+
The documented constraints are checked on all member variables.
|
|
60
|
+
The name and aliases follow the same character rules as a backlog
|
|
61
|
+
item key. Uniqueness across levels is checked by
|
|
62
|
+
:func:`check_levels_consistency`, not here.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
stderr_file: The file to report errors to.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
TypeError: If a field has the wrong type.
|
|
69
|
+
ValueError: If a field value violates a constraint.
|
|
70
|
+
"""
|
|
71
|
+
self._check_level_type(stderr_file)
|
|
72
|
+
self._check_labels(stderr_file)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
type Levels = dict[int, Level]
|
|
76
|
+
"""A dictionary of levels by level number."""
|
|
77
|
+
|
|
78
|
+
DEFAULT_LEVELS: Levels = {
|
|
79
|
+
0: Level(level=0, name='Sub-Task', aliases=[]),
|
|
80
|
+
1: Level(level=1, name='Story', aliases=['Task', 'Bug']),
|
|
81
|
+
2: Level(level=2, name='Epic', aliases=[]),
|
|
82
|
+
3: Level(level=3, name='Initiative', aliases=[]),
|
|
83
|
+
}
|
|
84
|
+
"""The default levels for backlog items."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def report_duplicate_label(label: str, existing: str,
|
|
88
|
+
stderr_file: TextIO = sys.stderr) -> NoReturn:
|
|
89
|
+
"""Report a duplicate level name or alias and raise ``KeyError``.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
label: The label that duplicates an earlier one.
|
|
93
|
+
existing: The earlier label it collides with.
|
|
94
|
+
stderr_file: The file to report the error to.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
KeyError: Always, after reporting the message.
|
|
98
|
+
"""
|
|
99
|
+
message = (f'Level name or alias {label!r} duplicates {existing!r} '
|
|
100
|
+
f'(case-insensitive)')
|
|
101
|
+
print(message, file=stderr_file)
|
|
102
|
+
raise KeyError(message)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_levels_consistency(levels: Levels,
|
|
106
|
+
stderr_file: TextIO = sys.stderr) -> None:
|
|
107
|
+
"""Check the consistency of the levels.
|
|
108
|
+
|
|
109
|
+
The documented constraints are checked on all levels. Each dict key
|
|
110
|
+
must match the ``level`` field of its value. Names and aliases are
|
|
111
|
+
checked for uniqueness across all levels using case-insensitive
|
|
112
|
+
comparison, so that a name and an alias may not differ only in case.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
levels: The levels to check.
|
|
116
|
+
stderr_file: The file to report errors to.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
TypeError: If a field has the wrong type.
|
|
120
|
+
ValueError: If a field value violates a constraint, or a dict key
|
|
121
|
+
does not match its level number.
|
|
122
|
+
KeyError: If a name or alias is not unique (case-insensitive).
|
|
123
|
+
"""
|
|
124
|
+
seen_labels: dict[str, str] = {}
|
|
125
|
+
for number, level in levels.items():
|
|
126
|
+
level.check_consistency(stderr_file)
|
|
127
|
+
if level.level != number:
|
|
128
|
+
report_bad_value('level', level.level,
|
|
129
|
+
f'does not match dict key {number}', stderr_file,
|
|
130
|
+
'Level')
|
|
131
|
+
for label in [level.name, *level.aliases]:
|
|
132
|
+
lowered = label.lower()
|
|
133
|
+
if lowered in seen_labels:
|
|
134
|
+
report_duplicate_label(label, seen_labels[lowered],
|
|
135
|
+
stderr_file)
|
|
136
|
+
seen_labels[lowered] = label
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def level_number_from_name(name: str, levels: Levels,
|
|
140
|
+
stderr_file: TextIO = sys.stderr) -> int:
|
|
141
|
+
"""Return the level number whose name or alias matches ``name``.
|
|
142
|
+
|
|
143
|
+
Matching is case-insensitive but otherwise exact (no prefix or
|
|
144
|
+
fuzzy matching). The level name and all of its aliases are
|
|
145
|
+
considered. The levels are assumed to be consistent, as checked by
|
|
146
|
+
:func:`check_levels_consistency`.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
name: The level name or alias to look up.
|
|
150
|
+
levels: The levels to search.
|
|
151
|
+
stderr_file: The file to report errors to.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The level number of the matching level.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ValueError: If no level name or alias matches ``name``.
|
|
158
|
+
"""
|
|
159
|
+
lowered = name.lower()
|
|
160
|
+
for level in levels.values():
|
|
161
|
+
if any(label.lower() == lowered
|
|
162
|
+
for label in [level.name, *level.aliases]):
|
|
163
|
+
return level.level
|
|
164
|
+
report_bad_value('level', name, 'unknown level name or alias', stderr_file,
|
|
165
|
+
'Level')
|