python-interactive-courses 1.0.0__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.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,769 @@
1
+ """
2
+ Check the structure of source code against rules, e.g. whether it uses recursion, avoids
3
+ loops, or calls a certain function.
4
+
5
+ The source is parsed with ``ast``, but never executed. Rules are plain dicts, so that they
6
+ can be stored as json.
7
+
8
+ Rule format
9
+ -----------
10
+ A rule has exactly one of the following keys:
11
+
12
+ ``concept``
13
+ A name from ``CONCEPTS``, e.g. ``"loop"``, ``"recursion"`` or ``"match"``. Some
14
+ concepts take parameters, which are given as additional keys::
15
+
16
+ {"concept": "call", "name": "sorted"}
17
+
18
+ ``node``
19
+ The name of an ``ast`` node class, or a list of them. Abstract classes such as
20
+ ``"stmt"`` match all of their subclasses::
21
+
22
+ {"node": ["ListComp", "GeneratorExp"]}
23
+
24
+ ``pattern``
25
+ A single Python statement or expression, which is matched structurally. ``_`` matches
26
+ any expression, statement or identifier, and ``...`` any number of arguments,
27
+ elements or statements. Optional parts which the pattern leaves out (``else``
28
+ branches, decorators, keyword arguments, annotations, parameters, ...) match
29
+ anything::
30
+
31
+ {"pattern": "for _ in range(len(_)): ..."}
32
+
33
+ A rule finds a number of nodes in the source, and holds if that number is at least
34
+ ``min`` and at most ``max``. By default, a rule requires at least one node, or at most
35
+ ``max`` nodes if only ``max`` is given, so ``"max": 0`` forbids a construct. ``scope`` restricts the search to the function or class with that dotted name
36
+ (e.g. ``"Graph.search"``), including everything nested in it. ``message`` replaces the
37
+ generated failure message::
38
+
39
+ {"concept": "loop", "scope": "fib", "max": 0, "message": "Use recursion instead."}
40
+
41
+ Rules can be combined with ``{"all": [...]}``, ``{"any": [...]}`` and ``{"not": rule}``.
42
+ These accept a ``message`` as well, and a ``scope``, which applies to all contained rules
43
+ without a ``scope`` of their own.
44
+
45
+ Recursion is detected from the calls between the functions and methods defined in the
46
+ source. Method calls are resolved by name within the class, so ``child.size()`` inside
47
+ ``Tree.size`` counts as recursion, just like ``self.size()``. Aliases (``f = fib``),
48
+ functions passed as arguments and recursive calls which are never executed are not taken
49
+ into account.
50
+ """
51
+
52
+ import ast
53
+ import textwrap
54
+ from collections.abc import Callable, Iterator
55
+ from dataclasses import dataclass, field
56
+ from functools import cache, cached_property
57
+ from typing import Any
58
+
59
+ from rich.markup import escape
60
+
61
+ Rule = dict[str, Any]
62
+
63
+ _LEAF_KEYS = ("concept", "node", "pattern")
64
+ _COMBINATOR_KEYS = ("all", "any", "not")
65
+ _MODIFIER_KEYS = {"scope", "min", "max", "message"}
66
+
67
+ _DEF_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
68
+ _LOOP_TYPES = (ast.For, ast.AsyncFor, ast.While)
69
+
70
+ # fields which are not compared when matching patterns
71
+ _IGNORED_FIELDS = {"ctx", "type_comment"}
72
+ # fields which match anything if they are left out (None or empty) in a pattern
73
+ _OPTIONAL_FIELDS = {
74
+ "annotation",
75
+ "asname",
76
+ "bases",
77
+ "cause",
78
+ "decorator_list",
79
+ "defaults",
80
+ "finalbody",
81
+ "format_spec",
82
+ "guard",
83
+ "handlers",
84
+ "keywords",
85
+ "kw_defaults",
86
+ "kwarg",
87
+ "kwonlyargs",
88
+ "msg",
89
+ "name",
90
+ "optional_vars",
91
+ "orelse",
92
+ "posonlyargs",
93
+ "returns",
94
+ "type_params",
95
+ "vararg",
96
+ }
97
+
98
+
99
+ class _MissingScope(Exception):
100
+ def __init__(self, scope: str):
101
+ self.scope = scope
102
+
103
+
104
+ def check_structure(
105
+ rules: list[Rule] | Rule,
106
+ source: str,
107
+ *,
108
+ filename: str = "",
109
+ use_rich_markup: bool = True,
110
+ locate: Callable[[int], str] | None = None,
111
+ ) -> list[str]:
112
+ """
113
+ Check ``source`` against ``rules``.
114
+
115
+ Parameters
116
+ ----------
117
+ rules: list[Rule] | Rule
118
+ A rule dict or a list of rule dicts (see module docstring), which must all hold
119
+ source: str
120
+ The source code to check
121
+ filename: str (optional, keyword only)
122
+ Name of the source file, used in the message if it can not be parsed
123
+ use_rich_markup: bool (optional, keyword only)
124
+ Use ``rich`` markup in messages for additional highlighting.
125
+ Default True.
126
+ locate: Callable[[int], str] | None (optional, keyword only)
127
+ Where a line of ``source`` (from 1) is, as messages name it, e.g. "cell 3, line
128
+ 2" for the source of a notebook's cells. By default "line N".
129
+
130
+ Returns
131
+ -------
132
+ list[str]
133
+ Messages for all rules which do not hold. Empty if all rules hold.
134
+
135
+ Raises
136
+ ------
137
+ TypeError, ValueError
138
+ if ``rules`` are malformed
139
+ """
140
+ rules = validate_rules(rules)
141
+ o = _Markup(use_rich_markup)
142
+ try:
143
+ tree = ast.parse(source, filename or "<unknown>")
144
+ except SyntaxError as err:
145
+ what = o.q(filename) if filename else "the source"
146
+ where = locate(err.lineno) if locate and err.lineno else f"line {err.lineno}"
147
+ return [f"Could not parse {what}: {err.msg} ({where})."]
148
+
149
+ ctx = _Context(tree, o, locate)
150
+ messages = []
151
+ for rule in rules:
152
+ try:
153
+ messages.extend(_evaluate(rule, ctx, None))
154
+ except _MissingScope as err:
155
+ messages.append(f"No function or class {o.q(err.scope)} found.")
156
+ return list(dict.fromkeys(messages))
157
+
158
+
159
+ def validate_rules(rules: list[Rule] | Rule) -> list[Rule]:
160
+ """
161
+ Validate a rule dict or a list of rule dicts (see module docstring), and return them
162
+ as a list.
163
+
164
+ Raises
165
+ ------
166
+ TypeError
167
+ if ``rules`` is neither a dict nor a list
168
+ ValueError
169
+ if a rule is malformed
170
+ """
171
+ if isinstance(rules, dict):
172
+ rules = [rules]
173
+ if not isinstance(rules, list | tuple):
174
+ raise TypeError("Rules must be a rule dict or a list of rule dicts.")
175
+ for i, rule in enumerate(rules):
176
+ _validate(rule, f"rules[{i}]")
177
+ return list(rules)
178
+
179
+
180
+ # concepts #############################################################################
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class Concept:
185
+ """
186
+ A named construct, found by ``find(nodes, ctx, **params)`` among the ``nodes`` in
187
+ scope. ``detailed`` describes the concept if parameters are given, with their values
188
+ inserted, e.g. ``"call to {name}"``.
189
+ """
190
+
191
+ description: str
192
+ find: Callable[..., list[ast.AST]]
193
+ params: tuple[str, ...] = ()
194
+ required: tuple[str, ...] = ()
195
+ detailed: str | None = None
196
+
197
+ def describe(self, params: dict[str, Any], o: _Markup) -> str:
198
+ if params and self.detailed:
199
+ return self.detailed.format(**{k: o.q(v) for k, v in params.items()})
200
+ return self.description
201
+
202
+
203
+ def _of_type(*types: type[ast.AST]) -> Callable[..., list[ast.AST]]:
204
+ return lambda nodes, ctx: [n for n in nodes if isinstance(n, types)]
205
+
206
+
207
+ def _calls(nodes: list[ast.AST], ctx: _Context, *, name: str) -> list[ast.AST]:
208
+ """calls of ``name``, as a function (``name(...)``), a method (``_.name(...)``), or
209
+ by its dotted name (``math.sqrt(...)``)"""
210
+
211
+ def matches(func: ast.expr) -> bool:
212
+ if isinstance(func, ast.Name):
213
+ return func.id == name
214
+ if isinstance(func, ast.Attribute):
215
+ return func.attr == name or ast.unparse(func) == name
216
+ return False
217
+
218
+ return [n for n in nodes if isinstance(n, ast.Call) and matches(n.func)]
219
+
220
+
221
+ def _imports(
222
+ nodes: list[ast.AST], ctx: _Context, *, name: str | None = None
223
+ ) -> list[ast.AST]:
224
+ """imports of module ``name`` or its submodules, or of any module"""
225
+
226
+ def matches(module: str | None) -> bool:
227
+ return name is None or module == name or (module or "").startswith(f"{name}.")
228
+
229
+ return [
230
+ n
231
+ for n in nodes
232
+ if (isinstance(n, ast.Import) and any(matches(a.name) for a in n.names))
233
+ or (isinstance(n, ast.ImportFrom) and matches(n.module))
234
+ ]
235
+
236
+
237
+ CONCEPTS: dict[str, Concept] = {
238
+ "loop": Concept("loop", _of_type(*_LOOP_TYPES)),
239
+ "for_loop": Concept("for loop", _of_type(ast.For, ast.AsyncFor)),
240
+ "while_loop": Concept("while loop", _of_type(ast.While)),
241
+ "nested_loop": Concept(
242
+ "nested loop", lambda nodes, ctx: [n for n in nodes if n in ctx.nested_loops]
243
+ ),
244
+ "comprehension": Concept(
245
+ "comprehension",
246
+ _of_type(ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp),
247
+ ),
248
+ "conditional": Concept("conditional", _of_type(ast.If, ast.IfExp, ast.Match)),
249
+ "if": Concept("if statement", _of_type(ast.If)),
250
+ "ternary": Concept("conditional expression", _of_type(ast.IfExp)),
251
+ "match": Concept("match statement", _of_type(ast.Match)),
252
+ "recursion": Concept(
253
+ "recursive call",
254
+ lambda nodes, ctx: [n for n in nodes if n in ctx.recursive_calls],
255
+ ),
256
+ "function": Concept(
257
+ "function definition", _of_type(ast.FunctionDef, ast.AsyncFunctionDef)
258
+ ),
259
+ "lambda": Concept("lambda", _of_type(ast.Lambda)),
260
+ "class": Concept("class definition", _of_type(ast.ClassDef)),
261
+ "generator": Concept("yield", _of_type(ast.Yield, ast.YieldFrom)),
262
+ "exception_handling": Concept("try statement", _of_type(ast.Try, ast.TryStar)),
263
+ "raise": Concept("raise statement", _of_type(ast.Raise)),
264
+ "with": Concept("with statement", _of_type(ast.With, ast.AsyncWith)),
265
+ "global": Concept(
266
+ "global or nonlocal statement", _of_type(ast.Global, ast.Nonlocal)
267
+ ),
268
+ "call": Concept(
269
+ "function call",
270
+ _calls,
271
+ params=("name",),
272
+ required=("name",),
273
+ detailed="call to {name}",
274
+ ),
275
+ "import": Concept(
276
+ "import", _imports, params=("name",), detailed="import of {name}"
277
+ ),
278
+ }
279
+
280
+
281
+ # validation ###########################################################################
282
+
283
+
284
+ def _is_count(value: Any) -> bool:
285
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
286
+
287
+
288
+ def _bounds(rule: Rule) -> tuple[Any, Any]:
289
+ """``min`` and ``max`` of a leaf rule, with defaults"""
290
+ hi = rule.get("max")
291
+ return rule.get("min", 1 if hi is None else 0), hi
292
+
293
+
294
+ def _is_node_type(name: Any) -> bool:
295
+ cls = getattr(ast, name, None) if isinstance(name, str) else None
296
+ return isinstance(cls, type) and issubclass(cls, ast.AST)
297
+
298
+
299
+ def _validate(rule: Any, path: str) -> None:
300
+ if not isinstance(rule, dict):
301
+ raise ValueError(f"{path}: must be a rule dict.") # noqa: TRY004
302
+
303
+ keys = [k for k in (*_LEAF_KEYS, *_COMBINATOR_KEYS) if k in rule]
304
+ if len(keys) != 1:
305
+ raise ValueError(
306
+ f"{path}: must have exactly one of the keys "
307
+ f"{[*_LEAF_KEYS, *_COMBINATOR_KEYS]}."
308
+ )
309
+ key = keys[0]
310
+
311
+ scope = rule.get("scope")
312
+ if scope is not None and not (
313
+ isinstance(scope, str) and all(p.isidentifier() for p in scope.split("."))
314
+ ):
315
+ raise ValueError(f"{path}.scope: must be a dotted name, e.g. 'Graph.search'.")
316
+ if not isinstance(rule.get("message", ""), str):
317
+ raise ValueError(f"{path}.message: must be a string.") # noqa: TRY004
318
+
319
+ if key in _COMBINATOR_KEYS:
320
+ if unknown := rule.keys() - {key, "scope", "message"}:
321
+ raise ValueError(f"{path}: unknown keys {sorted(unknown)}.")
322
+ if key == "not":
323
+ _validate(rule[key], f"{path}.not")
324
+ return
325
+ if not isinstance(rule[key], list | tuple) or not rule[key]:
326
+ raise ValueError(f"{path}.{key}: must be a non-empty list of rules.")
327
+ for i, child in enumerate(rule[key]):
328
+ _validate(child, f"{path}.{key}[{i}]")
329
+ return
330
+
331
+ allowed = {key, *_MODIFIER_KEYS}
332
+ if key == "concept":
333
+ concept = CONCEPTS.get(rule[key]) if isinstance(rule[key], str) else None
334
+ if concept is None:
335
+ raise ValueError(
336
+ f"{path}.concept: unknown concept {rule[key]!r}, expected one of "
337
+ f"{list(CONCEPTS)}."
338
+ )
339
+ allowed.update(concept.params)
340
+ if missing := set(concept.required) - rule.keys():
341
+ raise ValueError(f"{path}: missing keys {sorted(missing)}.")
342
+ for param in concept.params:
343
+ if not isinstance(rule.get(param, ""), str):
344
+ raise ValueError(f"{path}.{param}: must be a string.") # noqa: TRY004
345
+ elif key == "node":
346
+ names = [rule[key]] if isinstance(rule[key], str) else rule[key]
347
+ if not isinstance(names, list | tuple) or not names:
348
+ raise ValueError(
349
+ f"{path}.node: must be a name or a non-empty list of names."
350
+ )
351
+ for name in names:
352
+ if not _is_node_type(name):
353
+ raise ValueError(f"{path}.node: unknown ast node type {name!r}.")
354
+ else:
355
+ if not isinstance(rule[key], str):
356
+ raise ValueError(f"{path}.pattern: must be a string.")
357
+ try:
358
+ _compile_pattern(rule[key])
359
+ except SyntaxError as err:
360
+ raise ValueError(f"{path}.pattern: {err.msg}") from None
361
+
362
+ if unknown := rule.keys() - allowed:
363
+ raise ValueError(f"{path}: unknown keys {sorted(unknown)}.")
364
+
365
+ lo, hi = _bounds(rule)
366
+ if not _is_count(lo):
367
+ raise ValueError(f"{path}.min: must be a non-negative integer.")
368
+ if hi is not None and not _is_count(hi):
369
+ raise ValueError(f"{path}.max: must be a non-negative integer or None.")
370
+ if hi is not None and lo > hi:
371
+ raise ValueError(f"{path}: min ({lo}) is greater than max ({hi}).")
372
+
373
+
374
+ # patterns #############################################################################
375
+
376
+
377
+ @cache
378
+ def _compile_pattern(pattern: str) -> ast.AST:
379
+ """
380
+ Parse ``pattern`` into the node it matches: an expression, or a statement.
381
+
382
+ Raises
383
+ ------
384
+ SyntaxError
385
+ if ``pattern`` is not a single statement or expression
386
+ """
387
+ try:
388
+ body = ast.parse(textwrap.dedent(pattern)).body
389
+ except SyntaxError as err:
390
+ raise SyntaxError(f"invalid syntax ({err.msg}).") from None
391
+ if len(body) != 1:
392
+ raise SyntaxError("must be a single statement or expression.")
393
+ return body[0].value if isinstance(body[0], ast.Expr) else body[0]
394
+
395
+
396
+ def _is_wildcard(p: ast.AST) -> bool:
397
+ """``_``, as an expression or a statement"""
398
+ if isinstance(p, ast.Expr):
399
+ p = p.value
400
+ return isinstance(p, ast.Name) and p.id == "_"
401
+
402
+
403
+ def _is_ellipsis(p: ast.AST) -> bool:
404
+ """``...``, as an expression or a statement"""
405
+ if isinstance(p, ast.Expr):
406
+ p = p.value
407
+ return isinstance(p, ast.Constant) and p.value is Ellipsis
408
+
409
+
410
+ def _has_params(args: ast.arguments) -> bool:
411
+ return bool(
412
+ args.posonlyargs or args.args or args.vararg or args.kwonlyargs or args.kwarg
413
+ )
414
+
415
+
416
+ def _match(p: Any, n: Any) -> bool:
417
+ """whether the node (or field value) ``n`` matches the pattern ``p``"""
418
+ if isinstance(p, list):
419
+ return isinstance(n, list) and _match_list(p, n)
420
+ if not isinstance(p, ast.AST):
421
+ return p == "_" or p == n
422
+ if _is_wildcard(p):
423
+ return isinstance(n, ast.AST)
424
+ if type(p) is not type(n):
425
+ return False
426
+ if isinstance(p, ast.Constant):
427
+ # no identifier wildcard in string constants, and 1 must not match True
428
+ return type(p.value) is type(n.value) and p.value == n.value
429
+ if isinstance(p, ast.arguments) and not _has_params(p):
430
+ return True
431
+
432
+ for name in p._fields:
433
+ value = getattr(p, name, None)
434
+ if name in _IGNORED_FIELDS or (
435
+ name in _OPTIONAL_FIELDS and value in (None, [])
436
+ ):
437
+ continue
438
+ if not _match(value, getattr(n, name, None)):
439
+ return False
440
+ return True
441
+
442
+
443
+ def _match_list(ps: list, ns: list) -> bool:
444
+ if not ps:
445
+ return not ns
446
+ if _is_ellipsis(ps[0]):
447
+ return any(_match_list(ps[1:], ns[i:]) for i in range(len(ns) + 1))
448
+ return bool(ns) and _match(ps[0], ns[0]) and _match_list(ps[1:], ns[1:])
449
+
450
+
451
+ # analysis #############################################################################
452
+
453
+
454
+ def _child_defs(
455
+ node: ast.AST,
456
+ ) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef]:
457
+ """the defs in ``node``, which are not nested in other defs"""
458
+ for child in ast.iter_child_nodes(node):
459
+ if isinstance(child, _DEF_TYPES):
460
+ yield child
461
+ else:
462
+ yield from _child_defs(child)
463
+
464
+
465
+ def _descendants(node: ast.AST) -> Iterator[ast.AST]:
466
+ for child in ast.iter_child_nodes(node):
467
+ yield from ast.walk(child)
468
+
469
+
470
+ def _own_nodes(node: ast.AST) -> Iterator[ast.AST]:
471
+ """the nodes in the body of a function, excluding those in nested defs"""
472
+ is_function = isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
473
+ stack: list[ast.AST] = list(node.body) if is_function else []
474
+ while stack:
475
+ n = stack.pop()
476
+ yield n
477
+ if not isinstance(n, _DEF_TYPES):
478
+ stack.extend(ast.iter_child_nodes(n))
479
+
480
+
481
+ @dataclass(eq=False)
482
+ class _Def:
483
+ """a module, function or class, with the functions and classes defined in it"""
484
+
485
+ node: ast.AST
486
+ parent: _Def | None = None
487
+ children: dict[str, _Def] = field(default_factory=dict)
488
+
489
+ def __post_init__(self):
490
+ for child in _child_defs(self.node):
491
+ self.children[child.name] = _Def(child, self)
492
+
493
+ @property
494
+ def is_class(self) -> bool:
495
+ return isinstance(self.node, ast.ClassDef)
496
+
497
+ @property
498
+ def is_function(self) -> bool:
499
+ return isinstance(self.node, ast.FunctionDef | ast.AsyncFunctionDef)
500
+
501
+ def walk(self) -> Iterator[_Def]:
502
+ yield self
503
+ for child in self.children.values():
504
+ yield from child.walk()
505
+
506
+ def lookup(self, name: str) -> _Def | None:
507
+ """resolve ``name`` as Python would inside this def: in the enclosing functions and
508
+ the module, skipping class bodies"""
509
+ d: _Def | None = self
510
+ while d is not None:
511
+ if not d.is_class and name in d.children:
512
+ return d.children[name]
513
+ d = d.parent
514
+ return None
515
+
516
+ def member(self, name: str, seen: frozenset = frozenset()) -> _Def | None:
517
+ """the member ``name`` of this class, including those of base classes defined in
518
+ the same module"""
519
+ if name in self.children:
520
+ return self.children[name]
521
+ for base in getattr(self.node, "bases", ()):
522
+ cls = (
523
+ self.parent.lookup(base.id)
524
+ if self.parent and isinstance(base, ast.Name)
525
+ else None
526
+ )
527
+ if (
528
+ cls
529
+ and cls.is_class
530
+ and cls not in seen
531
+ and (found := cls.member(name, seen | {self}))
532
+ ):
533
+ return found
534
+ return None
535
+
536
+ def enclosing_class(self) -> _Def | None:
537
+ d = self.parent
538
+ while d is not None and not d.is_class:
539
+ d = d.parent
540
+ return d
541
+
542
+ def resolve(self, func: ast.expr) -> _Def | None:
543
+ """the function called by ``func(...)`` inside this def, if it is defined in the
544
+ module. Methods are resolved by name: ``C.m(...)`` calls the method ``m`` of the
545
+ class ``C``, any other ``_.m(...)`` the method ``m`` of the enclosing class."""
546
+ target = None
547
+ if isinstance(func, ast.Name):
548
+ target = self.lookup(func.id)
549
+ elif isinstance(func, ast.Attribute):
550
+ owner = (
551
+ self.lookup(func.value.id) if isinstance(func.value, ast.Name) else None
552
+ )
553
+ cls = owner if owner and owner.is_class else self.enclosing_class()
554
+ target = cls.member(func.attr) if cls else None
555
+ if target and target.is_class:
556
+ target = target.member("__init__")
557
+ return target if target and target.is_function else None
558
+
559
+
560
+ def _recursive_calls(tree: ast.Module) -> set[ast.AST]:
561
+ """calls from a function to itself, directly or via other functions"""
562
+ functions = [d for d in _Def(tree).walk() if d.is_function]
563
+ edges = {
564
+ f: [
565
+ (call, callee)
566
+ for call in _own_nodes(f.node)
567
+ if isinstance(call, ast.Call) and (callee := f.resolve(call.func))
568
+ ]
569
+ for f in functions
570
+ }
571
+
572
+ def reaches(start: _Def, goal: _Def) -> bool:
573
+ seen, stack = set(), [start]
574
+ while stack:
575
+ d = stack.pop()
576
+ if d is goal:
577
+ return True
578
+ if d not in seen:
579
+ seen.add(d)
580
+ stack.extend(callee for _, callee in edges.get(d, ()))
581
+ return False
582
+
583
+ return {call for f in functions for call, callee in edges[f] if reaches(callee, f)}
584
+
585
+
586
+ def _nested_loops(tree: ast.Module) -> set[ast.AST]:
587
+ """loops inside other loops of the same function"""
588
+ nested = set()
589
+
590
+ def visit(node: ast.AST, in_loop: bool) -> None:
591
+ for child in ast.iter_child_nodes(node):
592
+ if isinstance(child, _LOOP_TYPES):
593
+ if in_loop:
594
+ nested.add(child)
595
+ visit(child, True)
596
+ else:
597
+ visit(
598
+ child, in_loop and not isinstance(child, (*_DEF_TYPES, ast.Lambda))
599
+ )
600
+
601
+ visit(tree, False)
602
+ return nested
603
+
604
+
605
+ def _find_scope(tree: ast.Module, scope: str) -> ast.AST | None:
606
+ node: ast.AST | None = tree
607
+ for name in scope.split("."):
608
+ matches = [d for d in _child_defs(node) if d.name == name]
609
+ node = matches[-1] if matches else None
610
+ if node is None:
611
+ return None
612
+ return node
613
+
614
+
615
+ # evaluation ###########################################################################
616
+
617
+
618
+ @dataclass(frozen=True)
619
+ class _Markup:
620
+ rich: bool
621
+
622
+ def text(self, text: str) -> str:
623
+ """``text`` as it is, escaped if rich markup is used"""
624
+ return escape(text) if self.rich else text
625
+
626
+ def b(self, text: Any) -> str:
627
+ """bold ``text``, if rich markup is used"""
628
+ return f"[b]{escape(str(text))}[/]" if self.rich else str(text)
629
+
630
+ def q(self, name: str) -> str:
631
+ """quote and bold ``name``"""
632
+ return self.b(f"'{name}'")
633
+
634
+
635
+ class _Context:
636
+ def __init__(
637
+ self, tree: ast.Module, o: _Markup, locate: Callable[[int], str] | None = None
638
+ ):
639
+ self.tree = tree
640
+ self.o = o
641
+ self.locate = locate
642
+ self._nodes: dict[str | None, list[ast.AST]] = {}
643
+
644
+ @cached_property
645
+ def recursive_calls(self) -> set[ast.AST]:
646
+ return _recursive_calls(self.tree)
647
+
648
+ @cached_property
649
+ def nested_loops(self) -> set[ast.AST]:
650
+ return _nested_loops(self.tree)
651
+
652
+ def nodes_in(self, scope: str | None) -> list[ast.AST]:
653
+ if scope not in self._nodes:
654
+ node: ast.AST | None = self.tree
655
+ if scope is not None and (node := _find_scope(self.tree, scope)) is None:
656
+ raise _MissingScope(scope)
657
+ self._nodes[scope] = list(_descendants(node))
658
+ return self._nodes[scope]
659
+
660
+
661
+ def _find(rule: Rule, ctx: _Context, scope: str | None) -> list[ast.AST]:
662
+ nodes = ctx.nodes_in(scope)
663
+ if "concept" in rule:
664
+ concept = CONCEPTS[rule["concept"]]
665
+ params = {p: rule[p] for p in concept.params if p in rule}
666
+ return concept.find(nodes, ctx, **params)
667
+ if "node" in rule:
668
+ names = [rule["node"]] if isinstance(rule["node"], str) else rule["node"]
669
+ types = tuple(getattr(ast, name) for name in names)
670
+ return [n for n in nodes if isinstance(n, types)]
671
+ pattern = _compile_pattern(rule["pattern"])
672
+ return [n for n in nodes if _match(pattern, n)]
673
+
674
+
675
+ def _noun(rule: Rule, o: _Markup) -> str:
676
+ """what a leaf rule looks for, e.g. "loop" or "call to 'sorted'" """
677
+ if "concept" in rule:
678
+ concept = CONCEPTS[rule["concept"]]
679
+ return concept.describe({p: rule[p] for p in concept.params if p in rule}, o)
680
+ if "node" in rule:
681
+ names = [rule["node"]] if isinstance(rule["node"], str) else rule["node"]
682
+ return f"{' or '.join(o.b(n) for n in names)} node"
683
+ return o.b(f"`{' '.join(rule['pattern'].split())}`")
684
+
685
+
686
+ def _requirement(rule: Rule, o: _Markup, scope: str | None) -> str:
687
+ """what a leaf rule requires, e.g. "no loop in 'fib'" """
688
+ noun, (lo, hi) = _noun(rule, o), _bounds(rule)
689
+ if hi == 0:
690
+ text = f"no {noun}"
691
+ elif lo == 1 and hi is None:
692
+ text = noun
693
+ elif lo == hi:
694
+ text = f"exactly {lo} × {noun}"
695
+ elif hi is None:
696
+ text = f"at least {lo} × {noun}"
697
+ elif lo == 0:
698
+ text = f"at most {hi} × {noun}"
699
+ else:
700
+ text = f"between {lo} and {hi} × {noun}"
701
+ return f"{text} in {o.q(scope)}" if scope else text
702
+
703
+
704
+ def _describe(rule: Rule, o: _Markup, scope: str | None) -> list[str]:
705
+ """lines describing what ``rule`` requires"""
706
+ scope = rule.get("scope", scope)
707
+ if "not" in rule:
708
+ return ["not:", *_bullets([_describe(rule["not"], o, scope)])]
709
+ for key in ("all", "any"):
710
+ if key in rule:
711
+ return [f"{key} of:", *_bullets(_describe(c, o, scope) for c in rule[key])]
712
+ return [_requirement(rule, o, scope)]
713
+
714
+
715
+ def _bullets(items) -> list[str]:
716
+ """indent a list of line lists as bullet points"""
717
+ return [
718
+ f" - {line}" if i == 0 else f" {line}"
719
+ for lines in items
720
+ for i, line in enumerate(lines)
721
+ ]
722
+
723
+
724
+ def _found(nodes: list[ast.AST], ctx: _Context) -> str:
725
+ o = ctx.o
726
+ if not nodes:
727
+ return "none"
728
+ lines = sorted(
729
+ {ln for n in nodes if (ln := getattr(n, "lineno", None)) is not None}
730
+ )
731
+ if not lines:
732
+ return o.b(len(nodes))
733
+ if ctx.locate is not None:
734
+ return f"{o.b(len(nodes))} ({'; '.join(map(ctx.locate, lines))})"
735
+ label = "line" if len(lines) == 1 else "lines"
736
+ return f"{o.b(len(nodes))} ({label} {', '.join(map(str, lines))})"
737
+
738
+
739
+ def _evaluate(rule: Rule, ctx: _Context, scope: str | None) -> list[str]:
740
+ """messages for ``rule`` not holding, empty if it holds"""
741
+ o = ctx.o
742
+ scope = rule.get("scope", scope)
743
+
744
+ if "all" in rule:
745
+ messages = [m for child in rule["all"] for m in _evaluate(child, ctx, scope)]
746
+ elif "any" in rule:
747
+ # all children are evaluated, so that missing scopes are always reported
748
+ failures = [_evaluate(child, ctx, scope) for child in rule["any"]]
749
+ if not all(failures):
750
+ return []
751
+ lines = [[line for m in msgs for line in m.splitlines()] for msgs in failures]
752
+ messages = ["\n".join(["None of the following holds:", *_bullets(lines)])]
753
+ elif "not" in rule:
754
+ if _evaluate(rule["not"], ctx, scope):
755
+ return []
756
+ description = _bullets([_describe(rule["not"], o, scope)])
757
+ messages = ["\n".join(["Expected the following not to hold:", *description])]
758
+ else:
759
+ found = _find(rule, ctx, scope)
760
+ lo, hi = _bounds(rule)
761
+ if lo <= len(found) and (hi is None or len(found) <= hi):
762
+ return []
763
+ messages = [
764
+ f"Expected {_requirement(rule, o, scope)}, found {_found(found, ctx)}."
765
+ ]
766
+
767
+ if messages and "message" in rule:
768
+ return [o.text(rule["message"])]
769
+ return messages