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
@@ -0,0 +1,118 @@
1
+ from pyics.distribution import dependencies, sources
2
+ from pyics.distribution.sources import SourceError
3
+ from pyics.scripts.console import TRUST_WARNING, confirm, fail
4
+
5
+
6
+ def _add_source(
7
+ url: str, name: str | None = None, allow_http: bool = False, yes: bool = False
8
+ ) -> None:
9
+ """
10
+ Console script to add a course library (a source) by the URL of its index, so that
11
+ its courses can be pulled by name (see ``pull``).
12
+
13
+ Parameters
14
+ ----------
15
+ url: str
16
+ The URL of the index, or its path, e.g. on a network share.
17
+ name: str | None
18
+ The name to know the source by. By default, the index's name.
19
+ allow_http: bool
20
+ Allow fetching the index and the courses over plain http, which isn't secure.
21
+ yes: bool
22
+ Don't ask for confirmation.
23
+ """
24
+ try:
25
+ source, index, data = sources.prepare_source(url, name, allow_http=allow_http)
26
+ except SourceError as err:
27
+ fail(err)
28
+ count = f"{len(index.courses)} course" + ("" if len(index.courses) == 1 else "s")
29
+ courses = f": {', '.join(index.courses)}" if index.courses else ""
30
+ print(f"{index.name or source.url} lists {count}{courses}")
31
+ print(TRUST_WARNING)
32
+ if not yes and not confirm(f"Add it as {source.name!r}?"):
33
+ print("Cancelled, no source was added.")
34
+ return
35
+ try:
36
+ sources.save_source(source, data)
37
+ except (SourceError, OSError) as err:
38
+ fail(err)
39
+ print(f"Added the source {source.name!r}. Find its courses with: pyics search")
40
+
41
+
42
+ def _remove_source(name: str) -> None:
43
+ """
44
+ Console script to remove a course library (see ``add_source``). Courses pulled from
45
+ it stay where they are.
46
+
47
+ Parameters
48
+ ----------
49
+ name: str
50
+ The name of the source.
51
+ """
52
+ try:
53
+ sources.remove_source(name)
54
+ except (SourceError, OSError) as err:
55
+ fail(err)
56
+ print(f"Removed the source {name!r}.")
57
+
58
+
59
+ def _sources() -> None:
60
+ """Console script to list the course libraries (see ``add_source``)."""
61
+ try:
62
+ found = sources.load_sources()
63
+ except SourceError as err:
64
+ fail(err)
65
+ if not found:
66
+ print("You haven't added any sources. Add one with: pyics add_source <url>")
67
+ return
68
+ for source in found:
69
+ line = f"{source.name} {source.url}"
70
+ if source.allow_http:
71
+ line += " (plain http allowed)"
72
+ if source.site is not None:
73
+ line += f" (for all users, in {source.site})"
74
+ print(line)
75
+
76
+
77
+ def _search(term: str | None = None, offline: bool = False) -> None:
78
+ """
79
+ Console script to list the courses in the course libraries (see ``add_source``)
80
+ that match ``term`` in their name, title, description or tags, or all of them.
81
+
82
+ Parameters
83
+ ----------
84
+ term: str | None
85
+ What to look for.
86
+ offline: bool
87
+ Only look in the indexes fetched last, without fetching them anew.
88
+ """
89
+ try:
90
+ if not sources.load_sources():
91
+ print("You haven't added any sources. Add one with: pyics add_source <url>")
92
+ return
93
+ courses, warnings = sources.search(term, refresh=not offline)
94
+ except SourceError as err:
95
+ fail(err)
96
+ for warning in warnings:
97
+ print(f"warning: {warning}")
98
+ if not courses:
99
+ print("No course found." if term is None else f"No course matches {term!r}.")
100
+ return
101
+
102
+ slugs = [entry.slug for _, entry in courses]
103
+ for source, entry in courses:
104
+ # the source is needed to pull a course whose slug several sources have
105
+ name = (
106
+ entry.slug
107
+ if slugs.count(entry.slug) == 1
108
+ else f"{source.name}/{entry.slug}"
109
+ )
110
+ version = f" {entry.version}" if entry.version else ""
111
+ print(f"{name}{version}: {entry.title} (from {source.name})")
112
+ if entry.description:
113
+ print(f" {entry.description}")
114
+ if entry.tags:
115
+ print(f" tags: {', '.join(entry.tags)}")
116
+ if problems := dependencies.check(entry.requires, entry.requires_pyics):
117
+ print(f" needs {dependencies.describe(problems)}")
118
+ print("Pull a course with: pyics pull <name>")
@@ -0,0 +1,3 @@
1
+ from .tests import Test, PrintTest, SimpleTest, SpecTest, StructureTest, SizeTest
2
+
3
+ all = ["Test", "PrintTest", "SimpleTest", "SpecTest", "StructureTest", "SizeTest"]
pyics/tests/capture.py ADDED
@@ -0,0 +1,114 @@
1
+ import builtins
2
+ import io
3
+ import sys
4
+ import threading
5
+ from collections.abc import Generator, Iterable, Iterator
6
+ from contextlib import contextmanager
7
+
8
+
9
+ class _ThreadLocalStdout:
10
+ """`sys.stdout` (or `sys.stderr`) replacement that only redirects the calling thread's
11
+ writes."""
12
+
13
+ def __init__(self, real):
14
+ self._real = real
15
+ self._local = threading.local()
16
+
17
+ def _stack(self) -> list[io.StringIO]:
18
+ return self._local.__dict__.setdefault("stack", [])
19
+
20
+ def write(self, s: str) -> int:
21
+ stack = self._stack()
22
+ return stack[-1].write(s) if stack else self._real.write(s)
23
+
24
+ def flush(self) -> None:
25
+ if not self._stack():
26
+ self._real.flush()
27
+
28
+ def __getattr__(self, name):
29
+ return getattr(self._real, name)
30
+
31
+
32
+ _install_lock = threading.Lock()
33
+
34
+
35
+ def _ensure_installed(stream: str = "stdout") -> _ThreadLocalStdout:
36
+ with _install_lock:
37
+ if not isinstance(getattr(sys, stream), _ThreadLocalStdout):
38
+ setattr(sys, stream, _ThreadLocalStdout(getattr(sys, stream)))
39
+ return getattr(sys, stream)
40
+
41
+
42
+ @contextmanager
43
+ def capture_stdout() -> Generator[io.StringIO]:
44
+ """Capture this thread's stdout writes only; other threads are unaffected."""
45
+ with _capture("stdout") as buf:
46
+ yield buf
47
+
48
+
49
+ @contextmanager
50
+ def capture_stderr() -> Generator[io.StringIO]:
51
+ """Capture this thread's stderr writes only; other threads are unaffected."""
52
+ with _capture("stderr") as buf:
53
+ yield buf
54
+
55
+
56
+ @contextmanager
57
+ def _capture(stream: str) -> Generator[io.StringIO]:
58
+ proxy = _ensure_installed(stream)
59
+ buf = io.StringIO()
60
+ stack = proxy._stack()
61
+ stack.append(buf)
62
+ try:
63
+ yield buf
64
+ finally:
65
+ stack.pop()
66
+
67
+
68
+ class _ThreadLocalInput:
69
+ """`builtins.input` replacement that answers the calling thread's calls from provided
70
+ inputs, if any."""
71
+
72
+ def __init__(self, real):
73
+ self._real = real
74
+ self._local = threading.local()
75
+
76
+ def _stack(self) -> list[Iterator[str]]:
77
+ return self._local.__dict__.setdefault("stack", [])
78
+
79
+ def __call__(self, prompt: object = "") -> str:
80
+ stack = self._stack()
81
+ if not stack:
82
+ return self._real(prompt)
83
+
84
+ sys.stdout.write(str(prompt))
85
+ try:
86
+ answer = next(stack[-1])
87
+ except StopIteration:
88
+ raise EOFError("EOF when reading a line, no inputs left") from None
89
+ sys.stdout.write(answer + "\n") # echoed, as when typed into a console
90
+ return answer
91
+
92
+
93
+ def _ensure_input_installed() -> _ThreadLocalInput:
94
+ with _install_lock:
95
+ if not isinstance(builtins.input, _ThreadLocalInput):
96
+ builtins.input = _ThreadLocalInput(builtins.input)
97
+ return builtins.input
98
+
99
+
100
+ @contextmanager
101
+ def provide_input(inputs: Iterable[str]) -> Generator[None]:
102
+ """
103
+ Answer this thread's ``input()`` calls with ``inputs``, in order, writing each to
104
+ stdout after the prompt, like a console shows typed answers. Once they have run out,
105
+ ``input()`` raises ``EOFError``, like at the end of stdin. Other threads are
106
+ unaffected.
107
+ """
108
+ proxy = _ensure_input_installed()
109
+ stack = proxy._stack()
110
+ stack.append(iter(inputs))
111
+ try:
112
+ yield
113
+ finally:
114
+ stack.pop()