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/data/course.py ADDED
@@ -0,0 +1,531 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from fractions import Fraction
5
+ import os
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from rich.console import Group, RenderableType
11
+ from rich.padding import Padding
12
+ from rich.rule import Rule
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from pyics.data.task import Task
17
+ from pyics.distribution.dependencies import ModulePolicy, manifest_requirements
18
+ from pyics.util import json_handling as json
19
+ from pyics.util import paths
20
+ from pyics.util.discovery import COURSE_SUFFIX
21
+ from pyics.util.paths import StrPath
22
+ from pyics.util.logging import get_logger
23
+
24
+
25
+ # Optional fields of a course manifest, which describe the course when it is distributed
26
+ # (see ``pyics.distribution``): ``slug`` (the name it is pulled by), ``version``,
27
+ # ``description``, ``tags``, the packages it needs besides pyics (``requires``, PEP 508
28
+ # requirements) and the pyics version it needs (``requires_pyics``).
29
+ METADATA_KEYS = ("slug", "version", "description", "tags", "requires", "requires_pyics")
30
+
31
+
32
+ def _hints_view(hints: list[str], total: int) -> RenderableType:
33
+ """The revealed ``hints`` of a task with ``total`` hints, numbered below a rule."""
34
+ grid = Table.grid(padding=(0, 1))
35
+ grid.add_column(justify="right", style="bright_yellow")
36
+ grid.add_column()
37
+ for i, hint in enumerate(hints, 1):
38
+ grid.add_row(f"{i}.", Text(hint))
39
+ rule = Rule(f"[bright_yellow]Hints {len(hints)}/{total}[/]", style="bright_yellow")
40
+ return Padding(Group(rule, grid), pad=(0, 0, 1, 0))
41
+
42
+
43
+ class Course:
44
+ """
45
+ Course class. Handles creation and storage of courses, as well as safe task loading
46
+ and keeping track of course progress.
47
+ """
48
+
49
+ name: str
50
+ tasks: dict[str, StrPath]
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ name: str,
56
+ tasks: dict[str, StrPath],
57
+ progress: list[int],
58
+ requires: Sequence[str] | None = None,
59
+ check_progress: bool = False,
60
+ ) -> None:
61
+ """
62
+ Parameters
63
+ ----------
64
+ name: str
65
+ Name of the course.
66
+ tasks: dict[str, StrPath]
67
+ Ordered mapping of task names to task files.
68
+ progress: list[int]
69
+ Progress of each task (1 if solved), in the order of ``tasks``.
70
+ requires: Sequence[str] | None (optional)
71
+ The packages the course needs besides pyics, as PEP 508 requirements. Its
72
+ task files may make json_tricks import only their modules, besides pyics and
73
+ the standard library (see ``ModulePolicy``).
74
+ check_progress: bool (optional, keyword only)
75
+ If False (default), start right away on the first task that is unsolved
76
+ according to ``progress``. If True, the progress is instead checked from the
77
+ exercise files on disk, task by task in a background thread, and the course
78
+ starts on the first task found unsolved (see ``loading``).
79
+ """
80
+ self.name = name
81
+ self.tasks = tasks
82
+ self.requires = list(requires or [])
83
+ self.__module_policy = ModulePolicy(self.requires)
84
+ self.__tasks_list = list(self.tasks)
85
+ self.__progress: list[int] = progress
86
+
87
+ self.__current_task: Task | None = None
88
+ self.__task_changed: bool = False
89
+ self.__progress_changed: bool = False
90
+ self.__error: Exception | None = None
91
+ # how many hints have been revealed, by task name, so they stay revealed when a
92
+ # task is left and visited again
93
+ self.__hints_shown: dict[str, int] = {}
94
+
95
+ # the position of the task whose progress is being checked
96
+ self.__checking: int | None = 0 if check_progress else None
97
+ self.__stop_check = threading.Event()
98
+ self.__check_thread: threading.Thread | None = None
99
+
100
+ if check_progress:
101
+ self.__check_thread = threading.Thread(
102
+ target=self.__check_progress, daemon=True
103
+ )
104
+ self.__check_thread.start()
105
+ else:
106
+ # when you boot up a course, always show the next unsolved task
107
+ start = self.__progress.index(0) if 0 in self.__progress else -1
108
+ self.__set_current_task(self.__load_task(self.__tasks_list[start]))
109
+
110
+ def __load_task(self, name: str, *, setup: bool = True) -> Task:
111
+ """Load the task ``name`` of the course, which may only name the modules the
112
+ course declares for json_tricks to import."""
113
+ return Task(self.tasks[name], setup=setup, module_policy=self.__module_policy)
114
+
115
+ def __set_current_task(self, task: Task) -> None:
116
+ self.__current_task = task
117
+ self.__task_changed = True
118
+
119
+ def __check_progress(self) -> None:
120
+ """
121
+ DO NOT CALL THIS METHOD DIRECTLY, IT IS RUN BY ``__init__``.
122
+
123
+ Check every task's progress from its exercise files, in order, and start on the
124
+ first unsolved task (the last task if all are solved) as soon as it is found.
125
+ """
126
+ last: Task | None = None
127
+ for i, name in enumerate(self.__tasks_list):
128
+ if self.__stop_check.is_set():
129
+ break
130
+ self.__checking = i
131
+ try:
132
+ last = self.__load_task(name, setup=False)
133
+ solved = last.check_solved()
134
+ except Exception as err: # noqa: BLE001
135
+ get_logger(__name__).warning(
136
+ f"Can't check the task {name!r} of {self.name!r}", exc_info=True
137
+ )
138
+ if self.__current_task is None:
139
+ self.__error = err
140
+ break
141
+ continue
142
+
143
+ if self.__progress[i] != int(solved):
144
+ self.__progress[i] = int(solved)
145
+ self.__progress_changed = True
146
+ if self.__current_task is None and not solved:
147
+ last.setup()
148
+ self.__set_current_task(last)
149
+ else:
150
+ if self.__current_task is None and last is not None:
151
+ last.setup()
152
+ self.__set_current_task(last)
153
+ self.__checking = None
154
+
155
+ def stop_progress_check(self, timeout: float | None = None) -> None:
156
+ """
157
+ Stop checking the progress after the task that is being checked, and wait for
158
+ that (at most ``timeout`` seconds).
159
+ """
160
+ self.__stop_check.set()
161
+ if self.__check_thread is not None:
162
+ self.__check_thread.join(timeout)
163
+
164
+ @property
165
+ def tracing(self) -> bool:
166
+ """Whether the current task's tests are traced (see ``Task.tracing``). Off for
167
+ each task that is moved to, and while the course is loading."""
168
+ return self.__current_task is not None and self.__current_task.tracing
169
+
170
+ def toggle_tracing(self) -> None:
171
+ """Turn tracing the current task's tests on or off (see ``Task.toggle_tracing``),
172
+ unless the course is loading."""
173
+ if self.__current_task is not None:
174
+ self.__current_task.toggle_tracing()
175
+
176
+ @property
177
+ def loading(self) -> bool:
178
+ """Whether the course is still looking for the task to start on."""
179
+ return self.__current_task is None
180
+
181
+ @property
182
+ def check_status(self) -> str:
183
+ """What the progress check is doing, or "" once it is done."""
184
+ if self.__checking is None:
185
+ return ""
186
+ return f"Checking your progress... (task {self.__checking + 1}/{len(self.tasks)})"
187
+
188
+ @property
189
+ def error(self) -> Exception | None:
190
+ """Why the course can't be started, if its progress check failed before a task
191
+ to start on was found."""
192
+ return self.__error
193
+
194
+ @property
195
+ def progress(self) -> Fraction:
196
+ return Fraction(sum(self.__progress), len(self.__progress))
197
+
198
+ @property
199
+ def instructions(self) -> str:
200
+ if self.__current_task is None:
201
+ return ""
202
+ return self.__current_task.instructions
203
+
204
+ def __current_task_name(self) -> str:
205
+ """The current task's name in the course."""
206
+ return self.__tasks_list[self.__current_task_number()]
207
+
208
+ @property
209
+ def hints(self) -> list[str]:
210
+ """The current task's hints that have been revealed (see ``reveal_hint``)."""
211
+ if self.__current_task is None:
212
+ return []
213
+ shown = self.__hints_shown.get(self.__current_task_name(), 0)
214
+ return self.__current_task.help[:shown]
215
+
216
+ def reveal_hint(self) -> bool:
217
+ """
218
+ Reveal the current task's next hint, if it has one that hasn't been revealed.
219
+
220
+ Returns
221
+ -------
222
+ bool
223
+ Whether a hint has been revealed. False while the course is loading.
224
+ """
225
+ if self.__current_task is None:
226
+ return False
227
+ name = self.__current_task_name()
228
+ shown = self.__hints_shown.get(name, 0)
229
+ if shown >= len(self.__current_task.help):
230
+ return False
231
+ self.__hints_shown[name] = shown + 1
232
+ return True
233
+
234
+ @property
235
+ def info(self) -> RenderableType:
236
+ """
237
+ The current task's info, i.e. what is supposed to be displayed in the pyics header.
238
+ """
239
+ if self.__current_task is None:
240
+ return ""
241
+ return self.__current_task.info
242
+
243
+ # @info.setter
244
+ # def info(self, v: str) -> None:
245
+ # """
246
+ # I don't think this is needed.
247
+ # """
248
+ # self.__current_task.info = v
249
+
250
+ @property
251
+ def status(self) -> RenderableType:
252
+ """
253
+ The current task's status, i.e. what is supposed to be displayed in the pyics
254
+ main panel, below its revealed hints.
255
+ """
256
+ if self.__current_task is None:
257
+ return Text(self.check_status, style="dim")
258
+ if hints := self.hints:
259
+ total = len(self.__current_task.help)
260
+ return Group(_hints_view(hints, total), self.__current_task.status)
261
+ return self.__current_task.status
262
+
263
+ def update_status(self) -> None:
264
+ """
265
+ Expose the current task's update_status() method.
266
+ """
267
+ if self.__current_task is not None:
268
+ self.__current_task.update_status()
269
+
270
+ @property
271
+ def exercise_files(self) -> list[Path]:
272
+ """
273
+ The current task's exercise file paths.
274
+ """
275
+ if self.__current_task is None:
276
+ return []
277
+ return self.__current_task.exercise_files
278
+
279
+ @property
280
+ def sections(self) -> dict[Path, str]:
281
+ """
282
+ The current task's sections: the section of each of its exercise notebooks that
283
+ it is about, by the notebook's absolute path (see ``Task.sections``).
284
+ """
285
+ if self.__current_task is None:
286
+ return {}
287
+ return self.__current_task.sections
288
+
289
+ def __current_task_number(self) -> int:
290
+ """The current task's position in the course."""
291
+ assert self.__current_task is not None
292
+ return self.__tasks_list.index(
293
+ {v: k for k, v in self.tasks.items()}[self.__current_task.path]
294
+ )
295
+
296
+ def next(self) -> Task | None:
297
+ """
298
+ Select the next task. If the current task is unsolved, returns the current task.
299
+
300
+ Returns
301
+ -------
302
+ Task | None
303
+ Next task if the current task is solved, otherwise current task. None while
304
+ the course is loading.
305
+ """
306
+ if self.__current_task is not None and self.__current_task.solved:
307
+ current_task_number = self.__current_task_number()
308
+
309
+ if current_task_number + 1 < len(self.__tasks_list):
310
+ self.__set_current_task(
311
+ self.__load_task(self.__tasks_list[current_task_number + 1])
312
+ )
313
+
314
+ return self.__current_task
315
+
316
+ def previous(self) -> Task | None:
317
+ """
318
+ Select the previous task (if available)
319
+
320
+ Returns
321
+ -------
322
+ Task | None
323
+ Previous task if the current task isn't the first task, otherwise current
324
+ task. None while the course is loading.
325
+ """
326
+ if self.__current_task is None:
327
+ return None
328
+
329
+ current_task_number = self.__current_task_number()
330
+
331
+ if current_task_number != 0:
332
+ self.__set_current_task(
333
+ self.__load_task(self.__tasks_list[current_task_number - 1])
334
+ )
335
+
336
+ return self.__current_task
337
+
338
+ @property
339
+ def task_changed(self) -> bool:
340
+ """
341
+ Flag to indicate whether the current task has changed, set by next or previous,
342
+ and when the course has started on a task.
343
+ """
344
+ return self.__task_changed
345
+
346
+ def clear_task_changed(self) -> None:
347
+ """Expose the option to reset task_changed"""
348
+ self.__task_changed = False
349
+
350
+ def clear_test_results(self) -> None:
351
+ """Expose the current task's clear_test_results method"""
352
+ if self.__current_task is not None:
353
+ self.__current_task.clear_test_results()
354
+
355
+ def tick(self) -> tuple[bool, bool]:
356
+ """
357
+ Perform regular actions on the course, or pass them on to the current task. Does
358
+ the following:
359
+
360
+ 1. Calls check_file_changes on the current task
361
+ 2. Calls run_tests on the current task if files have changed.
362
+ 3. Calls check_overlay on the current task, to show the results of a plugin's
363
+ overlay (which never count as progress)
364
+ 4. checks if the course progress needs to be updated
365
+
366
+ Returns
367
+ -------
368
+ tuple[bool, bool]
369
+ first position indicates whether new test results from the current task are
370
+ available,
371
+ second position indicates whether the course progress has been updated (i.e.
372
+ whether a task has been newly solved/unsolved, here or by the progress check)
373
+ """
374
+ new_progress, self.__progress_changed = self.__progress_changed, False
375
+
376
+ if self.__current_task is None:
377
+ return (False, new_progress)
378
+
379
+ files_changed = self.__current_task.check_file_changes()
380
+
381
+ if files_changed:
382
+ self.__current_task.run_tests()
383
+
384
+ self.__current_task.check_overlay()
385
+
386
+ current_task_number = self.__current_task_number()
387
+ solved = int(self.__current_task.solved)
388
+ if self.__progress[current_task_number] != solved:
389
+ self.__progress[current_task_number] = solved
390
+ new_progress = True
391
+
392
+ return (self.__current_task.new_test_results, new_progress)
393
+
394
+ @classmethod
395
+ def load_course_manifest(cls, fp: StrPath, *, check_progress: bool = False) -> Course:
396
+ """
397
+ Load a course manifest to a ``Course`` object. Task file paths in the manifest
398
+ are relative to the manifest's directory (see ``create_course_manifest``).
399
+
400
+ Parameters
401
+ ----------
402
+ fp: StrPath
403
+ Path to the manifest
404
+ check_progress: bool (optional, keyword only)
405
+ Check the progress from the exercise files on disk (see ``Course``). Default
406
+ is False.
407
+
408
+ Returns
409
+ -------
410
+ Course
411
+ The Course object represented in the manifest
412
+ """
413
+ # a manifest holds plain data only, so reading it never imports a module
414
+ data = json.load_plain(fp)
415
+
416
+ base = Path(fp).parent
417
+ tasks = {k: paths.resolve(v, base) for k, v in data["tasks"].items()}
418
+ progress = list(data.get("progress") or [])[: len(tasks)]
419
+ progress += [0] * (len(tasks) - len(progress))
420
+ requires, _ = manifest_requirements(data)
421
+
422
+ return cls(
423
+ name=data.get("name") or Path(fp).name.removesuffix(COURSE_SUFFIX),
424
+ tasks=tasks,
425
+ progress=progress,
426
+ requires=requires,
427
+ check_progress=check_progress,
428
+ )
429
+
430
+ @staticmethod
431
+ def create_course_manifest(
432
+ fp: StrPath,
433
+ task_files: Sequence[StrPath],
434
+ *,
435
+ name: Optional[str] = None,
436
+ use_task_names: bool = True,
437
+ progress: Optional[list[int]] = None,
438
+ metadata: Optional[Mapping[str, Any]] = None,
439
+ ) -> Path:
440
+ """
441
+ Compose a course manifest from the provided ``task_files``. A course manifest is
442
+ a json file containing the course name, current progress, and an ordered mapping
443
+ of task names and their file paths.
444
+
445
+ Parameters
446
+ ----------
447
+ fp: StrPath
448
+ Path to the course manifest. Should have the suffix '.pyicscourse.json'. If
449
+ it doesn't, makes changes to the suffix and logs (if logging is active).
450
+ task_files: Sequence[StrPath]
451
+ List of task files to compose the course. Order is relevant. Given relative
452
+ to the working directory (or absolute), and stored relative to the directory
453
+ of the manifest, so the course keeps working when it is moved together with
454
+ its tasks.
455
+ name: str | None (optional, keyword only)
456
+ Name of the course. If None, use ``fp``'s name without its suffix.
457
+ use_task_names: bool (optional, keyword only)
458
+ Use tasks' names for the mapping in the course manifest. Default is True.
459
+ Setting this to False increases performance (no more json loading of tasks),
460
+ but loses information (only enumerates tasks).
461
+ progress: list[int] | None (optional, keyword only)
462
+ Progress of each task, in the order of ``task_files``. Default is None, i.e.
463
+ no task solved yet.
464
+ metadata: Mapping[str, Any] | None (optional, keyword only)
465
+ The course's optional fields (see ``METADATA_KEYS``), e.g. those of the
466
+ manifest that is rewritten. Other keys are ignored.
467
+
468
+ Returns
469
+ -------
470
+ Path
471
+ The path the manifest was written to, with its suffix normalized to
472
+ '.pyicscourse.json'.
473
+ """
474
+ fp = Path(fp)
475
+
476
+ match fp.suffixes:
477
+ case []:
478
+ fp = fp.with_suffix(".pyicscourse.json")
479
+ case [_]:
480
+ get_logger(__name__).warning(
481
+ f"Replacing suffix '{fp.suffix}' with '.pyicscourse.json'."
482
+ )
483
+ fp = fp.with_suffix(".pyicscourse.json")
484
+ case [".pyicscourse", ".json"]:
485
+ pass
486
+ case _:
487
+ get_logger(__name__).warning(
488
+ f"File suffixes don't match expected file path. Adding '.pyicscourse.json'."
489
+ )
490
+ fp = fp.with_suffix(fp.suffix + ".pyicscourse.json")
491
+
492
+ manifest = {}
493
+
494
+ manifest["name"] = name or fp.name.removesuffix(".pyicscourse.json")
495
+
496
+ if use_task_names:
497
+
498
+ def get_task_name(fp: StrPath) -> str:
499
+ return json.load_plain(fp)["name"]
500
+
501
+ keys = map(get_task_name, task_files)
502
+
503
+ else:
504
+ width = len(str(len(task_files)))
505
+ keys = [
506
+ f"{manifest['name']}_{str(n).zfill(width)}" for n in range(len(task_files))
507
+ ]
508
+
509
+ manifest["tasks"] = {
510
+ k: paths.relative_to(v, fp.parent) for k, v in zip(keys, task_files)
511
+ }
512
+
513
+ if progress is None:
514
+ progress = [0] * len(task_files)
515
+ elif len(progress) != len(task_files):
516
+ raise ValueError(
517
+ f"got progress for {len(progress)} tasks, but {len(task_files)} tasks"
518
+ )
519
+ manifest["progress"] = list(progress)
520
+ for key in METADATA_KEYS:
521
+ if metadata and metadata.get(key) is not None:
522
+ manifest[key] = metadata[key]
523
+
524
+ # serialized before the file is opened, so a manifest that is being edited isn't
525
+ # left empty if serializing fails
526
+ text = json.dumps(manifest, indent=4)
527
+ os.makedirs(fp.parent, exist_ok=True)
528
+ with open(fp, "w", encoding="utf-8") as f:
529
+ f.write(text)
530
+
531
+ return fp