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.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- 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
|