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