python-interactive-courses 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
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
@@ -0,0 +1,4 @@
1
+ from .course import Course
2
+ from .task import Task
3
+
4
+ all = ["Course", "Task"]