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,658 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Helpers for converting and validating backlog item data.
|
|
3
|
+
|
|
4
|
+
These helpers turn plain dictionaries into validated backlog field
|
|
5
|
+
values and report problems in a uniform way. They are deliberately
|
|
6
|
+
generic: they operate on values and type hints, not on the backlog item
|
|
7
|
+
class, so that the backlog module can use them without a circular
|
|
8
|
+
import.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
12
|
+
# MIT License
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from dataclasses import MISSING, Field
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from types import NoneType, UnionType
|
|
20
|
+
from typing import NoReturn, Optional, TextIO, TypeVar, Union
|
|
21
|
+
from typing import get_args, get_origin, get_type_hints
|
|
22
|
+
from config_as_json import string_to_enum_best_match
|
|
23
|
+
|
|
24
|
+
T = TypeVar('T')
|
|
25
|
+
|
|
26
|
+
FORBIDDEN_KEY_CHARS = frozenset(',.;:()[]{}')
|
|
27
|
+
"""Characters that must never appear in a key, release or dependency."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def field_type_hints(cls: type) -> dict[str, object]:
|
|
31
|
+
"""Return the resolved type hints for the fields of a class.
|
|
32
|
+
|
|
33
|
+
Postponed annotations and forward references are resolved, so that
|
|
34
|
+
callers receive concrete type objects (for example ``date`` and
|
|
35
|
+
``Status``) instead of their string annotations.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
cls: The class whose annotations should be resolved.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A mapping from field name to its resolved type hint.
|
|
42
|
+
"""
|
|
43
|
+
return dict(get_type_hints(cls))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_mandatory_field(item_field: Field[object]) -> bool:
|
|
47
|
+
"""Return True when a field must be supplied by the input data.
|
|
48
|
+
|
|
49
|
+
A field is mandatory when it takes part in ``__init__`` and has
|
|
50
|
+
neither a default value nor a default factory.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
item_field: The dataclass field to inspect.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the field has no default and must be supplied.
|
|
57
|
+
"""
|
|
58
|
+
return (item_field.init and item_field.default is MISSING and
|
|
59
|
+
item_field.default_factory is MISSING)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def enum_class_of(data_type: object) -> Optional[type[Enum]]:
|
|
63
|
+
"""Return the enum class of a type hint, or None.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data_type: The type hint to inspect.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The enum class when ``data_type`` is an Enum subclass, else None.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(data_type, type) and issubclass(data_type, Enum):
|
|
72
|
+
return data_type
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_union_type(data_type: object) -> bool:
|
|
77
|
+
"""Return True if a type hint is a ``Union`` or an ``X | Y`` union.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
data_type: The type hint to inspect.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if the type hint is any kind of union.
|
|
84
|
+
"""
|
|
85
|
+
return get_origin(data_type) in (Union, UnionType)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def non_optional_type(data_type: object) -> object:
|
|
89
|
+
"""Return the inner type of an ``Optional`` hint.
|
|
90
|
+
|
|
91
|
+
For ``Optional[X]`` (that is ``Union[X, None]``) the wrapped type
|
|
92
|
+
``X`` is returned. For a union with several non-None members, or for
|
|
93
|
+
a type hint that is not a union, the original hint is returned.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
data_type: The type hint to unwrap.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The single non-None union member, or the original type hint.
|
|
100
|
+
"""
|
|
101
|
+
if not is_union_type(data_type):
|
|
102
|
+
return data_type
|
|
103
|
+
members = [arg for arg in get_args(data_type) if arg is not NoneType]
|
|
104
|
+
return members[0] if len(members) == 1 else data_type
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def accepts_none(data_type: object) -> bool:
|
|
108
|
+
"""Return True if ``None`` is a valid value for a type hint.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
data_type: The type hint to inspect.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if the type hint is an optional or ``None`` accepting union.
|
|
115
|
+
"""
|
|
116
|
+
return is_union_type(data_type) and NoneType in get_args(data_type)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _matches_class(value: object, data_type: type) -> bool:
|
|
120
|
+
"""Return True if a value matches a plain (unparameterized) class.
|
|
121
|
+
|
|
122
|
+
A boolean is rejected where an integer is expected, because a
|
|
123
|
+
boolean is rarely a meaningful story point or numeric value here.
|
|
124
|
+
A ``datetime`` is rejected where a ``date`` is expected, even though
|
|
125
|
+
``datetime`` is a subclass of ``date``, so that a date field never
|
|
126
|
+
silently holds a value carrying a time component.
|
|
127
|
+
"""
|
|
128
|
+
if data_type is int:
|
|
129
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
130
|
+
if data_type is date:
|
|
131
|
+
return isinstance(value, date) and not isinstance(value, datetime)
|
|
132
|
+
return isinstance(value, data_type)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _matches_list(value: object, args: tuple[object, ...]) -> bool:
|
|
136
|
+
"""Return True if a value is a list whose items match the hint."""
|
|
137
|
+
if not isinstance(value, list):
|
|
138
|
+
return False
|
|
139
|
+
return all(value_matches_type(item, args[0]) for item in value) \
|
|
140
|
+
if args else True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _matches_dict(value: object, args: tuple[object, ...]) -> bool:
|
|
144
|
+
"""Return True if a value is a dict matching the key/value hints."""
|
|
145
|
+
if not isinstance(value, dict):
|
|
146
|
+
return False
|
|
147
|
+
if len(args) != 2:
|
|
148
|
+
return True
|
|
149
|
+
return all(value_matches_type(key, args[0]) and
|
|
150
|
+
value_matches_type(item, args[1])
|
|
151
|
+
for key, item in value.items())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _matches_concrete(value: object, data_type: object) -> bool:
|
|
155
|
+
"""Return True if a non-None value matches a concrete type hint."""
|
|
156
|
+
enum_class = enum_class_of(data_type)
|
|
157
|
+
if enum_class is not None:
|
|
158
|
+
return isinstance(value, enum_class)
|
|
159
|
+
origin = get_origin(data_type)
|
|
160
|
+
if origin is list:
|
|
161
|
+
return _matches_list(value, get_args(data_type))
|
|
162
|
+
if origin is dict:
|
|
163
|
+
return _matches_dict(value, get_args(data_type))
|
|
164
|
+
if isinstance(data_type, type):
|
|
165
|
+
return _matches_class(value, data_type)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def value_matches_type(value: object, data_type: object) -> bool:
|
|
170
|
+
"""Return True if a value matches a supported type hint.
|
|
171
|
+
|
|
172
|
+
Supported hints are ``object``, optional and union types, enums, and
|
|
173
|
+
the ``str``, ``int``, ``date``, ``list[...]`` and ``dict[..., ...]``
|
|
174
|
+
forms used by backlog items.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
value: The runtime value to check.
|
|
178
|
+
data_type: The type hint to check the value against.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True if the value is acceptable for the given type hint.
|
|
182
|
+
"""
|
|
183
|
+
if data_type is object:
|
|
184
|
+
return True
|
|
185
|
+
if is_union_type(data_type):
|
|
186
|
+
return any(value_matches_type(value, arg)
|
|
187
|
+
for arg in get_args(data_type))
|
|
188
|
+
if value is None:
|
|
189
|
+
return data_type is NoneType
|
|
190
|
+
return _matches_concrete(value, data_type)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _type_name(data_type: object) -> str:
|
|
194
|
+
"""Return a readable name for a type hint, used in messages."""
|
|
195
|
+
if data_type is object:
|
|
196
|
+
return 'object'
|
|
197
|
+
if isinstance(data_type, type):
|
|
198
|
+
return data_type.__name__
|
|
199
|
+
return str(data_type).replace('typing.', '')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def report_missing_field(field_name: str,
|
|
203
|
+
stderr_file: TextIO = sys.stderr) -> NoReturn:
|
|
204
|
+
"""Report a missing mandatory field and raise ``KeyError``.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
field_name: The name of the missing field.
|
|
208
|
+
stderr_file: The file to report the error to.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
KeyError: Always, after reporting the message.
|
|
212
|
+
"""
|
|
213
|
+
message = f'Missing mandatory backlog item field: {field_name}'
|
|
214
|
+
print(message, file=stderr_file)
|
|
215
|
+
raise KeyError(message)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def report_wrong_type(field_name: str, value: object, data_type: object,
|
|
219
|
+
stderr_file: TextIO = sys.stderr,
|
|
220
|
+
subject: str = 'Backlog item') -> NoReturn:
|
|
221
|
+
"""Report a value of the wrong type and raise ``TypeError``.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
field_name: The name of the offending field.
|
|
225
|
+
value: The value that has the wrong type.
|
|
226
|
+
data_type: The type hint the value was expected to match.
|
|
227
|
+
stderr_file: The file to report the error to.
|
|
228
|
+
subject: What owns the field, used to start the message (for
|
|
229
|
+
example ``'Backlog item'``, ``'Person'`` or ``'Team'``).
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
TypeError: Always, after reporting the message.
|
|
233
|
+
"""
|
|
234
|
+
message = (f'{subject} field {field_name!r} expected '
|
|
235
|
+
f'{_type_name(data_type)}, got {type(value).__name__}: '
|
|
236
|
+
f'{value!r}')
|
|
237
|
+
print(message, file=stderr_file)
|
|
238
|
+
raise TypeError(message)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def report_bad_value(field_name: str, value: object, reason: str,
|
|
242
|
+
stderr_file: TextIO = sys.stderr,
|
|
243
|
+
subject: str = 'Backlog item') -> NoReturn:
|
|
244
|
+
"""Report a value that violates a constraint and raise ``ValueError``.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
field_name: The name of the offending field.
|
|
248
|
+
value: The value that violates the constraint.
|
|
249
|
+
reason: A human readable explanation of the constraint.
|
|
250
|
+
stderr_file: The file to report the error to.
|
|
251
|
+
subject: What owns the field, used to start the message (for
|
|
252
|
+
example ``'Backlog item'``, ``'Person'`` or ``'Team'``).
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
ValueError: Always, after reporting the message.
|
|
256
|
+
"""
|
|
257
|
+
message = (f'{subject} field {field_name!r} has invalid value '
|
|
258
|
+
f'{value!r}: {reason}')
|
|
259
|
+
print(message, file=stderr_file)
|
|
260
|
+
raise ValueError(message)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def report_unknown_reference(field_name: str, owner_key: str,
|
|
264
|
+
referenced_key: str,
|
|
265
|
+
stderr_file: TextIO = sys.stderr,
|
|
266
|
+
subject: str = 'Backlog item') -> NoReturn:
|
|
267
|
+
"""Report a reference to a missing key and raise ``KeyError``.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
field_name: The field that holds the reference.
|
|
271
|
+
owner_key: The key of the item that owns the reference.
|
|
272
|
+
referenced_key: The key that does not exist.
|
|
273
|
+
stderr_file: The file to report the error to.
|
|
274
|
+
subject: What owns the field, used to start the message (for
|
|
275
|
+
example ``'Backlog item'`` or ``'Team'``).
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
KeyError: Always, after reporting the message.
|
|
279
|
+
"""
|
|
280
|
+
message = (f'{subject} {owner_key!r} field {field_name!r} '
|
|
281
|
+
f'references unknown key {referenced_key!r}')
|
|
282
|
+
print(message, file=stderr_file)
|
|
283
|
+
raise KeyError(message)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_field_types(instance: object, stderr_file: TextIO = sys.stderr,
|
|
287
|
+
subject: str = 'Backlog item') -> None:
|
|
288
|
+
"""Check that every field of a dataclass holds its declared type.
|
|
289
|
+
|
|
290
|
+
The instance must be a dataclass instance. Each field value is
|
|
291
|
+
compared with its resolved type hint using
|
|
292
|
+
:func:`value_matches_type`, and the first mismatch is reported with
|
|
293
|
+
:func:`report_wrong_type`.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
instance: The dataclass instance to check.
|
|
297
|
+
stderr_file: The file to report errors to.
|
|
298
|
+
subject: What owns the fields, used to start error messages.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
TypeError: If a field holds a value of the wrong type.
|
|
302
|
+
"""
|
|
303
|
+
field_types = field_type_hints(type(instance))
|
|
304
|
+
for field_name, data_type in field_types.items():
|
|
305
|
+
value = getattr(instance, field_name)
|
|
306
|
+
if not value_matches_type(value, data_type):
|
|
307
|
+
report_wrong_type(field_name, value, data_type, stderr_file,
|
|
308
|
+
subject)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def convert_to_enum(field_name: str, value: object, enum_class: type[Enum],
|
|
312
|
+
stderr_file: TextIO = sys.stderr) -> Enum:
|
|
313
|
+
"""Convert a value to a member of an enum class.
|
|
314
|
+
|
|
315
|
+
A value that is already a member of ``enum_class`` is returned
|
|
316
|
+
unchanged. A string is matched against the member names using
|
|
317
|
+
``string_to_enum_best_match`` (which allows case and unique prefix
|
|
318
|
+
matches). An integer is looked up as a raw enum value. Booleans are
|
|
319
|
+
rejected, even though a boolean is technically an integer.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
field_name: The name of the field being converted.
|
|
323
|
+
value: The member, name or raw value to convert.
|
|
324
|
+
enum_class: The enum class to convert to.
|
|
325
|
+
stderr_file: The file to report errors to.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
The matching enum member.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
TypeError: If no enum member matches the value.
|
|
332
|
+
"""
|
|
333
|
+
if isinstance(value, enum_class):
|
|
334
|
+
return value
|
|
335
|
+
if isinstance(value, bool):
|
|
336
|
+
report_wrong_type(field_name, value, enum_class, stderr_file)
|
|
337
|
+
if isinstance(value, str):
|
|
338
|
+
try:
|
|
339
|
+
return string_to_enum_best_match(value, enum_class)
|
|
340
|
+
except KeyError:
|
|
341
|
+
report_wrong_type(field_name, value, enum_class, stderr_file)
|
|
342
|
+
if isinstance(value, int):
|
|
343
|
+
try:
|
|
344
|
+
return enum_class(value)
|
|
345
|
+
except ValueError:
|
|
346
|
+
report_wrong_type(field_name, value, enum_class, stderr_file)
|
|
347
|
+
report_wrong_type(field_name, value, enum_class, stderr_file)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def convert_to_date(field_name: str, value: object,
|
|
351
|
+
stderr_file: TextIO = sys.stderr) -> date:
|
|
352
|
+
"""Convert a value to a ``datetime.date``.
|
|
353
|
+
|
|
354
|
+
A ``datetime`` is narrowed to its ``date`` part, dropping any time
|
|
355
|
+
component (spreadsheet date cells arrive as midnight ``datetime``
|
|
356
|
+
values). A value that is already a plain ``date`` is returned
|
|
357
|
+
unchanged. A string is parsed as an ISO 8601 date such as
|
|
358
|
+
``'2026-06-12'``.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
field_name: The name of the field being converted.
|
|
362
|
+
value: The date, datetime or ISO 8601 string to convert.
|
|
363
|
+
stderr_file: The file to report errors to.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
The converted date.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
TypeError: If the value is neither a date nor a valid ISO string.
|
|
370
|
+
"""
|
|
371
|
+
if isinstance(value, datetime):
|
|
372
|
+
return value.date()
|
|
373
|
+
if isinstance(value, date):
|
|
374
|
+
return value
|
|
375
|
+
if isinstance(value, str):
|
|
376
|
+
try:
|
|
377
|
+
return date.fromisoformat(value)
|
|
378
|
+
except ValueError:
|
|
379
|
+
report_wrong_type(field_name, value, date, stderr_file)
|
|
380
|
+
report_wrong_type(field_name, value, date, stderr_file)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def convert_to_str(field_name: str, value: object,
|
|
384
|
+
stderr_file: TextIO = sys.stderr) -> str:
|
|
385
|
+
"""Convert an unambiguous value to a ``str``.
|
|
386
|
+
|
|
387
|
+
A value that is already a string is returned unchanged. An integer
|
|
388
|
+
is converted to its decimal string, so a key entered as the number
|
|
389
|
+
``100`` becomes ``'100'``. A float without a fractional part is
|
|
390
|
+
converted as an integer (``100.0`` becomes ``'100'``); any other
|
|
391
|
+
float uses its own string form. A boolean is rejected, because
|
|
392
|
+
whether ``True`` should become ``'True'`` or ``'1'`` is ambiguous.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
field_name: The name of the field being converted.
|
|
396
|
+
value: The value to convert to a string.
|
|
397
|
+
stderr_file: The file to report errors to.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
The converted string.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
TypeError: If the value cannot be unambiguously converted.
|
|
404
|
+
"""
|
|
405
|
+
if isinstance(value, str):
|
|
406
|
+
return value
|
|
407
|
+
if isinstance(value, bool):
|
|
408
|
+
report_wrong_type(field_name, value, str, stderr_file)
|
|
409
|
+
if isinstance(value, int):
|
|
410
|
+
return str(value)
|
|
411
|
+
if isinstance(value, float):
|
|
412
|
+
return str(int(value)) if value.is_integer() else str(value)
|
|
413
|
+
report_wrong_type(field_name, value, str, stderr_file)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def convert_field_value(field_name: str, value: object, data_type: object,
|
|
417
|
+
stderr_file: TextIO = sys.stderr) -> object:
|
|
418
|
+
"""Convert and validate a single field value against its type hint.
|
|
419
|
+
|
|
420
|
+
``None`` is accepted for optional fields. Enum fields are converted
|
|
421
|
+
with :func:`convert_to_enum`, date fields with :func:`convert_to_date`,
|
|
422
|
+
string fields with :func:`convert_to_str`, and all other fields are
|
|
423
|
+
checked with :func:`value_matches_type`.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
field_name: The name of the field being converted.
|
|
427
|
+
value: The raw input value.
|
|
428
|
+
data_type: The resolved type hint of the field.
|
|
429
|
+
stderr_file: The file to report errors to.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The converted value, ready to be stored on the backlog item.
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
TypeError: If the value cannot be converted to the field type.
|
|
436
|
+
"""
|
|
437
|
+
if value is None and accepts_none(data_type):
|
|
438
|
+
return None
|
|
439
|
+
inner_type = non_optional_type(data_type)
|
|
440
|
+
enum_class = enum_class_of(inner_type)
|
|
441
|
+
if enum_class is not None:
|
|
442
|
+
return convert_to_enum(field_name, value, enum_class, stderr_file)
|
|
443
|
+
if inner_type is date:
|
|
444
|
+
return convert_to_date(field_name, value, stderr_file)
|
|
445
|
+
if inner_type is str:
|
|
446
|
+
return convert_to_str(field_name, value, stderr_file)
|
|
447
|
+
if not value_matches_type(value, data_type):
|
|
448
|
+
report_wrong_type(field_name, value, data_type, stderr_file)
|
|
449
|
+
return value
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def is_extra_field_map(item_field: Field[object],
|
|
453
|
+
field_types: dict[str, object]) -> bool:
|
|
454
|
+
"""Return True if a field is the ``dict[str, object]`` extras map.
|
|
455
|
+
|
|
456
|
+
The extras map stores input keys that do not correspond to a named
|
|
457
|
+
field. It is recognised by being a default-factory ``dict`` field
|
|
458
|
+
whose value type is ``object``.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
item_field: The dataclass field to inspect.
|
|
462
|
+
field_types: The resolved type hints of the dataclass.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
True if the field is the extras mapping field.
|
|
466
|
+
"""
|
|
467
|
+
data_type = field_types.get(item_field.name)
|
|
468
|
+
args = get_args(data_type)
|
|
469
|
+
return (item_field.default_factory is not MISSING and
|
|
470
|
+
get_origin(data_type) is dict and len(args) == 2 and
|
|
471
|
+
args[0] is str and args[1] is object)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def extra_field_name(item_fields: Sequence[Field[object]],
|
|
475
|
+
field_types: dict[str, object]) -> Optional[str]:
|
|
476
|
+
"""Return the name of the extras map field, if any.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
item_fields: The dataclass fields to search.
|
|
480
|
+
field_types: The resolved type hints of the dataclass.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
The name of the extras mapping field, or None.
|
|
484
|
+
"""
|
|
485
|
+
for item_field in item_fields:
|
|
486
|
+
if is_extra_field_map(item_field, field_types):
|
|
487
|
+
return item_field.name
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def collect_extra_values(data: dict[str, object], known_names: set[str],
|
|
492
|
+
extra_name: str, data_type: object,
|
|
493
|
+
stderr_file: TextIO = sys.stderr
|
|
494
|
+
) -> dict[str, object]:
|
|
495
|
+
"""Collect the values for the extras mapping field.
|
|
496
|
+
|
|
497
|
+
The result merges an explicit ``extra_name`` mapping found in the
|
|
498
|
+
input with every input key that does not match a named field.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
data: The raw input data for one backlog item.
|
|
502
|
+
known_names: The names of the named dataclass fields.
|
|
503
|
+
extra_name: The name of the extras mapping field.
|
|
504
|
+
data_type: The resolved type hint of the extras field.
|
|
505
|
+
stderr_file: The file to report errors to.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
The mapping of extra field names to their values.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
TypeError: If an explicit extras mapping has the wrong type.
|
|
512
|
+
"""
|
|
513
|
+
result: dict[str, object] = {}
|
|
514
|
+
if extra_name in data:
|
|
515
|
+
value = convert_field_value(extra_name, data[extra_name], data_type,
|
|
516
|
+
stderr_file)
|
|
517
|
+
assert isinstance(value, dict)
|
|
518
|
+
for key, item in value.items():
|
|
519
|
+
assert isinstance(key, str)
|
|
520
|
+
result[key] = item
|
|
521
|
+
for field_name, item in data.items():
|
|
522
|
+
if field_name not in known_names:
|
|
523
|
+
result[field_name] = item
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def build_item_kwargs(item_fields: Sequence[Field[object]],
|
|
528
|
+
field_types: dict[str, object], data: dict[str, object],
|
|
529
|
+
stderr_file: TextIO = sys.stderr) -> dict[str, object]:
|
|
530
|
+
"""Build the constructor keyword arguments for a backlog item.
|
|
531
|
+
|
|
532
|
+
Each named field present in ``data`` is converted to its declared
|
|
533
|
+
type. Missing mandatory fields are reported and rejected. Any input
|
|
534
|
+
keys that do not match a named field are gathered into the extras
|
|
535
|
+
mapping field.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
item_fields: The dataclass fields of the backlog item.
|
|
539
|
+
field_types: The resolved type hints of the dataclass.
|
|
540
|
+
data: The raw input data for one backlog item.
|
|
541
|
+
stderr_file: The file to report errors to.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
The keyword arguments to construct one backlog item.
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
KeyError: If a mandatory field is missing.
|
|
548
|
+
TypeError: If a field value has a type that cannot be converted.
|
|
549
|
+
"""
|
|
550
|
+
known_names = {item_field.name for item_field in item_fields}
|
|
551
|
+
extra_name = extra_field_name(item_fields, field_types)
|
|
552
|
+
result: dict[str, object] = {}
|
|
553
|
+
for item_field in item_fields:
|
|
554
|
+
if not item_field.init or item_field.name == extra_name:
|
|
555
|
+
continue
|
|
556
|
+
data_type = field_types.get(item_field.name, item_field.type)
|
|
557
|
+
if item_field.name in data:
|
|
558
|
+
result[item_field.name] = convert_field_value(
|
|
559
|
+
item_field.name, data[item_field.name], data_type, stderr_file)
|
|
560
|
+
elif is_mandatory_field(item_field):
|
|
561
|
+
report_missing_field(item_field.name, stderr_file)
|
|
562
|
+
if extra_name is not None:
|
|
563
|
+
extra_type = field_types.get(extra_name, dict[str, object])
|
|
564
|
+
result[extra_name] = collect_extra_values(data, known_names,
|
|
565
|
+
extra_name, extra_type,
|
|
566
|
+
stderr_file)
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def construct(item_cls: Callable[..., T], item_kwargs: dict[str, object]) -> T:
|
|
571
|
+
"""Construct an instance from validated keyword arguments.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
item_cls: The class (or callable) to instantiate.
|
|
575
|
+
item_kwargs: The keyword arguments to pass to ``item_cls``.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
The constructed instance.
|
|
579
|
+
"""
|
|
580
|
+
return item_cls(**item_kwargs)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def check_key_syntax(field_name: str, value: object,
|
|
584
|
+
stderr_file: TextIO = sys.stderr,
|
|
585
|
+
subject: str = 'Backlog item') -> None:
|
|
586
|
+
"""Check that a value is a well formed backlog key.
|
|
587
|
+
|
|
588
|
+
A backlog key (used by ``key`` and ``release`` and by the entries of
|
|
589
|
+
the dependency lists) must be a non-empty string that contains no
|
|
590
|
+
whitespace and none of the separator or bracket characters
|
|
591
|
+
``, . ; : ( ) [ ] { }``. All other characters, including letters,
|
|
592
|
+
digits, ``-``, ``_`` and signs such as ``#`` or ``$``, are allowed.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
field_name: The name of the field being checked.
|
|
596
|
+
value: The value that should be a valid key.
|
|
597
|
+
stderr_file: The file to report errors to.
|
|
598
|
+
subject: What owns the field, used to start error messages.
|
|
599
|
+
|
|
600
|
+
Raises:
|
|
601
|
+
TypeError: If the value is not a string.
|
|
602
|
+
ValueError: If the string is empty or contains a forbidden
|
|
603
|
+
character.
|
|
604
|
+
"""
|
|
605
|
+
if not isinstance(value, str):
|
|
606
|
+
report_wrong_type(field_name, value, str, stderr_file, subject)
|
|
607
|
+
if value == '':
|
|
608
|
+
report_bad_value(field_name, value, 'must not be empty', stderr_file,
|
|
609
|
+
subject)
|
|
610
|
+
if any(char.isspace() for char in value):
|
|
611
|
+
report_bad_value(field_name, value, 'must not contain whitespace',
|
|
612
|
+
stderr_file, subject)
|
|
613
|
+
forbidden = sorted(set(value) & FORBIDDEN_KEY_CHARS)
|
|
614
|
+
if forbidden:
|
|
615
|
+
report_bad_value(field_name, value,
|
|
616
|
+
'must not contain any of ' + ''.join(forbidden),
|
|
617
|
+
stderr_file, subject)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def find_cycle(graph: dict[str, list[str]]) -> Optional[list[str]]:
|
|
621
|
+
"""Return a cycle in a directed graph, or None if it is acyclic.
|
|
622
|
+
|
|
623
|
+
The graph maps each node to the list of nodes it points to. A
|
|
624
|
+
returned cycle starts and ends with the same node, so a self
|
|
625
|
+
reference is reported as ``[node, node]``.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
graph: A mapping from each node to its successor nodes.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
The nodes that form a cycle (with the start node repeated at the
|
|
632
|
+
end), or None when the graph has no cycle.
|
|
633
|
+
"""
|
|
634
|
+
visiting: set[str] = set()
|
|
635
|
+
visited: set[str] = set()
|
|
636
|
+
path: list[str] = []
|
|
637
|
+
|
|
638
|
+
def visit(node: str) -> Optional[list[str]]:
|
|
639
|
+
"""Depth-first search for a cycle reachable from ``node``."""
|
|
640
|
+
if node in visited:
|
|
641
|
+
return None
|
|
642
|
+
if node in visiting:
|
|
643
|
+
return path[path.index(node):] + [node]
|
|
644
|
+
visiting.add(node)
|
|
645
|
+
path.append(node)
|
|
646
|
+
for successor in graph.get(node, []):
|
|
647
|
+
cycle = visit(successor)
|
|
648
|
+
if cycle is not None:
|
|
649
|
+
return cycle
|
|
650
|
+
path.pop()
|
|
651
|
+
visiting.discard(node)
|
|
652
|
+
visited.add(node)
|
|
653
|
+
return None
|
|
654
|
+
for start in graph:
|
|
655
|
+
cycle = visit(start)
|
|
656
|
+
if cycle is not None:
|
|
657
|
+
return cycle
|
|
658
|
+
return None
|