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
pyics/tests/tests.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import enum
|
|
3
|
+
import importlib.util as iutil
|
|
4
|
+
import sys
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from importlib.machinery import ModuleSpec
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import MappingProxyType, ModuleType
|
|
11
|
+
from typing import Any, ClassVar, Literal, Protocol, Self
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from _pytest.assertion.util import assertrepr_compare, dummy_highlighter
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
|
|
17
|
+
import pyics.util.json_handling as json
|
|
18
|
+
from pyics.notebook import cells, loader, sections
|
|
19
|
+
from pyics.tests.capture import capture_stdout, provide_input
|
|
20
|
+
from pyics.tests.trace import Trace
|
|
21
|
+
from pyics.util import paths
|
|
22
|
+
from pyics.util.paths import StrPath
|
|
23
|
+
from . import check_specs, check_structure, compare
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Inherit(enum.Enum):
|
|
27
|
+
INHERIT = "inherit"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# the section of a test that wasn't given one (see ``PrintTest`` and ``StructureTest``):
|
|
31
|
+
# it inherits its task's section for its file (see ``TASK_SECTIONS``)
|
|
32
|
+
INHERIT = _Inherit.INHERIT
|
|
33
|
+
type Inherit = Literal[_Inherit.INHERIT]
|
|
34
|
+
|
|
35
|
+
# The section of each exercise notebook that the task whose tests are running is about
|
|
36
|
+
# (see ``Task.sections``), by the notebook's absolute path. Set while a test runs (see
|
|
37
|
+
# ``PyicsItem.runtest``), so that tests that weren't given a section of their own
|
|
38
|
+
# inherit their task's (see ``Test.resolved_section``), without storing it.
|
|
39
|
+
TASK_SECTIONS: ContextVar[Mapping[Path, str]] = ContextVar(
|
|
40
|
+
"TASK_SECTIONS", default=MappingProxyType({})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LiveSource(Protocol):
|
|
45
|
+
"""
|
|
46
|
+
Where tests take the code under test from while they run on code that isn't saved
|
|
47
|
+
yet (see ``LIVE_SOURCE``), e.g. on the objects a notebook's kernel defines.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def module(self, path: StrPath) -> ModuleType | None:
|
|
51
|
+
"""The code of the exercise file at ``path`` as a module, or None if this
|
|
52
|
+
source doesn't hold it, so that the file is loaded as usual."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Set by a plugin while the tests run on code that isn't saved yet (see ``LiveSource``):
|
|
57
|
+
# ``Test.load_solution`` takes the code of the files it holds from it, and
|
|
58
|
+
# ``PrintTest``s and ``StructureTest``s are skipped, as they run on the saved file.
|
|
59
|
+
LIVE_SOURCE: ContextVar[LiveSource | None] = ContextVar("LIVE_SOURCE", default=None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _skip_live_run() -> None:
|
|
63
|
+
"""Skip the running test if it runs on code that isn't saved yet (see
|
|
64
|
+
``LIVE_SOURCE``)."""
|
|
65
|
+
if LIVE_SOURCE.get() is not None:
|
|
66
|
+
pytest.skip("runs when the notebook is saved")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MarkupAssertionError(AssertionError):
|
|
70
|
+
"""
|
|
71
|
+
``AssertionError`` whose message is ``rich`` markup, which is rendered in the main
|
|
72
|
+
panel. Messages of other exceptions are shown as they are.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Test(ABC):
|
|
77
|
+
"""Base Class for Tests"""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
file_path: StrPath
|
|
81
|
+
# the section of the notebook ``file_path`` the test is about (see
|
|
82
|
+
# ``resolved_section``): None for the whole notebook, which is all that tests of
|
|
83
|
+
# other kinds than ``PrintTest`` and ``StructureTest`` are about
|
|
84
|
+
section: str | None | Inherit = None
|
|
85
|
+
# whether the test runs the code in ``file_path``, which can then be traced (see
|
|
86
|
+
# ``run``)
|
|
87
|
+
supports_trace: ClassVar[bool] = False
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def __init__(self): ...
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, test_dict: dict) -> Self:
|
|
94
|
+
return cls(**test_dict)
|
|
95
|
+
|
|
96
|
+
def __call__(self) -> None:
|
|
97
|
+
self.run()
|
|
98
|
+
|
|
99
|
+
def load_solution(
|
|
100
|
+
self, *, as_main: bool = False, trace: Trace | None = None
|
|
101
|
+
) -> ModuleType:
|
|
102
|
+
"""
|
|
103
|
+
Run the file at ``file_path`` as a new module, and return it. With ``as_main``,
|
|
104
|
+
it is run as a script, i.e. with ``__name__ == "__main__"``.
|
|
105
|
+
|
|
106
|
+
With ``trace``, the file's code is watched by it (see ``Trace.watch``), so that
|
|
107
|
+
the calls into it that are run by ``trace`` are traced. A script is traced as a
|
|
108
|
+
whole, while importing a module isn't.
|
|
109
|
+
|
|
110
|
+
A notebook's code cells are run instead (see ``loader.run_notebook``), where an
|
|
111
|
+
error in a cell doesn't stop the run. While a ``LIVE_SOURCE`` that holds the
|
|
112
|
+
file is set, its module is returned instead, untraced.
|
|
113
|
+
"""
|
|
114
|
+
if (source := LIVE_SOURCE.get()) is not None and (
|
|
115
|
+
module := source.module(self.file_path)
|
|
116
|
+
) is not None:
|
|
117
|
+
return module
|
|
118
|
+
if cells.is_notebook(self.file_path):
|
|
119
|
+
return loader.load_module(self.file_path, as_main=as_main, trace=trace)
|
|
120
|
+
|
|
121
|
+
spec: ModuleSpec | None = iutil.spec_from_file_location(
|
|
122
|
+
name="current_solution", location=self.file_path
|
|
123
|
+
)
|
|
124
|
+
if not isinstance(spec, ModuleSpec):
|
|
125
|
+
raise ImportError("Could not import solution file. Has the file been moved?")
|
|
126
|
+
if spec.loader is None:
|
|
127
|
+
raise AttributeError("spec.loader cannot be None.")
|
|
128
|
+
|
|
129
|
+
module = iutil.module_from_spec(spec)
|
|
130
|
+
if as_main:
|
|
131
|
+
module.__name__ = "__main__"
|
|
132
|
+
sys.modules["current_solution"] = module
|
|
133
|
+
# compiled from source rather than run by the loader, whose cached bytecode is
|
|
134
|
+
# stale if the file was saved twice within a second without changing its size
|
|
135
|
+
source = Path(self.file_path).read_bytes()
|
|
136
|
+
code = compile(source, self.file_path, "exec", dont_inherit=True)
|
|
137
|
+
if trace is not None:
|
|
138
|
+
trace.watch(code)
|
|
139
|
+
if trace is not None and as_main:
|
|
140
|
+
trace.run(lambda: exec(code, module.__dict__)) # noqa: S102
|
|
141
|
+
else:
|
|
142
|
+
exec(code, module.__dict__) # noqa: S102
|
|
143
|
+
|
|
144
|
+
return module
|
|
145
|
+
|
|
146
|
+
def resolved_section(self) -> str | None:
|
|
147
|
+
"""
|
|
148
|
+
The name of the section of the notebook ``file_path`` the test is about (see
|
|
149
|
+
``pyics.notebook.sections``), or None for the whole notebook: its own
|
|
150
|
+
``section``, or if it wasn't given one, its task's section for its file while
|
|
151
|
+
the task's tests run (see ``TASK_SECTIONS``).
|
|
152
|
+
"""
|
|
153
|
+
if self.section is INHERIT:
|
|
154
|
+
return TASK_SECTIONS.get().get(paths.absolute(self.file_path))
|
|
155
|
+
return self.section
|
|
156
|
+
|
|
157
|
+
def serialize(self) -> str:
|
|
158
|
+
"""turn this test object into a dictionary for export"""
|
|
159
|
+
return json.dumps(self)
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def run(self, trace: Trace | None = None) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Run the test, raising an ``AssertionError`` if it fails. With ``trace``, the code
|
|
165
|
+
under test is traced by it, if the test ``supports_trace`` and the test's author
|
|
166
|
+
allows it.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _visible(output: str) -> str:
|
|
171
|
+
"""``output`` without the whitespace that isn't visible in a console: at the end of
|
|
172
|
+
lines, and blank lines at the end."""
|
|
173
|
+
return "\n".join(line.rstrip() for line in output.split("\n")).rstrip("\n")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PrintTest(Test):
|
|
177
|
+
"""
|
|
178
|
+
Console output of a file must match the desired output.
|
|
179
|
+
|
|
180
|
+
The file is run as a script (with ``__name__ == "__main__"``) until it ends or exits,
|
|
181
|
+
e.g. by calling ``sys.exit()``. Its ``input()`` calls are answered with ``inputs``,
|
|
182
|
+
in order, which show up in the output like answers typed into a console. Whitespace
|
|
183
|
+
that isn't visible in a console is ignored. Only the output of the thread running the
|
|
184
|
+
test is captured, so that other threads (e.g. the CLI's) can keep writing to the
|
|
185
|
+
console in the meantime.
|
|
186
|
+
|
|
187
|
+
A notebook's code cells are run instead, and their output is compared, which
|
|
188
|
+
includes what Jupyter shows as text: the ``repr`` of objects passed to ``display``,
|
|
189
|
+
and of each cell's final expression (unless it is ``None``, or the cell ends with
|
|
190
|
+
``;``). It only has to contain the desired output, so that what students output
|
|
191
|
+
besides (e.g. the value of an expression they left at the end of a cell) doesn't
|
|
192
|
+
fail the test: ``[1, 2]`` is also found in ``[[1, 2]]``. If no output is desired,
|
|
193
|
+
there must be none. With a ``section`` (see ``Test.resolved_section``), the whole
|
|
194
|
+
notebook is run, but only the output of the section's cells is compared. A test
|
|
195
|
+
that isn't given a ``section`` inherits its task's; None compares the output of
|
|
196
|
+
all cells.
|
|
197
|
+
|
|
198
|
+
While a ``LIVE_SOURCE`` is set, it is skipped.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
expected: str # printout is necessarily str
|
|
202
|
+
inputs: list[str]
|
|
203
|
+
supports_trace = True
|
|
204
|
+
# whether the test may be traced, e.g. by students who want to see what their code
|
|
205
|
+
# does. Also the default for tests stored before it was introduced.
|
|
206
|
+
traceable: bool = True
|
|
207
|
+
# only set if the test is given a section, so that tests that inherit their task's
|
|
208
|
+
# section store nothing (and tests stored before sections inherit it, too)
|
|
209
|
+
section: str | None | Inherit = INHERIT
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
name: str,
|
|
214
|
+
file_path: StrPath,
|
|
215
|
+
expected: str,
|
|
216
|
+
*,
|
|
217
|
+
inputs: list[str] = [],
|
|
218
|
+
traceable: bool = True,
|
|
219
|
+
section: str | None | Inherit = INHERIT,
|
|
220
|
+
**kwargs,
|
|
221
|
+
):
|
|
222
|
+
if isinstance(inputs, str):
|
|
223
|
+
raise TypeError("inputs must be a list, with an answer per input() call")
|
|
224
|
+
self.name = name
|
|
225
|
+
self.file_path = file_path
|
|
226
|
+
self.expected = expected
|
|
227
|
+
self.inputs = [str(answer) for answer in inputs]
|
|
228
|
+
self.traceable = traceable
|
|
229
|
+
if section is not INHERIT:
|
|
230
|
+
self.section = None if section is None else sections.check_name(section)
|
|
231
|
+
|
|
232
|
+
def run(self, trace: Trace | None = None) -> None:
|
|
233
|
+
_skip_live_run()
|
|
234
|
+
trace = trace if self.traceable else None
|
|
235
|
+
is_notebook = cells.is_notebook(self.file_path)
|
|
236
|
+
with capture_stdout() as buffer, provide_input(self.inputs):
|
|
237
|
+
if is_notebook:
|
|
238
|
+
if (section := self.resolved_section()) is not None:
|
|
239
|
+
# a missing section fails the test before the notebook is run
|
|
240
|
+
sections.get_section(cells.read(self.file_path), section)
|
|
241
|
+
# each cell's output is captured on its own
|
|
242
|
+
run = loader.run_notebook(self.file_path, as_main=True, trace=trace)
|
|
243
|
+
buffer.write(run.output(section=section))
|
|
244
|
+
else:
|
|
245
|
+
try:
|
|
246
|
+
self.load_solution(as_main=True, trace=trace)
|
|
247
|
+
except SystemExit:
|
|
248
|
+
pass # the program ended
|
|
249
|
+
res, expected = _visible(buffer.getvalue()), _visible(self.expected)
|
|
250
|
+
# a notebook's output only has to contain the expected one, unless there is
|
|
251
|
+
# none, which any output would contain
|
|
252
|
+
contains = is_notebook and expected != ""
|
|
253
|
+
|
|
254
|
+
if not (expected in res if contains else res == expected):
|
|
255
|
+
expl = list(
|
|
256
|
+
assertrepr_compare(
|
|
257
|
+
"==",
|
|
258
|
+
res,
|
|
259
|
+
expected,
|
|
260
|
+
verbose=1,
|
|
261
|
+
highlighter=dummy_highlighter,
|
|
262
|
+
assertion_text_diff_style="ndiff",
|
|
263
|
+
)
|
|
264
|
+
) or [f"{res!r} == {expected!r}"]
|
|
265
|
+
if contains:
|
|
266
|
+
# the summary of what was compared, above the diff
|
|
267
|
+
expl[0] = compare.summary(expected, res, op="in")
|
|
268
|
+
|
|
269
|
+
raise AssertionError("\n".join(expl))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class SimpleTest(Test):
|
|
273
|
+
"""
|
|
274
|
+
Output of a function must match the desired output for some parameters.
|
|
275
|
+
|
|
276
|
+
The output is compared with ``compare.equal``: by ``==``, except for numpy arrays and
|
|
277
|
+
pandas objects, which are compared as a whole.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
function_name: str
|
|
281
|
+
function_args: list[Any]
|
|
282
|
+
function_kwargs: dict[str, Any]
|
|
283
|
+
expected: Any
|
|
284
|
+
supports_trace = True
|
|
285
|
+
# whether the test may be traced, which shows its arguments, too. Also the default
|
|
286
|
+
# for tests stored before it was introduced.
|
|
287
|
+
traceable: bool = True
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
name: str,
|
|
292
|
+
file_path: StrPath,
|
|
293
|
+
function_name: str,
|
|
294
|
+
expected: Any,
|
|
295
|
+
*,
|
|
296
|
+
function_args: list[Any] = [],
|
|
297
|
+
function_kwargs: dict[str, Any] = {},
|
|
298
|
+
traceable: bool = True,
|
|
299
|
+
**kwargs,
|
|
300
|
+
):
|
|
301
|
+
self.name = name
|
|
302
|
+
self.file_path = file_path
|
|
303
|
+
self.function_name = function_name
|
|
304
|
+
self.expected = expected
|
|
305
|
+
self.function_args = function_args
|
|
306
|
+
self.function_kwargs = function_kwargs
|
|
307
|
+
self.traceable = traceable
|
|
308
|
+
|
|
309
|
+
def run(self, trace: Trace | None = None) -> None:
|
|
310
|
+
trace = trace if self.traceable else None
|
|
311
|
+
m = self.load_solution(trace=trace)
|
|
312
|
+
func = getattr(m, self.function_name)
|
|
313
|
+
# copied, so a solution that modifies its arguments can't change them for the
|
|
314
|
+
# next run of this test
|
|
315
|
+
args = copy.deepcopy(self.function_args)
|
|
316
|
+
kwargs = copy.deepcopy(self.function_kwargs)
|
|
317
|
+
if trace is None:
|
|
318
|
+
res = func(*args, **kwargs)
|
|
319
|
+
else:
|
|
320
|
+
res = trace.run(lambda: func(*args, **kwargs))
|
|
321
|
+
|
|
322
|
+
if not compare.equal(res, self.expected):
|
|
323
|
+
raise AssertionError("\n".join(compare.explain(res, self.expected)))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class SpecTest(Test):
|
|
327
|
+
"""
|
|
328
|
+
Symbols in file match some specification.
|
|
329
|
+
|
|
330
|
+
``expected`` is either a spec dict (see ``check_specs``), or a class or function,
|
|
331
|
+
which is converted into a spec dict so that the test can be stored as json. If
|
|
332
|
+
``by_type`` is set, parameters and attributes are matched by their type only, ignoring
|
|
333
|
+
their names and order.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
symbol_name: str
|
|
337
|
+
expected: check_specs.SpecDict
|
|
338
|
+
by_type: bool
|
|
339
|
+
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
name: str,
|
|
343
|
+
file_path: StrPath,
|
|
344
|
+
symbol_name: str,
|
|
345
|
+
expected: check_specs.SpecType,
|
|
346
|
+
*,
|
|
347
|
+
by_type: bool = False,
|
|
348
|
+
**kwargs,
|
|
349
|
+
):
|
|
350
|
+
self.name = name
|
|
351
|
+
self.file_path = file_path
|
|
352
|
+
self.symbol_name = symbol_name
|
|
353
|
+
self.expected = check_specs.to_spec_dict(expected)
|
|
354
|
+
self.by_type = by_type
|
|
355
|
+
|
|
356
|
+
def run(self, trace: Trace | None = None) -> None:
|
|
357
|
+
m = self.load_solution()
|
|
358
|
+
symb = getattr(m, self.symbol_name)
|
|
359
|
+
|
|
360
|
+
if diff := check_specs.compare_spec(self.expected, symb, by_type=self.by_type):
|
|
361
|
+
raise MarkupAssertionError("\n".join(diff))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class StructureTest(Test):
|
|
365
|
+
"""
|
|
366
|
+
Source code uses (or avoids) constructs such as loops or recursion.
|
|
367
|
+
|
|
368
|
+
``rules`` is a rule dict or a list of rule dicts (see ``check_structure``), e.g.
|
|
369
|
+
``{"concept": "recursion", "scope": "fib"}``. The file is parsed, but not executed.
|
|
370
|
+
|
|
371
|
+
Of a notebook, the code cells are checked (see ``loader.python_source``), and lines
|
|
372
|
+
are named by their cell, e.g. "cell 3, line 2". With a ``section`` (see
|
|
373
|
+
``Test.resolved_section``), only the code cells of that section are checked, and
|
|
374
|
+
the test fails if one of them can't be parsed. A test that isn't given a
|
|
375
|
+
``section`` inherits its task's; None checks all code cells, leaving out those that
|
|
376
|
+
can't be parsed (they are named if the test fails).
|
|
377
|
+
|
|
378
|
+
While a ``LIVE_SOURCE`` is set, it is skipped.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
rules: list[check_structure.Rule]
|
|
382
|
+
# only set if the test is given a section, so that tests that inherit their task's
|
|
383
|
+
# section store nothing (and tests stored before sections inherit it, too)
|
|
384
|
+
section: str | None | Inherit = INHERIT
|
|
385
|
+
|
|
386
|
+
def __init__(
|
|
387
|
+
self,
|
|
388
|
+
name: str,
|
|
389
|
+
file_path: StrPath,
|
|
390
|
+
rules: list[check_structure.Rule] | check_structure.Rule,
|
|
391
|
+
*,
|
|
392
|
+
section: str | None | Inherit = INHERIT,
|
|
393
|
+
**kwargs,
|
|
394
|
+
):
|
|
395
|
+
self.name = name
|
|
396
|
+
self.file_path = file_path
|
|
397
|
+
self.rules = check_structure.validate_rules(rules)
|
|
398
|
+
if section is not INHERIT:
|
|
399
|
+
self.section = None if section is None else sections.check_name(section)
|
|
400
|
+
|
|
401
|
+
def run(self, trace: Trace | None = None) -> None:
|
|
402
|
+
_skip_live_run()
|
|
403
|
+
path = Path(self.file_path)
|
|
404
|
+
if cells.is_notebook(path):
|
|
405
|
+
section = self.resolved_section()
|
|
406
|
+
source = loader.python_source(path, section=section)
|
|
407
|
+
diff = self._check_notebook(source, path.name, whole=section is None)
|
|
408
|
+
else:
|
|
409
|
+
diff = check_structure.check_structure(
|
|
410
|
+
self.rules, path.read_text(encoding="utf-8"), filename=path.name
|
|
411
|
+
)
|
|
412
|
+
if diff:
|
|
413
|
+
raise MarkupAssertionError("\n".join(diff))
|
|
414
|
+
|
|
415
|
+
def _check_notebook(
|
|
416
|
+
self, source: loader.NotebookSource, filename: str, *, whole: bool = True
|
|
417
|
+
) -> list[str]:
|
|
418
|
+
"""
|
|
419
|
+
Messages for the rules that don't hold for the code cells ``source``. Of the
|
|
420
|
+
``whole`` notebook, the cells left out since they can't be parsed are named if
|
|
421
|
+
a rule doesn't hold, as that may be why. Of a section, they fail the test, like
|
|
422
|
+
a Python file that can't be parsed.
|
|
423
|
+
"""
|
|
424
|
+
unparsable = [
|
|
425
|
+
f"Could not parse cell {cell.number}: {escape(err.msg)} "
|
|
426
|
+
f"(line {err.lineno})."
|
|
427
|
+
for cell, err in source.unparsable
|
|
428
|
+
]
|
|
429
|
+
if unparsable and not whole:
|
|
430
|
+
return unparsable
|
|
431
|
+
diff = check_structure.check_structure(
|
|
432
|
+
self.rules, source.source, filename=filename, locate=source.locate
|
|
433
|
+
)
|
|
434
|
+
return diff + unparsable if diff else []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class SizeTest(Test):
|
|
438
|
+
"""file has a maximum line number or size"""
|
|
439
|
+
|
|
440
|
+
def __init__(self, test_dict: dict): ...
|
pyics/tests/trace.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Call traces of the code under test, recorded with ``snoop``: every line that is run,
|
|
3
|
+
with the values of the variables it changes, and the calls it makes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable, Generator, Iterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import CodeType
|
|
12
|
+
|
|
13
|
+
import snoop
|
|
14
|
+
from rich.style import Style
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
from snoop.formatting import DefaultFormatter, Event
|
|
17
|
+
from snoop.tracer import FrameInfo
|
|
18
|
+
from snoop.utils import ArgDefaultDict
|
|
19
|
+
|
|
20
|
+
# the most lines a trace keeps: its first and last half, and how many were left out in
|
|
21
|
+
# between
|
|
22
|
+
MAX_TRACE_LINES = 300
|
|
23
|
+
|
|
24
|
+
# e.g. ``__builtins__`` and ``len(__builtins__)``
|
|
25
|
+
_DUNDER = re.compile(r"\b__\w+__\b")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Lines:
|
|
29
|
+
"""
|
|
30
|
+
Keeps the first and last of the lines written to it, and counts the ones in between,
|
|
31
|
+
so that e.g. the trace of an endless loop doesn't grow without bounds.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_lines: int):
|
|
35
|
+
self._head_size = max_lines // 2
|
|
36
|
+
self.head: list[str] = []
|
|
37
|
+
self.tail: deque[str] = deque(maxlen=max_lines - self._head_size)
|
|
38
|
+
self.omitted = 0
|
|
39
|
+
self._partial = ""
|
|
40
|
+
|
|
41
|
+
def write(self, s: str) -> None:
|
|
42
|
+
*lines, self._partial = (self._partial + s).split("\n")
|
|
43
|
+
for line in lines:
|
|
44
|
+
self._add(line)
|
|
45
|
+
|
|
46
|
+
def _add(self, line: str) -> None:
|
|
47
|
+
if len(self.head) < self._head_size:
|
|
48
|
+
self.head.append(line)
|
|
49
|
+
return
|
|
50
|
+
if len(self.tail) == self.tail.maxlen:
|
|
51
|
+
self.omitted += 1
|
|
52
|
+
self.tail.append(line)
|
|
53
|
+
|
|
54
|
+
def flush(self) -> None:
|
|
55
|
+
if self._partial:
|
|
56
|
+
self._add(self._partial)
|
|
57
|
+
self._partial = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _FrameInfo(FrameInfo):
|
|
61
|
+
"""``FrameInfo`` leaving out dunder variables, e.g. the ``__builtins__`` of a
|
|
62
|
+
module."""
|
|
63
|
+
|
|
64
|
+
def get_local_reprs(self, *args, **kwargs) -> Generator[tuple[str, object]]:
|
|
65
|
+
for source, value in super().get_local_reprs(*args, **kwargs):
|
|
66
|
+
if not _DUNDER.search(source):
|
|
67
|
+
yield source, value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Formatter(DefaultFormatter):
|
|
71
|
+
"""
|
|
72
|
+
``DefaultFormatter`` naming the files of calls without their directories, and
|
|
73
|
+
showing where a module starts: at its first line, rather than at line 0, which
|
|
74
|
+
modules start on before running their first line.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def format(self, event: Event) -> str:
|
|
78
|
+
if event.event == "call" and event.line_no == 0:
|
|
79
|
+
event.line_no = 1
|
|
80
|
+
formatted = self.format_lines(event, self.format_start(event))
|
|
81
|
+
else:
|
|
82
|
+
formatted = super().format(event)
|
|
83
|
+
if event.event == "call":
|
|
84
|
+
path = event.code.co_filename
|
|
85
|
+
formatted = formatted.replace(f'File "{path}"', f'File "{Path(path).name}"')
|
|
86
|
+
return formatted
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _code_objects(code: CodeType) -> Iterator[CodeType]:
|
|
90
|
+
"""``code`` and the code objects defined in it, e.g. of functions, classes, their
|
|
91
|
+
methods and lambdas."""
|
|
92
|
+
yield code
|
|
93
|
+
for const in code.co_consts:
|
|
94
|
+
if isinstance(const, CodeType):
|
|
95
|
+
yield from _code_objects(const)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Trace:
|
|
99
|
+
"""
|
|
100
|
+
Records the trace of the code it watches (see ``watch``) while it is run (see
|
|
101
|
+
``run``): of each call into that code, however deeply nested, but not of other
|
|
102
|
+
code, such as libraries or pyics itself. Only the thread calling ``run`` is traced.
|
|
103
|
+
|
|
104
|
+
At most ``max_lines`` lines are kept (see ``text``).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, max_lines: int = MAX_TRACE_LINES):
|
|
108
|
+
self._lines = _Lines(max_lines)
|
|
109
|
+
self._config = snoop.Config(
|
|
110
|
+
out=self._lines.write, columns="", color=True, formatter_class=_Formatter
|
|
111
|
+
)
|
|
112
|
+
# every frame of the watched code is a target, so depth 1 traces all of them
|
|
113
|
+
self._tracer = self._config.snoop(depth=1)
|
|
114
|
+
self._tracer.frame_infos = ArgDefaultDict(_FrameInfo)
|
|
115
|
+
|
|
116
|
+
def watch(self, code: CodeType) -> None:
|
|
117
|
+
"""Trace ``code`` and all code defined in it, e.g. a module's functions."""
|
|
118
|
+
self._tracer.target_codes.update(_code_objects(code))
|
|
119
|
+
|
|
120
|
+
def run[T](self, call: Callable[[], T]) -> T:
|
|
121
|
+
"""Return ``call()``, tracing the watched code it runs."""
|
|
122
|
+
self._config.thread_local.__dict__.setdefault("depth", -1)
|
|
123
|
+
previous = sys.gettrace()
|
|
124
|
+
sys.settrace(self._tracer.trace)
|
|
125
|
+
try:
|
|
126
|
+
return call()
|
|
127
|
+
finally:
|
|
128
|
+
sys.settrace(previous)
|
|
129
|
+
self._lines.flush()
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def ansi(self) -> str:
|
|
133
|
+
"""The trace as snoop highlighted it, with ANSI escape codes, one trace line per
|
|
134
|
+
line, so that it can be passed on as text, e.g. to another process (see
|
|
135
|
+
``ansi_text``). If it is longer than ``max_lines``, the lines in between its
|
|
136
|
+
first and last ones are left out, and marked as such."""
|
|
137
|
+
lines = list(self._lines.head)
|
|
138
|
+
if self._lines.omitted:
|
|
139
|
+
omitted = f"... {self._lines.omitted} lines omitted ..."
|
|
140
|
+
lines.append(Style(dim=True).render(omitted))
|
|
141
|
+
lines += self._lines.tail
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def text(self) -> Text:
|
|
146
|
+
"""The trace, highlighted (see ``ansi``)."""
|
|
147
|
+
return ansi_text(self.ansi)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ansi_text(ansi: str) -> Text:
|
|
151
|
+
"""
|
|
152
|
+
The highlighted text of ``ansi``, text with ANSI escape codes such as a trace (see
|
|
153
|
+
``Trace.ansi``). Each line is decoded on its own, so that a color left open at the end
|
|
154
|
+
of a line doesn't run on into the next.
|
|
155
|
+
"""
|
|
156
|
+
return Text("\n").join(Text.from_ansi(line) for line in ansi.split("\n"))
|
pyics/util/discovery.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Find task files and course manifests below a directory.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from pathlib import Path, PurePath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pyics.util import json_handling as json
|
|
12
|
+
from pyics.util import paths
|
|
13
|
+
|
|
14
|
+
TASK_SUFFIX = ".pyicstask.json"
|
|
15
|
+
COURSE_SUFFIX = ".pyicscourse.json"
|
|
16
|
+
# never searched for task files or course manifests: large, and never contain any
|
|
17
|
+
SKIP_DIRS = {
|
|
18
|
+
".git",
|
|
19
|
+
".venv",
|
|
20
|
+
"venv",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".ruff_cache",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# a plain logger rather than ``get_logger``, which would set up pyics's log file when
|
|
28
|
+
# courses are looked for outside the TUI, e.g. by a plugin in a notebook's kernel
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_files(root: Path, max_depth: int, suffix: str) -> Iterator[Path]:
|
|
33
|
+
"""The files whose names end in ``suffix`` at most ``max_depth`` directories below
|
|
34
|
+
``root``, skipping ``SKIP_DIRS``, relative to ``root``."""
|
|
35
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
36
|
+
rel = Path(dirpath).relative_to(root)
|
|
37
|
+
if len(rel.parts) >= max_depth:
|
|
38
|
+
dirnames.clear()
|
|
39
|
+
else:
|
|
40
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
41
|
+
for filename in sorted(filenames):
|
|
42
|
+
if filename.endswith(suffix):
|
|
43
|
+
yield rel / filename
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_task_files(root: Path, max_depth: int) -> dict[Path, str]:
|
|
47
|
+
"""
|
|
48
|
+
Find task files at most ``max_depth`` directories below ``root``, skipping
|
|
49
|
+
``SKIP_DIRS``. Files that can't be read or have no task name are skipped.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
dict[Path, str]
|
|
54
|
+
The task files' paths, relative to ``root``, mapped to their task names.
|
|
55
|
+
"""
|
|
56
|
+
found: dict[Path, str] = {}
|
|
57
|
+
for rel in find_files(root, max_depth, TASK_SUFFIX):
|
|
58
|
+
try:
|
|
59
|
+
name = json.load_plain(root / rel)["name"]
|
|
60
|
+
except Exception: # noqa: BLE001
|
|
61
|
+
_logger.debug(f"Skipping {root / rel}", exc_info=True)
|
|
62
|
+
continue
|
|
63
|
+
if isinstance(name, str):
|
|
64
|
+
found[rel] = name
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_courses(root: Path, max_depth: int) -> dict[Path, dict[str, Any]]:
|
|
69
|
+
"""
|
|
70
|
+
Find course manifests at most ``max_depth`` directories below ``root``, skipping
|
|
71
|
+
``SKIP_DIRS``. Files that can't be read or have no tasks are skipped.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
dict[Path, dict[str, Any]]
|
|
76
|
+
The manifests' absolute paths, mapped to their contents.
|
|
77
|
+
"""
|
|
78
|
+
found: dict[Path, dict[str, Any]] = {}
|
|
79
|
+
for rel in find_files(root, max_depth, COURSE_SUFFIX):
|
|
80
|
+
path = paths.absolute(root / rel)
|
|
81
|
+
try:
|
|
82
|
+
# read as plain data, so finding courses never imports a module
|
|
83
|
+
manifest = json.load_plain(path)
|
|
84
|
+
tasks = manifest["tasks"]
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
_logger.debug(f"Skipping {path}", exc_info=True)
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(tasks, dict) and all(
|
|
89
|
+
isinstance(v, (str, PurePath)) for v in tasks.values()
|
|
90
|
+
):
|
|
91
|
+
found[path] = manifest
|
|
92
|
+
return found
|