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