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