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/scripts/sources.py
ADDED
|
@@ -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>")
|
pyics/tests/__init__.py
ADDED
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()
|