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/app/pyics.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from pyics.app.actions import (
|
|
16
|
+
Courses,
|
|
17
|
+
Hint,
|
|
18
|
+
KeyAction,
|
|
19
|
+
Next,
|
|
20
|
+
Previous,
|
|
21
|
+
Quit,
|
|
22
|
+
ScrollDown,
|
|
23
|
+
ScrollUp,
|
|
24
|
+
ToggleTrace,
|
|
25
|
+
)
|
|
26
|
+
from pyics.app.cli import CLI
|
|
27
|
+
from pyics.app.key_listener import hey_listen
|
|
28
|
+
from pyics.app.prompts import PromptBack, PromptCancelled, SelectInput
|
|
29
|
+
from pyics.data import Course
|
|
30
|
+
from pyics.data.task import wait_for_test_runs
|
|
31
|
+
from pyics.distribution import dependencies, sources
|
|
32
|
+
from pyics.distribution.archive import ORIGIN_FILE, ArchiveError
|
|
33
|
+
from pyics.distribution.dependencies import Problem
|
|
34
|
+
from pyics.distribution.pull import PullError, pull
|
|
35
|
+
from pyics.distribution.sources import CourseEntry, Source
|
|
36
|
+
from pyics.notebook import cells, sections
|
|
37
|
+
from pyics.util import paths
|
|
38
|
+
from pyics.util.discovery import COURSE_SUFFIX, find_courses
|
|
39
|
+
|
|
40
|
+
_SELECT_INFO = "Select a course..."
|
|
41
|
+
# how long to wait on shutdown for test runs to finish, which restore the terminal mode
|
|
42
|
+
# they started in
|
|
43
|
+
_SHUTDOWN_TIMEOUT = 5 # seconds
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _LocalCourse:
|
|
48
|
+
"""A course in the working directory, as offered in the course selector."""
|
|
49
|
+
|
|
50
|
+
path: Path
|
|
51
|
+
# its requirements that aren't met, which keep it from being started
|
|
52
|
+
problems: list[Problem] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _SourceCourse:
|
|
57
|
+
"""A course in one of the sources, offered in the course selector to be pulled."""
|
|
58
|
+
|
|
59
|
+
source: Source
|
|
60
|
+
entry: CourseEntry
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _exercise_title(path: Path, section: str | None) -> Text:
|
|
64
|
+
"""
|
|
65
|
+
How the main panel's title shows the exercise file at ``path``: its path, and for a
|
|
66
|
+
notebook whose task is about its ``section``, the section's title, e.g.
|
|
67
|
+
``lecture.ipynb › Exercise 2: Breadth-First Search``. The section is looked up in
|
|
68
|
+
the saved notebook, so the title follows it when its cells are moved.
|
|
69
|
+
"""
|
|
70
|
+
title = Text(path.as_posix())
|
|
71
|
+
if section is None or not cells.is_notebook(path):
|
|
72
|
+
return title
|
|
73
|
+
try:
|
|
74
|
+
found = sections.get_section(cells.read(path), section)
|
|
75
|
+
except sections.SectionNotFound:
|
|
76
|
+
return title.append(" › ").append(f"section '{section}' not found", style="red")
|
|
77
|
+
except OSError, cells.NotebookError:
|
|
78
|
+
# e.g. while it is being saved; its tests tell what is wrong
|
|
79
|
+
return title
|
|
80
|
+
return title.append(f" › {found.title}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cant_start(name: str, problems: list[Problem]) -> str:
|
|
84
|
+
"""Why the course ``name`` can't be started, as markup for the info bar."""
|
|
85
|
+
return (
|
|
86
|
+
f"[red]Can't start {escape(name)}:[/red] it needs "
|
|
87
|
+
f"{escape(dependencies.describe(problems))}. "
|
|
88
|
+
f"{escape(dependencies.install_hint(problems))}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PyIC:
|
|
93
|
+
"""PyIC main thread"""
|
|
94
|
+
|
|
95
|
+
__current_course: Course | None
|
|
96
|
+
|
|
97
|
+
def __init__(self, cli: CLI, *, max_depth: int = 2):
|
|
98
|
+
"""
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
cli: CLI
|
|
102
|
+
The CLI to draw to.
|
|
103
|
+
max_depth: int (optional, keyword only)
|
|
104
|
+
Set the maximum depth to walk the working directory for courses. For example,
|
|
105
|
+
with the default value (2), ``./subdir/subsubdir/c.pyicscourse.json`` is
|
|
106
|
+
found, but not ``./subdir/subsubdir/subsubsubdir/c.pyicscourse.json``.
|
|
107
|
+
"""
|
|
108
|
+
self.cwd = Path.cwd()
|
|
109
|
+
self.cli = cli
|
|
110
|
+
self.max_depth = max_depth
|
|
111
|
+
self.running = False
|
|
112
|
+
self.key_actions: dict[str, KeyAction] = {}
|
|
113
|
+
self.__current_course: Course | None = None
|
|
114
|
+
# the course that was left for the course selector, to go back to
|
|
115
|
+
self.__previous_course: Course | None = None
|
|
116
|
+
# the courses loaded in this session, by manifest path, so that a course that is
|
|
117
|
+
# left and selected again resumes where it was left
|
|
118
|
+
self.courses: dict[Path, Course] = {}
|
|
119
|
+
# set once the current course is entered, to draw it as a whole
|
|
120
|
+
self.__entered = False
|
|
121
|
+
self.__shown_check_status = ""
|
|
122
|
+
# set once a hint is revealed, to draw it
|
|
123
|
+
self.__hint_revealed = False
|
|
124
|
+
# shown in the header the next time the course selector is
|
|
125
|
+
self.__select_info = _SELECT_INFO
|
|
126
|
+
# printed once the interface has been left (see ``scripts.main``)
|
|
127
|
+
self.exit_message: str | None = None
|
|
128
|
+
|
|
129
|
+
self._register_actions()
|
|
130
|
+
self.cli.update_hotkeys({a.ref: a.name for a in self.key_actions.values()})
|
|
131
|
+
|
|
132
|
+
def _find_courses(self) -> dict[str, _LocalCourse]:
|
|
133
|
+
"""
|
|
134
|
+
Find the courses in the working directory (see ``max_depth``), skipping
|
|
135
|
+
manifests that can't be read or have no tasks.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
dict[str, _LocalCourse]
|
|
140
|
+
The courses, ordered by path, by their labels in the course selector: their
|
|
141
|
+
course names, followed by their paths if names are taken several times.
|
|
142
|
+
"""
|
|
143
|
+
entries: list[tuple[str, _LocalCourse]] = []
|
|
144
|
+
for path, manifest in sorted(find_courses(self.cwd, self.max_depth).items()):
|
|
145
|
+
if not manifest["tasks"]:
|
|
146
|
+
continue
|
|
147
|
+
name = manifest.get("name")
|
|
148
|
+
if not isinstance(name, str) or not name:
|
|
149
|
+
name = path.name.removesuffix(COURSE_SUFFIX)
|
|
150
|
+
problems = dependencies.check_manifest(manifest)
|
|
151
|
+
entries.append((name, _LocalCourse(path, problems)))
|
|
152
|
+
|
|
153
|
+
counts = Counter(name for name, _ in entries)
|
|
154
|
+
return {
|
|
155
|
+
f"{name} ({self._display_path(c.path)})" if counts[name] > 1 else name: c
|
|
156
|
+
for name, c in entries
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def _pulled_courses(self, local: Iterable[_LocalCourse]) -> set[tuple[str, str]]:
|
|
160
|
+
"""The sources and slugs of the ``local`` courses that were pulled from a
|
|
161
|
+
source (see ``pyics.distribution.pull``)."""
|
|
162
|
+
pulled: set[tuple[str, str]] = set()
|
|
163
|
+
for course in local:
|
|
164
|
+
for directory in course.path.parents:
|
|
165
|
+
if not directory.is_relative_to(self.cwd):
|
|
166
|
+
break
|
|
167
|
+
try:
|
|
168
|
+
origin = json.loads(
|
|
169
|
+
(directory / ORIGIN_FILE).read_text(encoding="utf-8")
|
|
170
|
+
)
|
|
171
|
+
except (OSError, ValueError):
|
|
172
|
+
continue
|
|
173
|
+
if isinstance(origin, dict):
|
|
174
|
+
pulled.add((str(origin.get("source")), str(origin.get("slug"))))
|
|
175
|
+
break
|
|
176
|
+
return pulled
|
|
177
|
+
|
|
178
|
+
def _find_source_courses(
|
|
179
|
+
self, local: dict[str, _LocalCourse]
|
|
180
|
+
) -> dict[str, _SourceCourse]:
|
|
181
|
+
"""
|
|
182
|
+
The courses in the sources that haven't been pulled to the working directory,
|
|
183
|
+
as known from their indexes as they were fetched last, so that showing them
|
|
184
|
+
needs no connection.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
dict[str, _SourceCourse]
|
|
189
|
+
The courses by their labels in the course selector: their titles, followed
|
|
190
|
+
by their sources.
|
|
191
|
+
"""
|
|
192
|
+
pulled = self._pulled_courses(local.values())
|
|
193
|
+
found: dict[str, _SourceCourse] = {}
|
|
194
|
+
for source, entry in sources.cached_courses():
|
|
195
|
+
if (source.name, entry.slug) in pulled or (self.cwd / entry.slug).exists():
|
|
196
|
+
continue
|
|
197
|
+
label = f"{entry.title} (pull from {source.name})"
|
|
198
|
+
if label in local or label in found:
|
|
199
|
+
label = f"{entry.title} (pull {source.name}/{entry.slug})"
|
|
200
|
+
found[label] = _SourceCourse(source, entry)
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
def _source_course_hint(self, course: _SourceCourse) -> str:
|
|
204
|
+
entry = course.entry
|
|
205
|
+
lines = [entry.description] if entry.description else []
|
|
206
|
+
version = f" {entry.version}" if entry.version else ""
|
|
207
|
+
lines.append(
|
|
208
|
+
f"Version{version} in {course.source.name}, pulled to ./{entry.slug} once "
|
|
209
|
+
"selected."
|
|
210
|
+
)
|
|
211
|
+
if problems := dependencies.check(entry.requires, entry.requires_pyics):
|
|
212
|
+
lines.append(f"Needs {dependencies.describe(problems)}.")
|
|
213
|
+
return "\n".join(lines)
|
|
214
|
+
|
|
215
|
+
def _pull_course(self, course: _SourceCourse) -> Path | None:
|
|
216
|
+
"""
|
|
217
|
+
Pull ``course`` to the working directory, showing its progress in the info
|
|
218
|
+
bar. Returns its manifest if it can be started, or None if it can't, with the
|
|
219
|
+
reason why shown in the info bar.
|
|
220
|
+
"""
|
|
221
|
+
entry = course.entry
|
|
222
|
+
self.cli.update_info(f"Pulling {escape(entry.title)} from {course.source.name}...")
|
|
223
|
+
try:
|
|
224
|
+
pulled = pull(f"{course.source.name}/{entry.slug}", cwd=self.cwd)
|
|
225
|
+
except (PullError, ArchiveError, OSError) as err:
|
|
226
|
+
self.cli.update_info(
|
|
227
|
+
f"[red]Can't pull {escape(entry.title)}:[/red] {escape(str(err))}"
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
if pulled.problems:
|
|
231
|
+
self.cli.update_info(_cant_start(pulled.info.title, pulled.problems))
|
|
232
|
+
return None
|
|
233
|
+
return paths.absolute(pulled.manifest_path)
|
|
234
|
+
|
|
235
|
+
def _display_path(self, path: Path) -> str:
|
|
236
|
+
return paths.relative_to(path, self.cwd).as_posix()
|
|
237
|
+
|
|
238
|
+
def _select_course(self, q: queue.Queue[str]) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Let the user select a course in the working directory and enter it. Cancelling
|
|
241
|
+
(ctrl-c) quits, and going back (esc) returns to the course that was left for the
|
|
242
|
+
selector, if any. Quits with an ``exit_message`` if there is no course.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
q: queue.Queue[str]
|
|
247
|
+
The queue which collects key presses
|
|
248
|
+
"""
|
|
249
|
+
courses = self._find_courses()
|
|
250
|
+
source_courses = self._find_source_courses(courses)
|
|
251
|
+
if not courses and not source_courses:
|
|
252
|
+
self.exit_message = (
|
|
253
|
+
f"No course found in {self.cwd} "
|
|
254
|
+
f"(searched {self.max_depth} directories deep)."
|
|
255
|
+
)
|
|
256
|
+
self.running = False
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
hints = {}
|
|
260
|
+
for label, local in courses.items():
|
|
261
|
+
hints[label] = self._display_path(local.path)
|
|
262
|
+
if (course := self.courses.get(local.path)) is not None:
|
|
263
|
+
hints[label] += f"\n{float(course.progress):.0%} solved"
|
|
264
|
+
if local.problems:
|
|
265
|
+
hints[label] += f"\nNeeds {dependencies.describe(local.problems)}."
|
|
266
|
+
for label, source_course in source_courses.items():
|
|
267
|
+
hints[label] = self._source_course_hint(source_course)
|
|
268
|
+
options = [*courses, *source_courses]
|
|
269
|
+
prompt = SelectInput("Select Course", options, hints=hints)
|
|
270
|
+
previous = self.__previous_course
|
|
271
|
+
for label, local in courses.items():
|
|
272
|
+
if previous is not None and self.courses.get(local.path) is previous:
|
|
273
|
+
prompt.set_answer(label)
|
|
274
|
+
|
|
275
|
+
self.cli.update_info(self.__select_info)
|
|
276
|
+
self.__select_info = _SELECT_INFO
|
|
277
|
+
while True:
|
|
278
|
+
try:
|
|
279
|
+
label = prompt.run(self.cli, q, allow_back=previous is not None)
|
|
280
|
+
except PromptBack:
|
|
281
|
+
assert previous is not None
|
|
282
|
+
self._enter_course(previous)
|
|
283
|
+
return
|
|
284
|
+
except PromptCancelled:
|
|
285
|
+
self.running = False
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if label in source_courses:
|
|
289
|
+
pulled = self._pull_course(source_courses[label])
|
|
290
|
+
if pulled is None:
|
|
291
|
+
# offered again as a course in the working directory, if it was
|
|
292
|
+
# pulled
|
|
293
|
+
self.__select_info = self.cli.info_text
|
|
294
|
+
return
|
|
295
|
+
path = pulled
|
|
296
|
+
elif courses[label].problems:
|
|
297
|
+
self.cli.update_info(_cant_start(label, courses[label].problems))
|
|
298
|
+
continue
|
|
299
|
+
else:
|
|
300
|
+
path = courses[label].path
|
|
301
|
+
if (course := self.courses.get(path)) is None:
|
|
302
|
+
try:
|
|
303
|
+
course = Course.load_course_manifest(path, check_progress=True)
|
|
304
|
+
except Exception as err: # noqa: BLE001
|
|
305
|
+
self.cli.update_info(
|
|
306
|
+
f"[red]{type(err).__name__}:[/red] {escape(str(err))}"
|
|
307
|
+
)
|
|
308
|
+
continue
|
|
309
|
+
self.courses[path] = course
|
|
310
|
+
self._enter_course(course)
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
def _enter_course(self, course: Course) -> None:
|
|
314
|
+
"""Make ``course`` the current course, to be drawn as a whole."""
|
|
315
|
+
self.__current_course = course
|
|
316
|
+
self.__previous_course = None
|
|
317
|
+
self.__entered = True
|
|
318
|
+
|
|
319
|
+
def leave_course(self) -> None:
|
|
320
|
+
"""Leave the current course for the course selector. The course keeps its
|
|
321
|
+
state, and is resumed if it is selected again."""
|
|
322
|
+
if self.__current_course is not None:
|
|
323
|
+
self.__previous_course = self.__current_course
|
|
324
|
+
self.__current_course = None
|
|
325
|
+
|
|
326
|
+
def _register_actions(self) -> None:
|
|
327
|
+
""" """
|
|
328
|
+
key_actions: list[KeyAction] = [
|
|
329
|
+
Quit(cli=self.cli, pyics=self),
|
|
330
|
+
Next(pyics=self),
|
|
331
|
+
Previous(pyics=self),
|
|
332
|
+
ToggleTrace(pyics=self),
|
|
333
|
+
Courses(pyics=self),
|
|
334
|
+
ScrollUp(cli=self.cli, pyics=self),
|
|
335
|
+
ScrollDown(cli=self.cli, pyics=self),
|
|
336
|
+
Hint(pyics=self),
|
|
337
|
+
]
|
|
338
|
+
for a in key_actions:
|
|
339
|
+
self.key_actions[a.key] = a
|
|
340
|
+
|
|
341
|
+
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
|
342
|
+
""" """
|
|
343
|
+
self.running = True
|
|
344
|
+
self.thread = threading.Thread(target=self.run)
|
|
345
|
+
self.thread.start()
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def current_course(self) -> Course | None:
|
|
349
|
+
""""""
|
|
350
|
+
return self.__current_course
|
|
351
|
+
|
|
352
|
+
def next_task(self) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Choose the next task in the self.tasks iteratable. Specifically load the task now
|
|
355
|
+
to avoid populating memory with fully loaded tasks.
|
|
356
|
+
"""
|
|
357
|
+
if self.current_course is not None:
|
|
358
|
+
self.current_course.next()
|
|
359
|
+
|
|
360
|
+
def reveal_hint(self) -> None:
|
|
361
|
+
"""Reveal the current task's next hint, if it has one left, to be drawn."""
|
|
362
|
+
if self.current_course is not None and self.current_course.reveal_hint():
|
|
363
|
+
self.__hint_revealed = True
|
|
364
|
+
|
|
365
|
+
@hey_listen("q")
|
|
366
|
+
def run(self, q: queue.Queue[str]):
|
|
367
|
+
""" """
|
|
368
|
+
self.cli()
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
while self.running:
|
|
372
|
+
if self.current_course is None:
|
|
373
|
+
self._select_course(q)
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
self.check_keypress(q)
|
|
377
|
+
if self.current_course is not None:
|
|
378
|
+
self._update_course(self.current_course)
|
|
379
|
+
|
|
380
|
+
time.sleep(0.05)
|
|
381
|
+
finally:
|
|
382
|
+
self.running = False
|
|
383
|
+
for course in self.courses.values():
|
|
384
|
+
course.stop_progress_check(_SHUTDOWN_TIMEOUT)
|
|
385
|
+
# the key listener restores the terminal mode once this returns
|
|
386
|
+
wait_for_test_runs(_SHUTDOWN_TIMEOUT)
|
|
387
|
+
self.cli.stop()
|
|
388
|
+
|
|
389
|
+
def _update_course(self, course: Course) -> None:
|
|
390
|
+
"""
|
|
391
|
+
Tick the current ``course`` and draw what has changed, or all of it if it has
|
|
392
|
+
just been entered.
|
|
393
|
+
"""
|
|
394
|
+
entered, self.__entered = self.__entered, False
|
|
395
|
+
hint_revealed, self.__hint_revealed = self.__hint_revealed, False
|
|
396
|
+
|
|
397
|
+
if course.error is not None:
|
|
398
|
+
# it can't be started, so it isn't resumed either
|
|
399
|
+
self.courses = {p: c for p, c in self.courses.items() if c is not course}
|
|
400
|
+
self.__current_course = None
|
|
401
|
+
self.__select_info = (
|
|
402
|
+
f"[red]Can't start {escape(course.name)}:[/red] "
|
|
403
|
+
f"{escape(str(course.error))}"
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
new_test_results, new_progress = course.tick()
|
|
408
|
+
task_changed = course.task_changed
|
|
409
|
+
if task_changed:
|
|
410
|
+
course.clear_task_changed()
|
|
411
|
+
|
|
412
|
+
if task_changed or entered:
|
|
413
|
+
info = course.name if course.loading else course.instructions
|
|
414
|
+
self.cli.update_info(escape(info))
|
|
415
|
+
|
|
416
|
+
if course.loading:
|
|
417
|
+
if entered or course.check_status != self.__shown_check_status:
|
|
418
|
+
self.__shown_check_status = course.check_status
|
|
419
|
+
self.cli.update_main_content(course.status)
|
|
420
|
+
elif new_test_results or task_changed or entered or hint_revealed:
|
|
421
|
+
# clear before reading the status, so results that arrive in between
|
|
422
|
+
# are picked up on the next tick instead of being dropped
|
|
423
|
+
course.clear_test_results()
|
|
424
|
+
if hint_revealed:
|
|
425
|
+
# the hints are shown above the test results
|
|
426
|
+
self.cli.scroll_offset = 0
|
|
427
|
+
task_sections = course.sections
|
|
428
|
+
titles = [
|
|
429
|
+
_exercise_title(p, task_sections.get(paths.absolute(p)))
|
|
430
|
+
for p in course.exercise_files
|
|
431
|
+
]
|
|
432
|
+
self.cli.update_main_content(
|
|
433
|
+
course.status, Text(", ", style="dim").join(titles)
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if new_progress or entered:
|
|
437
|
+
self.cli.update_progress(float(course.progress))
|
|
438
|
+
|
|
439
|
+
def check_keypress(self, q: queue.Queue[str]) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Check if a key has been pressed, and if so, try to match it to known actions.
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
q: Queue
|
|
446
|
+
The queue which collects key presses
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
key = q.get_nowait()
|
|
450
|
+
|
|
451
|
+
self.key_actions.get(key, lambda: ...)()
|
|
452
|
+
|
|
453
|
+
except queue.Empty:
|
|
454
|
+
pass
|
pyics/data/__init__.py
ADDED