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/data/task.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Union
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from rich.console import Group, RenderableType
|
|
12
|
+
from rich.padding import Padding
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
import pyics.util.json_handling as json
|
|
18
|
+
from pyics import plugins
|
|
19
|
+
from pyics.distribution.dependencies import ModulePolicy, check_task_data
|
|
20
|
+
from pyics.notebook import cells
|
|
21
|
+
from pyics.notebook import sections as notebook_sections
|
|
22
|
+
from pyics.util import paths, terminal
|
|
23
|
+
from pyics.util.paths import StrPath
|
|
24
|
+
from pyics.util.logging import get_logger
|
|
25
|
+
from pyics.tests import Test
|
|
26
|
+
from pyics.tests.pytest_integration import LiveTestCollectionPlugin
|
|
27
|
+
from pyics.tests.results import TestResult
|
|
28
|
+
|
|
29
|
+
# pytest runs aren't thread-safe (e.g. the tests import the exercise file as the module
|
|
30
|
+
# ``current_solution``), so all tasks' runs are serialized
|
|
31
|
+
_pytest_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def wait_for_test_runs(timeout: float = -1) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Wait until no task's tests are being run (at most ``timeout`` seconds, if given).
|
|
37
|
+
A run restores the terminal mode it started in, so e.g. the terminal mode must not be
|
|
38
|
+
changed back to cooked before a run started in raw mode has finished.
|
|
39
|
+
"""
|
|
40
|
+
if _pytest_lock.acquire(timeout=timeout):
|
|
41
|
+
_pytest_lock.release()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _failure_text(result: TestResult) -> Text:
|
|
45
|
+
"""The failure message of ``result``, highlighted if it comes from a ``PyicsItem``"""
|
|
46
|
+
return result.message_text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _failure_trace(result: TestResult) -> Text | None:
|
|
50
|
+
"""The trace of the failed test of ``result``, or why there is none, if it was run
|
|
51
|
+
with tracing (see ``Task.tracing``)"""
|
|
52
|
+
return result.trace_text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _traceable(test: Test) -> bool:
|
|
56
|
+
"""Whether ``test`` can be traced, and its author allows it (see ``Test.run``)"""
|
|
57
|
+
return test.supports_trace and getattr(test, "traceable", True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _path_list(files: StrPath | Sequence[StrPath] | None) -> list[StrPath]:
|
|
61
|
+
"""``files`` as a list of paths, whether given as one path, several or None."""
|
|
62
|
+
if files is None:
|
|
63
|
+
return []
|
|
64
|
+
if isinstance(files, (str, PathLike)):
|
|
65
|
+
return [files]
|
|
66
|
+
return list(files)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_task_json(fp: StrPath, module_policy: ModulePolicy | None = None) -> dict:
|
|
70
|
+
"""
|
|
71
|
+
Read and decode the task file at ``fp``, once it has been checked that decoding it
|
|
72
|
+
imports only modules ``module_policy`` allows (if given), which are installed (see
|
|
73
|
+
``check_task_data``).
|
|
74
|
+
"""
|
|
75
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
76
|
+
text = f.read()
|
|
77
|
+
check_task_data(json.loads_plain(text), fp, module_policy)
|
|
78
|
+
return json.loads(text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _hint_list(hints: str | Sequence[str] | None) -> list[str]:
|
|
82
|
+
"""``hints`` as a list of hints, whether given as one hint, several or None."""
|
|
83
|
+
if hints is None:
|
|
84
|
+
return []
|
|
85
|
+
if isinstance(hints, str):
|
|
86
|
+
return [hints]
|
|
87
|
+
return list(hints)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_sections(raw_json: Mapping[str, Any], base: Path) -> dict[Path, str]:
|
|
91
|
+
"""The ``sections`` of a task file read from the directory ``base``, by notebook
|
|
92
|
+
path resolved relative to ``base`` (see ``create_task_json``). Entries that aren't a
|
|
93
|
+
path and the name of a section are left out."""
|
|
94
|
+
stored = raw_json.get("sections")
|
|
95
|
+
if not isinstance(stored, Mapping):
|
|
96
|
+
return {}
|
|
97
|
+
return {
|
|
98
|
+
paths.resolve(path, base): name
|
|
99
|
+
for path, name in stored.items()
|
|
100
|
+
if isinstance(path, str) and notebook_sections.is_name(name)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Task:
|
|
105
|
+
"""Unified Task Representation"""
|
|
106
|
+
|
|
107
|
+
__status: RenderableType
|
|
108
|
+
__info: RenderableType
|
|
109
|
+
__solved: bool = False
|
|
110
|
+
__new_test_results: bool = False
|
|
111
|
+
__tracing: bool = False
|
|
112
|
+
# whether the current test results were traced
|
|
113
|
+
__traced: bool = False
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
task_file: StrPath,
|
|
118
|
+
*,
|
|
119
|
+
setup: bool = True,
|
|
120
|
+
module_policy: ModulePolicy | None = None,
|
|
121
|
+
):
|
|
122
|
+
"""
|
|
123
|
+
Parses a task from a task json-file.
|
|
124
|
+
|
|
125
|
+
File paths in the task file are relative to the task file's directory (see
|
|
126
|
+
``create_task_json``).
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
task_file: StrPath
|
|
131
|
+
Path to the task file
|
|
132
|
+
setup: bool (optional, keyword only)
|
|
133
|
+
Create the task's files and run its tests (see ``setup``). Default is True.
|
|
134
|
+
If False, the task can only be checked (see ``check_solved``) until ``setup``
|
|
135
|
+
is called.
|
|
136
|
+
module_policy: ModulePolicy | None (optional, keyword only)
|
|
137
|
+
The modules the task file may name to decode the objects stored in it, i.e.
|
|
138
|
+
those its course declares (see ``ModulePolicy``). If None, any module.
|
|
139
|
+
|
|
140
|
+
Raises
|
|
141
|
+
------
|
|
142
|
+
ModuleNotAllowedError
|
|
143
|
+
If the task file names a module ``module_policy`` doesn't allow.
|
|
144
|
+
MissingDependencyError
|
|
145
|
+
If the task needs a package that isn't installed.
|
|
146
|
+
"""
|
|
147
|
+
self.__path = task_file
|
|
148
|
+
raw_json = _load_task_json(task_file, module_policy)
|
|
149
|
+
|
|
150
|
+
base = Path(task_file).parent
|
|
151
|
+
|
|
152
|
+
self.name: str = raw_json.get("name", "Task")
|
|
153
|
+
self.instructions: str = raw_json.get("instructions", "")
|
|
154
|
+
# revealed one at a time, in order (see ``Course.reveal_hint``)
|
|
155
|
+
self.help: list[str] = _hint_list(raw_json.get("help"))
|
|
156
|
+
|
|
157
|
+
self.exercise_files: list[Path] = [
|
|
158
|
+
paths.resolve(fp, base) for fp in raw_json.get("ex_files", [])
|
|
159
|
+
]
|
|
160
|
+
self.solution_files: list[Path] = [
|
|
161
|
+
paths.resolve(fp, base) for fp in raw_json.get("sol_files", [])
|
|
162
|
+
]
|
|
163
|
+
# the section of each exercise notebook the task is about, by the notebook's
|
|
164
|
+
# absolute path (see ``pyics.notebook.sections``). Its tests inherit it, unless
|
|
165
|
+
# they have a section of their own (see ``Test.resolved_section``).
|
|
166
|
+
self.sections: dict[Path, str] = {
|
|
167
|
+
paths.absolute(path): name
|
|
168
|
+
for path, name in _read_sections(raw_json, base).items()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# kept until the files are created; the code does not need to be in memory after
|
|
172
|
+
self.__code: tuple[list[str], list[str]] | None = (
|
|
173
|
+
raw_json["exercise_code"],
|
|
174
|
+
raw_json["solution_code"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self.tests: list[Test] = raw_json["tests"]
|
|
178
|
+
for test in self.tests:
|
|
179
|
+
test.file_path = paths.resolve(test.file_path, base)
|
|
180
|
+
|
|
181
|
+
self.__info = self.instructions
|
|
182
|
+
self.__status = Text("tests pending...", style="dim")
|
|
183
|
+
self.test_results: list[TestResult] = []
|
|
184
|
+
self.__tested = False
|
|
185
|
+
|
|
186
|
+
self._tests_lock = threading.Lock()
|
|
187
|
+
self._tests_pending = False
|
|
188
|
+
# when the version of the exercise files that ``test_results`` come from was
|
|
189
|
+
# saved: the latest of their modification times when the run started
|
|
190
|
+
self.test_results_time: float | None = None
|
|
191
|
+
# a plugin's overlay of the results (see ``check_overlay``), asked for once
|
|
192
|
+
self.__overlay: plugins.ResultsOverlay | None = None
|
|
193
|
+
self.__overlay_asked = False
|
|
194
|
+
# the status is updated by test runs and by the overlay, in different threads
|
|
195
|
+
self.__status_lock = threading.RLock()
|
|
196
|
+
|
|
197
|
+
self.last_mtimes: dict[Path, float] = {}
|
|
198
|
+
if setup:
|
|
199
|
+
self.setup()
|
|
200
|
+
|
|
201
|
+
def setup(self) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Create the task's files (keeping existing exercise files, see ``create_files``)
|
|
204
|
+
and run its tests, unless they have already been run by ``check_solved``. Only
|
|
205
|
+
does something the first time it is called.
|
|
206
|
+
"""
|
|
207
|
+
if self.__code is None:
|
|
208
|
+
return
|
|
209
|
+
self.create_files(*self.__code)
|
|
210
|
+
self.__code = None
|
|
211
|
+
|
|
212
|
+
# files checked by check_solved keep the time they were checked at, so changes
|
|
213
|
+
# made since are still picked up
|
|
214
|
+
self.last_mtimes = {
|
|
215
|
+
f: self.last_mtimes.get(f, os.path.getmtime(f))
|
|
216
|
+
for f in self.exercise_files
|
|
217
|
+
if os.path.exists(f)
|
|
218
|
+
}
|
|
219
|
+
if not self.__tested:
|
|
220
|
+
self.run_tests()
|
|
221
|
+
|
|
222
|
+
def check_solved(self) -> bool:
|
|
223
|
+
"""
|
|
224
|
+
Run the tests synchronously and return whether the task is solved, without
|
|
225
|
+
creating any files. A task whose exercise files don't all exist is unsolved, and
|
|
226
|
+
its tests aren't run.
|
|
227
|
+
"""
|
|
228
|
+
if not all(os.path.exists(f) for f in self.exercise_files):
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
mtimes = {f: os.path.getmtime(f) for f in self.exercise_files}
|
|
232
|
+
with self._tests_lock:
|
|
233
|
+
self._tests_pending = True
|
|
234
|
+
self.__run_tests_locked()
|
|
235
|
+
# changes made from here on are picked up by check_file_changes
|
|
236
|
+
self.last_mtimes = mtimes
|
|
237
|
+
return self.solved
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def new_test_results(self) -> bool:
|
|
241
|
+
return self.__new_test_results
|
|
242
|
+
|
|
243
|
+
def clear_test_results(self) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Set new_test_results to False, indicating that the new results have been
|
|
246
|
+
processed.
|
|
247
|
+
"""
|
|
248
|
+
self.__new_test_results = False
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def path(self) -> StrPath:
|
|
252
|
+
return self.__path
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def info(self) -> RenderableType:
|
|
256
|
+
"""The task info, i.e. what is supposed to be displayed in the pyics header"""
|
|
257
|
+
return self.__info
|
|
258
|
+
|
|
259
|
+
@info.setter
|
|
260
|
+
def info(self, v: str) -> None:
|
|
261
|
+
"""The task info, i.e. what is supposed to be displayed in the pyics header"""
|
|
262
|
+
self.__info = v
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def status(self) -> RenderableType:
|
|
266
|
+
"""The task status, i.e. what is supposed to be displayed in the pyics main panel"""
|
|
267
|
+
return self.__status
|
|
268
|
+
|
|
269
|
+
def update_status(self) -> None:
|
|
270
|
+
"""Update the status from the test reports, as the results overlay shows them
|
|
271
|
+
if there is one (see ``check_overlay``)"""
|
|
272
|
+
with self.__status_lock:
|
|
273
|
+
self.__update_status()
|
|
274
|
+
|
|
275
|
+
def __update_status(self) -> None:
|
|
276
|
+
# progress only counts the results of the saved files, never the overlay's
|
|
277
|
+
saved = [r.outcome == "passed" for r in self.test_results if r.when == "call"]
|
|
278
|
+
self.__solved = bool(saved) and round(100 * sum(saved) / len(saved)) == 100
|
|
279
|
+
|
|
280
|
+
results, lines = self.test_results, []
|
|
281
|
+
if self.__overlay is not None:
|
|
282
|
+
try:
|
|
283
|
+
overlay = self.__overlay.apply(self.test_results, self.test_results_time)
|
|
284
|
+
results, lines = overlay.results, overlay.lines
|
|
285
|
+
except Exception: # noqa: BLE001
|
|
286
|
+
get_logger(__name__).warning("The results overlay failed", exc_info=True)
|
|
287
|
+
|
|
288
|
+
# filter tests for only call tests
|
|
289
|
+
relevant_reports = [r for r in results if r.when == "call"]
|
|
290
|
+
passed = [r.outcome == "passed" for r in relevant_reports]
|
|
291
|
+
failed_reports = [r for r in relevant_reports if r.outcome == "failed"]
|
|
292
|
+
pass_rate = round(100 * sum(passed) / len(passed)) if passed else 0
|
|
293
|
+
|
|
294
|
+
# result summary, box=None so the row reaches the panel's own border
|
|
295
|
+
summary = Table(expand=True, show_header=False, box=None, padding=0)
|
|
296
|
+
summary.add_column()
|
|
297
|
+
summary.add_column(justify="right")
|
|
298
|
+
summary.add_row(
|
|
299
|
+
"".join("[bright_green].[/]" if ok else "[bright_red]F[/]" for ok in passed),
|
|
300
|
+
f"[{'bright_green' if pass_rate == 100 else 'bright_red'}]{pass_rate}%[/]",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
sections: list[RenderableType] = [summary, *lines]
|
|
304
|
+
if self.__traced:
|
|
305
|
+
sections.append(
|
|
306
|
+
Text("Tracing failed tests with snoop, press t to stop.", style="dim")
|
|
307
|
+
)
|
|
308
|
+
elif failed_reports and any(_traceable(t) for t in self.tests):
|
|
309
|
+
sections.append(
|
|
310
|
+
Text("Press t to trace failed tests with snoop.", style="dim")
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if failed_reports:
|
|
314
|
+
failures = [(r.nodeid, _failure_text(r)) for r in failed_reports]
|
|
315
|
+
traces = [_failure_trace(r) for r in failed_reports]
|
|
316
|
+
|
|
317
|
+
# one rule + traceback per failure, mirroring pytest's own failure sections
|
|
318
|
+
sections.append(
|
|
319
|
+
Padding(Rule("[white]FAILURES[/]", style="white"), pad=(1, 0, 0, 0))
|
|
320
|
+
)
|
|
321
|
+
for (nodeid, text), trace in zip(failures, traces):
|
|
322
|
+
failure: list[RenderableType] = [
|
|
323
|
+
Padding(
|
|
324
|
+
Rule(Text(nodeid, style="bold bright_red"), style="bright_red"),
|
|
325
|
+
pad=(1, 0, 0, 0),
|
|
326
|
+
),
|
|
327
|
+
Text.assemble(text, style="red"),
|
|
328
|
+
]
|
|
329
|
+
if trace is not None:
|
|
330
|
+
failure += [
|
|
331
|
+
Padding(
|
|
332
|
+
Rule(Text("trace", style="dim"), style="dim"),
|
|
333
|
+
pad=(1, 0, 0, 0),
|
|
334
|
+
),
|
|
335
|
+
trace,
|
|
336
|
+
]
|
|
337
|
+
sections.append(Group(*failure))
|
|
338
|
+
|
|
339
|
+
# pytest's "short test summary info", one line per failed test
|
|
340
|
+
sections.append(
|
|
341
|
+
Padding(
|
|
342
|
+
Rule("[bright_cyan]short test summary info[/]", style="bright_cyan"),
|
|
343
|
+
pad=(1, 0, 0, 0),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
sections += [
|
|
347
|
+
Text.assemble(
|
|
348
|
+
f"FAILED {nodeid} - ",
|
|
349
|
+
Text(" ").join(text.split()),
|
|
350
|
+
style="bright_red",
|
|
351
|
+
)
|
|
352
|
+
for nodeid, text in failures
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
if relevant_reports:
|
|
356
|
+
n_failed, n_passed = len(failed_reports), sum(passed)
|
|
357
|
+
duration = sum(r.duration for r in relevant_reports)
|
|
358
|
+
|
|
359
|
+
counts = Text()
|
|
360
|
+
if n_failed:
|
|
361
|
+
counts.append(f"{n_failed} failed", style="bold bright_red")
|
|
362
|
+
if n_failed and n_passed:
|
|
363
|
+
counts.append(", ", style="white")
|
|
364
|
+
if n_passed:
|
|
365
|
+
counts.append(f"{n_passed} passed", style="bright_green")
|
|
366
|
+
counts.append(
|
|
367
|
+
f" in {duration:.2f}s", style="bright_red" if n_failed else "bright_green"
|
|
368
|
+
)
|
|
369
|
+
sections.append(Padding(counts, pad=(1, 0, 0, 0)))
|
|
370
|
+
|
|
371
|
+
self.__status = Group(*sections)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def solved(self) -> bool:
|
|
375
|
+
return self.__solved
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def tracing(self) -> bool:
|
|
379
|
+
"""
|
|
380
|
+
Whether the tests are traced with ``snoop`` when they are run, to show the trace
|
|
381
|
+
of each failed test below its failure (see ``Test.run``). Off for a new task.
|
|
382
|
+
"""
|
|
383
|
+
return self.__tracing
|
|
384
|
+
|
|
385
|
+
def toggle_tracing(self) -> None:
|
|
386
|
+
"""Turn ``tracing`` on or off, and run the tests again to show the change."""
|
|
387
|
+
self.__tracing = not self.__tracing
|
|
388
|
+
self.run_tests()
|
|
389
|
+
|
|
390
|
+
def check_overlay(self) -> bool:
|
|
391
|
+
"""
|
|
392
|
+
Ask the plugins for an overlay of the task's results the first time (see
|
|
393
|
+
``plugins.ResultsOverlay``), and from then on, whether it has new results, to
|
|
394
|
+
update the status if it has. Cheap enough to call at every tick of the TUI;
|
|
395
|
+
without plugins, it does nothing after the first call.
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
bool
|
|
400
|
+
True if the overlay has new results
|
|
401
|
+
"""
|
|
402
|
+
changed = False
|
|
403
|
+
if not self.__overlay_asked:
|
|
404
|
+
self.__overlay_asked = True
|
|
405
|
+
try:
|
|
406
|
+
overlay = plugins.manager().hook.pyics_results_overlay(task=self)
|
|
407
|
+
except Exception: # noqa: BLE001
|
|
408
|
+
get_logger(__name__).warning("A plugin failed", exc_info=True)
|
|
409
|
+
overlay = None
|
|
410
|
+
self.__overlay = overlay
|
|
411
|
+
# the status may have been drawn without it
|
|
412
|
+
changed = overlay is not None
|
|
413
|
+
if self.__overlay is None:
|
|
414
|
+
return False
|
|
415
|
+
try:
|
|
416
|
+
changed = self.__overlay.poll() or changed
|
|
417
|
+
except Exception: # noqa: BLE001
|
|
418
|
+
# asked again at the next tick
|
|
419
|
+
get_logger(__name__).debug("The results overlay failed", exc_info=True)
|
|
420
|
+
if changed:
|
|
421
|
+
with self.__status_lock:
|
|
422
|
+
# until the files have been tested, the status says the tests are
|
|
423
|
+
# pending
|
|
424
|
+
if self.__tested:
|
|
425
|
+
self.update_status()
|
|
426
|
+
self.__new_test_results = True
|
|
427
|
+
return changed
|
|
428
|
+
|
|
429
|
+
def check_file_changes(self) -> bool:
|
|
430
|
+
"""
|
|
431
|
+
Check whether an exercise file has been changed by monitoring the last modified
|
|
432
|
+
timestamp.
|
|
433
|
+
|
|
434
|
+
Returns
|
|
435
|
+
-------
|
|
436
|
+
bool
|
|
437
|
+
True if any exercise file has been changed
|
|
438
|
+
"""
|
|
439
|
+
changed = map(self._check_file_changed, self.exercise_files)
|
|
440
|
+
return any(changed)
|
|
441
|
+
|
|
442
|
+
def _check_file_changed(self, filepath: Path) -> bool:
|
|
443
|
+
"""
|
|
444
|
+
Check whether a single exercise file has been changed by monitoring the last
|
|
445
|
+
modified timestamp.
|
|
446
|
+
|
|
447
|
+
Returns
|
|
448
|
+
-------
|
|
449
|
+
bool
|
|
450
|
+
True if the file has been changed, False otherwise
|
|
451
|
+
"""
|
|
452
|
+
if not os.path.exists(filepath):
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
current_mtime = os.path.getmtime(filepath)
|
|
456
|
+
if current_mtime > self.last_mtimes.get(filepath, 0):
|
|
457
|
+
self.last_mtimes[filepath] = current_mtime
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
def run_tests(self) -> None:
|
|
463
|
+
"""
|
|
464
|
+
Runs all tests in self.tests using pytest and stores the unfiltered reports in
|
|
465
|
+
self.test_results.
|
|
466
|
+
|
|
467
|
+
Starts self.__run_tests as a daemon thread.
|
|
468
|
+
"""
|
|
469
|
+
self._tests_pending = True
|
|
470
|
+
if not self._tests_lock.acquire(blocking=False):
|
|
471
|
+
return
|
|
472
|
+
threading.Thread(target=self.__run_tests, daemon=True).start()
|
|
473
|
+
|
|
474
|
+
def __run_tests(self) -> None:
|
|
475
|
+
"""
|
|
476
|
+
DO NOT CALL THIS METHOD DIRECTLY. CALL run_tests INSTEAD.
|
|
477
|
+
|
|
478
|
+
Runs all tests in self.tests synchronously using pytest and stores the unfiltered
|
|
479
|
+
reports in self.test_results.
|
|
480
|
+
"""
|
|
481
|
+
try:
|
|
482
|
+
self.__run_tests_locked()
|
|
483
|
+
finally:
|
|
484
|
+
self._tests_lock.release()
|
|
485
|
+
|
|
486
|
+
def __run_tests_locked(self) -> None:
|
|
487
|
+
"""Run the tests while they are pending. ``self._tests_lock`` must be held."""
|
|
488
|
+
while self._tests_pending:
|
|
489
|
+
self._tests_pending = False
|
|
490
|
+
tracing = self.__tracing
|
|
491
|
+
plugin = LiveTestCollectionPlugin(
|
|
492
|
+
self.tests, tracing=tracing, sections=self.sections
|
|
493
|
+
)
|
|
494
|
+
saved_at = self.__saved_at()
|
|
495
|
+
|
|
496
|
+
with _pytest_lock, terminal.preserve_terminal_mode():
|
|
497
|
+
pytest.main(["-p", "no:terminal", "--capture=no"], plugins=[plugin])
|
|
498
|
+
|
|
499
|
+
# log reports
|
|
500
|
+
logger = get_logger(__name__)
|
|
501
|
+
for result in plugin.results:
|
|
502
|
+
logger.debug(f"{result.nodeid} {result.when} {result.outcome}")
|
|
503
|
+
logger.debug(_failure_text(result).plain)
|
|
504
|
+
|
|
505
|
+
with self.__status_lock:
|
|
506
|
+
self.test_results = plugin.results
|
|
507
|
+
self.test_results_time = saved_at
|
|
508
|
+
self.__traced = tracing
|
|
509
|
+
self.__tested = True
|
|
510
|
+
self.update_status()
|
|
511
|
+
self.__new_test_results = True
|
|
512
|
+
|
|
513
|
+
def __saved_at(self) -> float:
|
|
514
|
+
"""When the exercise files were last saved: the latest of their modification
|
|
515
|
+
times, or now if none can be told (see ``test_results_time``)."""
|
|
516
|
+
times = []
|
|
517
|
+
for f in self.exercise_files:
|
|
518
|
+
try:
|
|
519
|
+
times.append(os.path.getmtime(f))
|
|
520
|
+
except OSError:
|
|
521
|
+
pass
|
|
522
|
+
return max(times, default=time.time())
|
|
523
|
+
|
|
524
|
+
def create_files(
|
|
525
|
+
self,
|
|
526
|
+
exercise_code: list[str],
|
|
527
|
+
solution_code: list[str],
|
|
528
|
+
*,
|
|
529
|
+
overwrite: bool = False,
|
|
530
|
+
) -> None:
|
|
531
|
+
"""
|
|
532
|
+
Create the exercise and solution files. Assumes that filepaths in
|
|
533
|
+
self.exercise_files and self.solution_files are in the same order as the code
|
|
534
|
+
lists, respectively.
|
|
535
|
+
|
|
536
|
+
Existing exercise files are kept (they hold the student's work) unless
|
|
537
|
+
``overwrite`` is set. Solution files are always rewritten. Missing parent
|
|
538
|
+
directories are created.
|
|
539
|
+
|
|
540
|
+
Parameters
|
|
541
|
+
----------
|
|
542
|
+
exercise_code: list[str]
|
|
543
|
+
List of file-contents for the exercise files
|
|
544
|
+
solution_code: list[str]
|
|
545
|
+
List of file-contents for the solution files
|
|
546
|
+
overwrite: bool (optional, keyword only)
|
|
547
|
+
Reset existing exercise files to the exercise code. Default is False.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
for fp, code in zip(self.exercise_files, exercise_code):
|
|
551
|
+
if not overwrite and fp.exists():
|
|
552
|
+
continue
|
|
553
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
fp.write_text(code, encoding="utf-8")
|
|
555
|
+
|
|
556
|
+
for fp, code in zip(self.solution_files, solution_code):
|
|
557
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
fp.write_text(code, encoding="utf-8")
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def read_task_json(fp: StrPath) -> dict[str, Any]:
|
|
562
|
+
"""
|
|
563
|
+
Read a task file without loading the task, i.e. without creating its files or
|
|
564
|
+
running its tests. Inverse of ``create_task_json``: returns its keyword
|
|
565
|
+
arguments, so ``Task.create_task_json(fp, **Task.read_task_json(fp))`` rewrites
|
|
566
|
+
the task as it is.
|
|
567
|
+
|
|
568
|
+
File paths are resolved relative to the task file's directory, like in
|
|
569
|
+
``__init__``.
|
|
570
|
+
|
|
571
|
+
Parameters
|
|
572
|
+
----------
|
|
573
|
+
fp: StrPath
|
|
574
|
+
Path to the task file
|
|
575
|
+
|
|
576
|
+
Returns
|
|
577
|
+
-------
|
|
578
|
+
dict[str, Any]
|
|
579
|
+
``name``, ``instructions``, ``ex_files``, ``sol_files``, ``tests``,
|
|
580
|
+
``help`` and ``sections`` (by notebook path, resolved like ``ex_files``),
|
|
581
|
+
and the contents stored for the exercise and solution files as
|
|
582
|
+
``file_contents``, by path.
|
|
583
|
+
"""
|
|
584
|
+
raw_json = _load_task_json(fp)
|
|
585
|
+
|
|
586
|
+
base = Path(fp).parent
|
|
587
|
+
|
|
588
|
+
ex_files = [paths.resolve(f, base) for f in raw_json.get("ex_files", [])]
|
|
589
|
+
sol_files = [paths.resolve(f, base) for f in raw_json.get("sol_files", [])]
|
|
590
|
+
|
|
591
|
+
file_contents = dict(zip(ex_files, raw_json.get("exercise_code", [])))
|
|
592
|
+
file_contents.update(zip(sol_files, raw_json.get("solution_code", [])))
|
|
593
|
+
|
|
594
|
+
tests: list[Test] = raw_json.get("tests") or []
|
|
595
|
+
for test in tests:
|
|
596
|
+
test.file_path = paths.resolve(test.file_path, base)
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
"name": raw_json.get("name", "Task"),
|
|
600
|
+
"instructions": raw_json.get("instructions", ""),
|
|
601
|
+
"ex_files": ex_files,
|
|
602
|
+
"sol_files": sol_files,
|
|
603
|
+
"tests": tests,
|
|
604
|
+
"file_contents": file_contents,
|
|
605
|
+
"help": _hint_list(raw_json.get("help")),
|
|
606
|
+
"sections": _read_sections(raw_json, base),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
@staticmethod
|
|
610
|
+
def create_task_json(
|
|
611
|
+
fp: StrPath,
|
|
612
|
+
name: str,
|
|
613
|
+
instructions: str,
|
|
614
|
+
ex_files: StrPath | Sequence[StrPath] | None = None,
|
|
615
|
+
sol_files: StrPath | Sequence[StrPath] | None = None,
|
|
616
|
+
tests: Union[Test, list[Test], None] = None,
|
|
617
|
+
file_contents: Mapping[StrPath, str] | None = None,
|
|
618
|
+
help: str | Sequence[str] | None = None,
|
|
619
|
+
sections: Mapping[StrPath, str] | None = None,
|
|
620
|
+
) -> Path:
|
|
621
|
+
"""
|
|
622
|
+
Create a task_json from all relevant information as it would naturally be available
|
|
623
|
+
when designing a task.
|
|
624
|
+
|
|
625
|
+
File paths (``ex_files``, ``sol_files`` and the tests' ``file_path``) are given
|
|
626
|
+
relative to the working directory (or absolute), and stored relative to the
|
|
627
|
+
directory of the task file, so the task keeps working when it is moved together
|
|
628
|
+
with its files.
|
|
629
|
+
|
|
630
|
+
Parameters
|
|
631
|
+
----------
|
|
632
|
+
fp: StrPath
|
|
633
|
+
Desired output path
|
|
634
|
+
name: str
|
|
635
|
+
Name of the task
|
|
636
|
+
instructions: str
|
|
637
|
+
Instructions for the task
|
|
638
|
+
ex_files: StrPath | Sequence[StrPath] | None
|
|
639
|
+
Filename or list of filenames of exercise files.
|
|
640
|
+
sol_files: StrPath | Sequence[StrPath] | None
|
|
641
|
+
Filename or list of filenames of solution files.
|
|
642
|
+
tests: Union[Test, list[Test]]
|
|
643
|
+
Filename or list of filenames containing serialized tests
|
|
644
|
+
file_contents: Mapping[StrPath, str] | None
|
|
645
|
+
Contents to store for some of the exercise and solution files, by path,
|
|
646
|
+
instead of reading these files, e.g. the contents already stored in the task
|
|
647
|
+
file when editing it (see ``read_task_json``). Other files are read. The
|
|
648
|
+
contents of notebooks are stored normalized (see ``cells.normalize``), e.g.
|
|
649
|
+
without their outputs.
|
|
650
|
+
help: str | Sequence[str] | None
|
|
651
|
+
Hint or list of hints for the task, which the student can reveal one at a
|
|
652
|
+
time, in order.
|
|
653
|
+
sections: Mapping[StrPath, str] | None
|
|
654
|
+
The section the task is about (see ``pyics.notebook.sections``) of some of
|
|
655
|
+
its exercise notebooks, by path. Its tests inherit it, unless they have a
|
|
656
|
+
section of their own. Stored as ``sections``, by the notebook's path
|
|
657
|
+
relative to the task file (with forward slashes), and left out if empty.
|
|
658
|
+
Older versions of pyics ignore it.
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
Path
|
|
663
|
+
The path the task file was written to, with its suffix normalized to
|
|
664
|
+
'.pyicstask.json'.
|
|
665
|
+
|
|
666
|
+
Raises
|
|
667
|
+
------
|
|
668
|
+
ValueError
|
|
669
|
+
If a path of ``sections`` isn't one of the exercise notebooks, or a name in
|
|
670
|
+
it can't be the name of a section.
|
|
671
|
+
"""
|
|
672
|
+
fp = Path(fp)
|
|
673
|
+
base = fp.parent
|
|
674
|
+
task: dict = {}
|
|
675
|
+
|
|
676
|
+
task["name"] = name
|
|
677
|
+
task["instructions"] = instructions
|
|
678
|
+
task["help"] = _hint_list(help)
|
|
679
|
+
|
|
680
|
+
ex_files = _path_list(ex_files)
|
|
681
|
+
sol_files = _path_list(sol_files)
|
|
682
|
+
task["ex_files"] = [paths.relative_to(f, base) for f in ex_files]
|
|
683
|
+
task["sol_files"] = [paths.relative_to(f, base) for f in sol_files]
|
|
684
|
+
|
|
685
|
+
notebooks = {paths.absolute(f) for f in ex_files if cells.is_notebook(f)}
|
|
686
|
+
stored_sections = {}
|
|
687
|
+
for path, section in (sections or {}).items():
|
|
688
|
+
if paths.absolute(path) not in notebooks:
|
|
689
|
+
raise ValueError(
|
|
690
|
+
f"{path} has a section, but isn't one of the exercise notebooks"
|
|
691
|
+
)
|
|
692
|
+
key = paths.relative_to(path, base).as_posix()
|
|
693
|
+
stored_sections[key] = notebook_sections.check_name(section)
|
|
694
|
+
if stored_sections:
|
|
695
|
+
task["sections"] = stored_sections
|
|
696
|
+
|
|
697
|
+
contents = {paths.absolute(f): c for f, c in (file_contents or {}).items()}
|
|
698
|
+
|
|
699
|
+
def read(f: StrPath) -> str:
|
|
700
|
+
if (key := paths.absolute(f)) in contents:
|
|
701
|
+
text = contents[key]
|
|
702
|
+
else:
|
|
703
|
+
text = Path(f).read_text(encoding="utf-8")
|
|
704
|
+
if cells.is_notebook(f):
|
|
705
|
+
# without outputs, which would leak the solution's, and bloat the file
|
|
706
|
+
return cells.normalize(text, name=Path(f).name)
|
|
707
|
+
return text
|
|
708
|
+
|
|
709
|
+
task["exercise_code"] = [read(f) for f in ex_files]
|
|
710
|
+
task["solution_code"] = [read(f) for f in sol_files]
|
|
711
|
+
|
|
712
|
+
tests = tests if isinstance(tests, list) else [tests] if tests is not None else []
|
|
713
|
+
task["tests"] = []
|
|
714
|
+
for test in tests:
|
|
715
|
+
# copied, so the caller's test keeps its path
|
|
716
|
+
test = copy.copy(test)
|
|
717
|
+
test.file_path = paths.relative_to(test.file_path, base)
|
|
718
|
+
task["tests"].append(test)
|
|
719
|
+
|
|
720
|
+
os.makedirs(base, exist_ok=True)
|
|
721
|
+
|
|
722
|
+
match fp.suffixes:
|
|
723
|
+
case []:
|
|
724
|
+
fp = fp.with_suffix(".pyicstask.json")
|
|
725
|
+
case [_]:
|
|
726
|
+
get_logger(__name__).warning(
|
|
727
|
+
f"Replacing suffix '{fp.suffix}' with '.pyicstask.json'."
|
|
728
|
+
)
|
|
729
|
+
fp = fp.with_suffix(".pyicstask.json")
|
|
730
|
+
case [".pyicstask", ".json"]:
|
|
731
|
+
pass
|
|
732
|
+
case _:
|
|
733
|
+
get_logger(__name__).warning(
|
|
734
|
+
f"File suffixes don't match expected file path. Adding '.pyicstask.json'."
|
|
735
|
+
)
|
|
736
|
+
fp = fp.with_suffix(fp.suffix + ".pyicstask.json")
|
|
737
|
+
|
|
738
|
+
# serialized before the file is opened, so a task file that is being edited
|
|
739
|
+
# isn't left empty if serializing fails
|
|
740
|
+
text = json.dumps(task, indent=4)
|
|
741
|
+
with open(fp, "w", encoding="utf-8") as f:
|
|
742
|
+
f.write(text)
|
|
743
|
+
|
|
744
|
+
return fp
|