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
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The course libraries ("sources") the user has added, and their indexes.
|
|
3
|
+
|
|
4
|
+
A source is the URL of an index: a JSON file, served by any web server (or a local
|
|
5
|
+
file), that lists courses by their slugs, the names they are pulled by::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"pyics_index": 1,
|
|
9
|
+
"name": "Uni Bamberg AI courses",
|
|
10
|
+
"courses": {
|
|
11
|
+
"search-algorithms": {
|
|
12
|
+
"title": "Search Algorithms",
|
|
13
|
+
"description": "DFS, BFS, ...",
|
|
14
|
+
"version": "1.2.0",
|
|
15
|
+
"requires_pyics": ">=0.1",
|
|
16
|
+
"requires": ["numpy>=1.26"],
|
|
17
|
+
"url": "courses/search-algorithms-1.2.0.zip",
|
|
18
|
+
"sha256": "...",
|
|
19
|
+
"tags": ["algorithms", "graphs"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Each course is a zip archive (see ``archive``), whose ``url`` is relative to the index.
|
|
25
|
+
``build_index`` writes an index for a directory of archives.
|
|
26
|
+
|
|
27
|
+
The sources are stored in ``sources.json`` in the user's configuration directory (see
|
|
28
|
+
``config_dir``), and the sources an administrator configured for all users of a
|
|
29
|
+
computer in the site configuration directories (see ``site_config_dirs``). The last
|
|
30
|
+
fetched index of each source is cached (see ``cache_dir``), so that the courses it
|
|
31
|
+
lists are known without a connection.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import tempfile
|
|
38
|
+
import urllib.parse
|
|
39
|
+
from collections.abc import Iterable, Sequence
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import platformdirs
|
|
45
|
+
|
|
46
|
+
from pyics.distribution import archive
|
|
47
|
+
from pyics.distribution.dependencies import is_newer
|
|
48
|
+
from pyics.distribution.fetch import FetchError, check_url, fetch, to_url
|
|
49
|
+
from pyics.distribution.slugs import check_slug, slugify
|
|
50
|
+
|
|
51
|
+
# overrides the user's configuration directory; only its sources are used then
|
|
52
|
+
CONFIG_DIR_ENV = "PYICS_CONFIG_DIR"
|
|
53
|
+
SOURCES_FILE = "sources.json"
|
|
54
|
+
INDEX_FILE = "index.json"
|
|
55
|
+
INDEX_VERSION = 1 # the version of the index format pyics reads and writes
|
|
56
|
+
MAX_INDEX_BYTES = 10 * 2**20
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SourceError(Exception):
|
|
60
|
+
"""A source can't be added, removed or used, or a course can't be found in them."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_dir() -> Path:
|
|
64
|
+
"""The user's pyics configuration directory, e.g. ``~/.config/pyics`` on Linux,
|
|
65
|
+
unless overridden by the ``PYICS_CONFIG_DIR`` environment variable."""
|
|
66
|
+
if override := os.environ.get(CONFIG_DIR_ENV):
|
|
67
|
+
return Path(override)
|
|
68
|
+
return Path(platformdirs.user_config_dir("pyics", appauthor=False))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def site_config_dirs() -> list[Path]:
|
|
72
|
+
"""The directories in which administrators configure sources for all users of the
|
|
73
|
+
computer, e.g. ``/etc/xdg/pyics`` on Linux. None if ``PYICS_CONFIG_DIR`` is set."""
|
|
74
|
+
if os.environ.get(CONFIG_DIR_ENV):
|
|
75
|
+
return []
|
|
76
|
+
dirs = platformdirs.site_config_dir("pyics", appauthor=False, multipath=True)
|
|
77
|
+
return [Path(d) for d in dirs.split(os.pathsep) if d]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cache_dir() -> Path:
|
|
81
|
+
"""Where the last fetched index of each source is kept."""
|
|
82
|
+
if override := os.environ.get(CONFIG_DIR_ENV):
|
|
83
|
+
return Path(override) / "cache"
|
|
84
|
+
return Path(platformdirs.user_cache_dir("pyics", appauthor=False))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Source:
|
|
89
|
+
"""A course library: the URL of its index, and the name it is known by."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
url: str
|
|
93
|
+
# whether its index and archives may be fetched over plain http
|
|
94
|
+
allow_http: bool = False
|
|
95
|
+
# the site configuration file it is configured in for all users, None if it is the
|
|
96
|
+
# user's own
|
|
97
|
+
site: Path | None = None
|
|
98
|
+
|
|
99
|
+
def to_json(self) -> dict[str, Any]:
|
|
100
|
+
data: dict[str, Any] = {"name": self.name, "url": self.url}
|
|
101
|
+
if self.allow_http:
|
|
102
|
+
data["allow_http"] = True
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_sources(fp: Path, site: bool) -> list[Source]:
|
|
107
|
+
if not fp.is_file():
|
|
108
|
+
return []
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(fp.read_text(encoding="utf-8"))
|
|
111
|
+
return [
|
|
112
|
+
Source(
|
|
113
|
+
check_slug(s["name"], "source name"),
|
|
114
|
+
str(s["url"]),
|
|
115
|
+
bool(s.get("allow_http", False)),
|
|
116
|
+
fp if site else None,
|
|
117
|
+
)
|
|
118
|
+
for s in data["sources"]
|
|
119
|
+
]
|
|
120
|
+
except Exception as err: # noqa: BLE001
|
|
121
|
+
raise SourceError(f"The sources in {fp} can't be read: {err}") from err
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_sources() -> list[Source]:
|
|
125
|
+
"""The sources: the user's own first, then those configured for all users (unless
|
|
126
|
+
the user has one of the same name), each in the order they were added. Courses are
|
|
127
|
+
looked for in this order."""
|
|
128
|
+
sources = _read_sources(config_dir() / SOURCES_FILE, site=False)
|
|
129
|
+
names = {s.name for s in sources}
|
|
130
|
+
for directory in site_config_dirs():
|
|
131
|
+
for source in _read_sources(directory / SOURCES_FILE, site=True):
|
|
132
|
+
if source.name not in names:
|
|
133
|
+
names.add(source.name)
|
|
134
|
+
sources.append(source)
|
|
135
|
+
return sources
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _write_json(fp: Path, data: Any) -> None:
|
|
139
|
+
"""Write ``data`` to ``fp`` as a whole, so that it is never left half written."""
|
|
140
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
fd, tmp = tempfile.mkstemp(dir=fp.parent, prefix=f".{fp.name}.", suffix=".tmp")
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
144
|
+
json.dump(data, f, indent=4)
|
|
145
|
+
f.write("\n")
|
|
146
|
+
os.replace(tmp, fp)
|
|
147
|
+
except BaseException:
|
|
148
|
+
Path(tmp).unlink(missing_ok=True)
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _write_user_sources(sources: Iterable[Source]) -> None:
|
|
153
|
+
data = {"version": 1, "sources": [s.to_json() for s in sources if s.site is None]}
|
|
154
|
+
_write_json(config_dir() / SOURCES_FILE, data)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- indexes ---
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class CourseEntry:
|
|
162
|
+
"""A course listed in an index."""
|
|
163
|
+
|
|
164
|
+
slug: str
|
|
165
|
+
title: str
|
|
166
|
+
# the absolute URL of its archive
|
|
167
|
+
url: str
|
|
168
|
+
sha256: str
|
|
169
|
+
description: str = ""
|
|
170
|
+
version: str = ""
|
|
171
|
+
requires: list[str] = field(default_factory=list)
|
|
172
|
+
requires_pyics: str | None = None
|
|
173
|
+
tags: list[str] = field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
def matches(self, term: str) -> bool:
|
|
176
|
+
"""Whether ``term`` occurs in its slug, title, description or tags, ignoring
|
|
177
|
+
case."""
|
|
178
|
+
term = term.lower()
|
|
179
|
+
texts = [self.slug, self.title, self.description, *self.tags]
|
|
180
|
+
return any(term in text.lower() for text in texts)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class Index:
|
|
185
|
+
"""The index of a source."""
|
|
186
|
+
|
|
187
|
+
name: str
|
|
188
|
+
courses: dict[str, CourseEntry]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _str_list(value: Any, what: str) -> list[str]:
|
|
192
|
+
if value is None:
|
|
193
|
+
return []
|
|
194
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
195
|
+
raise ValueError(f"its {what} must be a list of strings")
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _optional_str(value: Any, what: str) -> str:
|
|
200
|
+
if value is None:
|
|
201
|
+
return ""
|
|
202
|
+
if not isinstance(value, str):
|
|
203
|
+
raise ValueError(f"its {what} must be a string")
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_entry(slug: str, data: Any, index_url: str) -> CourseEntry:
|
|
208
|
+
check_slug(slug, "course slug")
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
raise ValueError("it must be an object")
|
|
211
|
+
url, sha256 = data.get("url"), data.get("sha256")
|
|
212
|
+
if not isinstance(url, str) or not url:
|
|
213
|
+
raise ValueError("it has no url")
|
|
214
|
+
if not isinstance(sha256, str) or len(sha256) != 64:
|
|
215
|
+
raise ValueError("it has no sha256 hash")
|
|
216
|
+
try:
|
|
217
|
+
int(sha256, 16)
|
|
218
|
+
except ValueError:
|
|
219
|
+
raise ValueError("its sha256 hash isn't hexadecimal") from None
|
|
220
|
+
requires_pyics = data.get("requires_pyics")
|
|
221
|
+
if requires_pyics is not None and not isinstance(requires_pyics, str):
|
|
222
|
+
raise ValueError("its requires_pyics must be a version specifier")
|
|
223
|
+
return CourseEntry(
|
|
224
|
+
slug=slug,
|
|
225
|
+
title=_optional_str(data.get("title"), "title") or slug,
|
|
226
|
+
url=urllib.parse.urljoin(index_url, url),
|
|
227
|
+
sha256=sha256.lower(),
|
|
228
|
+
description=_optional_str(data.get("description"), "description"),
|
|
229
|
+
version=_optional_str(data.get("version"), "version"),
|
|
230
|
+
requires=_str_list(data.get("requires"), "requires"),
|
|
231
|
+
requires_pyics=requires_pyics or None,
|
|
232
|
+
tags=_str_list(data.get("tags"), "tags"),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def parse_index(data: Any, url: str) -> Index:
|
|
237
|
+
"""
|
|
238
|
+
Parse the index ``data`` (decoded JSON) fetched from ``url``.
|
|
239
|
+
|
|
240
|
+
Raises
|
|
241
|
+
------
|
|
242
|
+
SourceError
|
|
243
|
+
If it isn't a valid index, or needs a newer version of pyics.
|
|
244
|
+
"""
|
|
245
|
+
if not isinstance(data, dict) or "pyics_index" not in data:
|
|
246
|
+
raise SourceError(f"{url} isn't a pyics index")
|
|
247
|
+
version = data["pyics_index"]
|
|
248
|
+
if not isinstance(version, int) or version < 1:
|
|
249
|
+
raise SourceError(f"{url} has an invalid index version {version!r}")
|
|
250
|
+
if version > INDEX_VERSION:
|
|
251
|
+
raise SourceError(
|
|
252
|
+
f"{url} is an index of version {version}, which needs a newer pyics: "
|
|
253
|
+
"upgrade it with: pip install -U pyics"
|
|
254
|
+
)
|
|
255
|
+
name = data.get("name")
|
|
256
|
+
courses = data.get("courses")
|
|
257
|
+
if not isinstance(courses, dict):
|
|
258
|
+
raise SourceError(f"The index {url} has no courses")
|
|
259
|
+
entries: dict[str, CourseEntry] = {}
|
|
260
|
+
for slug, entry in courses.items():
|
|
261
|
+
try:
|
|
262
|
+
entries[slug] = _parse_entry(slug, entry, url)
|
|
263
|
+
except ValueError as err:
|
|
264
|
+
raise SourceError(f"The course {slug!r} of the index {url}: {err}") from err
|
|
265
|
+
return Index(name if isinstance(name, str) else "", entries)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def fetch_index(url: str, *, allow_http: bool = False) -> tuple[Index, Any]:
|
|
269
|
+
"""
|
|
270
|
+
Fetch and parse the index at ``url``.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
tuple[Index, Any]
|
|
275
|
+
The index, and its decoded JSON, e.g. to cache it.
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
raw = fetch(url, max_bytes=MAX_INDEX_BYTES, allow_http=allow_http)
|
|
279
|
+
except FetchError as err:
|
|
280
|
+
raise SourceError(str(err)) from err
|
|
281
|
+
try:
|
|
282
|
+
data = json.loads(raw.decode("utf-8"))
|
|
283
|
+
except ValueError as err: # including UnicodeDecodeError
|
|
284
|
+
raise SourceError(f"{url} isn't a pyics index: {err}") from err
|
|
285
|
+
return parse_index(data, url), data
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _cache_file(source: Source) -> Path:
|
|
289
|
+
# the URL is hashed into the name, so the index of a source that is removed and
|
|
290
|
+
# added again with another URL isn't mistaken for its old one
|
|
291
|
+
digest = hashlib.sha256(source.url.encode()).hexdigest()[:16]
|
|
292
|
+
return cache_dir() / "indexes" / f"{source.name}-{digest}.json"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cache_index(source: Source, data: Any) -> None:
|
|
296
|
+
"""Keep the index ``data`` of ``source``, as fetched last."""
|
|
297
|
+
try:
|
|
298
|
+
_write_json(_cache_file(source), data)
|
|
299
|
+
except OSError:
|
|
300
|
+
pass # the cache is only a convenience
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cached_index(source: Source) -> Index | None:
|
|
304
|
+
"""The index of ``source`` as it was fetched last, if it is cached."""
|
|
305
|
+
try:
|
|
306
|
+
data = json.loads(_cache_file(source).read_text(encoding="utf-8"))
|
|
307
|
+
return parse_index(data, source.url)
|
|
308
|
+
except (OSError, ValueError, SourceError):
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class SourceIndex:
|
|
314
|
+
"""The index of a source, as far as it is known (see ``load_indexes``)."""
|
|
315
|
+
|
|
316
|
+
source: Source
|
|
317
|
+
index: Index | None
|
|
318
|
+
# why it couldn't be fetched, if it couldn't
|
|
319
|
+
error: str | None = None
|
|
320
|
+
# whether it is the cached index, since it couldn't be fetched
|
|
321
|
+
cached: bool = False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_indexes(
|
|
325
|
+
sources: Sequence[Source] | None = None, *, refresh: bool = True
|
|
326
|
+
) -> list[SourceIndex]:
|
|
327
|
+
"""
|
|
328
|
+
The indexes of ``sources`` (all sources by default): fetched anew if ``refresh``,
|
|
329
|
+
falling back to the cached ones of the sources that can't be fetched, or only the
|
|
330
|
+
cached ones otherwise.
|
|
331
|
+
"""
|
|
332
|
+
results: list[SourceIndex] = []
|
|
333
|
+
for source in load_sources() if sources is None else sources:
|
|
334
|
+
if not refresh:
|
|
335
|
+
results.append(SourceIndex(source, cached_index(source), cached=True))
|
|
336
|
+
continue
|
|
337
|
+
try:
|
|
338
|
+
index, data = fetch_index(source.url, allow_http=source.allow_http)
|
|
339
|
+
except SourceError as err:
|
|
340
|
+
cached = cached_index(source)
|
|
341
|
+
results.append(SourceIndex(source, cached, str(err), cached is not None))
|
|
342
|
+
continue
|
|
343
|
+
cache_index(source, data)
|
|
344
|
+
results.append(SourceIndex(source, index))
|
|
345
|
+
return results
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def prepare_source(
|
|
349
|
+
location: str, name: str | None = None, *, allow_http: bool = False
|
|
350
|
+
) -> tuple[Source, Index, Any]:
|
|
351
|
+
"""
|
|
352
|
+
Fetch and check the index at ``location`` (a URL or a path) to add it as a source,
|
|
353
|
+
named ``name``, or else after the index's name. Nothing is stored yet (see
|
|
354
|
+
``save_source``).
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
tuple[Source, Index, Any]
|
|
359
|
+
The source, its index, and the index's decoded JSON.
|
|
360
|
+
"""
|
|
361
|
+
url = to_url(location)
|
|
362
|
+
try:
|
|
363
|
+
check_url(url, allow_http=allow_http)
|
|
364
|
+
except FetchError as err:
|
|
365
|
+
raise SourceError(str(err)) from err
|
|
366
|
+
index, data = fetch_index(url, allow_http=allow_http)
|
|
367
|
+
|
|
368
|
+
if name is None:
|
|
369
|
+
name = slugify(index.name) or slugify(urllib.parse.urlsplit(url).hostname or "")
|
|
370
|
+
if not name:
|
|
371
|
+
raise SourceError("The index has no name: name the source with --name")
|
|
372
|
+
try:
|
|
373
|
+
check_slug(name, "source name")
|
|
374
|
+
except ValueError as err:
|
|
375
|
+
raise SourceError(str(err)) from err
|
|
376
|
+
for source in load_sources():
|
|
377
|
+
if source.url == url:
|
|
378
|
+
raise SourceError(f"{url} is added already, as {source.name!r}")
|
|
379
|
+
if source.name == name:
|
|
380
|
+
raise SourceError(
|
|
381
|
+
f"There is a source named {name!r} already ({source.url}): name this "
|
|
382
|
+
"one differently with --name"
|
|
383
|
+
)
|
|
384
|
+
return Source(name, url, allow_http), index, data
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def save_source(source: Source, data: Any) -> None:
|
|
388
|
+
"""Add ``source`` (see ``prepare_source``) after the user's other sources, and cache
|
|
389
|
+
its index ``data``."""
|
|
390
|
+
_write_user_sources([*load_sources(), source])
|
|
391
|
+
cache_index(source, data)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def remove_source(name: str) -> Source:
|
|
395
|
+
"""Remove the user's source ``name``, and its cached index."""
|
|
396
|
+
sources = load_sources()
|
|
397
|
+
for source in sources:
|
|
398
|
+
if source.name != name:
|
|
399
|
+
continue
|
|
400
|
+
if source.site is not None:
|
|
401
|
+
raise SourceError(
|
|
402
|
+
f"{name!r} is configured for all users of this computer, in "
|
|
403
|
+
f"{source.site}, so it can only be removed there"
|
|
404
|
+
)
|
|
405
|
+
_write_user_sources(s for s in sources if s is not source)
|
|
406
|
+
_cache_file(source).unlink(missing_ok=True)
|
|
407
|
+
return source
|
|
408
|
+
raise SourceError(f"There is no source named {name!r}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@dataclass(frozen=True)
|
|
412
|
+
class Resolved:
|
|
413
|
+
"""A course found in the sources (see ``resolve``)."""
|
|
414
|
+
|
|
415
|
+
source: Source
|
|
416
|
+
entry: CourseEntry
|
|
417
|
+
# problems with the sources that were looked in
|
|
418
|
+
warnings: list[str]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _warnings(indexes: Iterable[SourceIndex]) -> list[str]:
|
|
422
|
+
warnings = []
|
|
423
|
+
for result in indexes:
|
|
424
|
+
if result.error is None:
|
|
425
|
+
continue
|
|
426
|
+
note = "using its cached index" if result.cached else "skipping it"
|
|
427
|
+
warnings.append(f"{result.source.name}: {result.error} ({note})")
|
|
428
|
+
return warnings
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def resolve(spec: str) -> Resolved:
|
|
432
|
+
"""
|
|
433
|
+
Find the course ``spec`` in the sources: a slug, e.g. ``search-algorithms``, or a
|
|
434
|
+
slug in a source, e.g. ``uni-bamberg/search-algorithms``. The sources' indexes are
|
|
435
|
+
fetched anew (falling back to the cached ones).
|
|
436
|
+
|
|
437
|
+
Raises
|
|
438
|
+
------
|
|
439
|
+
SourceError
|
|
440
|
+
If no source, or more than one, lists it.
|
|
441
|
+
"""
|
|
442
|
+
source_name, _, slug = spec.rpartition("/")
|
|
443
|
+
sources = load_sources()
|
|
444
|
+
if not sources:
|
|
445
|
+
raise SourceError(
|
|
446
|
+
"You haven't added any sources: add one with: pyics add_source <url>"
|
|
447
|
+
)
|
|
448
|
+
if source_name:
|
|
449
|
+
sources = [s for s in sources if s.name == source_name]
|
|
450
|
+
if not sources:
|
|
451
|
+
raise SourceError(f"There is no source named {source_name!r}")
|
|
452
|
+
|
|
453
|
+
indexes = load_indexes(sources)
|
|
454
|
+
found = [
|
|
455
|
+
(result.source, result.index.courses[slug])
|
|
456
|
+
for result in indexes
|
|
457
|
+
if result.index is not None and slug in result.index.courses
|
|
458
|
+
]
|
|
459
|
+
warnings = _warnings(indexes)
|
|
460
|
+
if not found:
|
|
461
|
+
where = f"the source {source_name!r}" if source_name else "your sources"
|
|
462
|
+
message = f"There is no course {slug!r} in {where}"
|
|
463
|
+
if warnings:
|
|
464
|
+
message += " (" + "; ".join(warnings) + ")"
|
|
465
|
+
raise SourceError(message + ". Find courses with: pyics search")
|
|
466
|
+
if len(found) > 1:
|
|
467
|
+
options = ", ".join(f"{s.name}/{e.slug}" for s, e in found)
|
|
468
|
+
raise SourceError(
|
|
469
|
+
f"Several sources have a course {slug!r}: pull one of {options}"
|
|
470
|
+
)
|
|
471
|
+
source, entry = found[0]
|
|
472
|
+
return Resolved(source, entry, warnings)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def search(
|
|
476
|
+
term: str | None = None, *, refresh: bool = True
|
|
477
|
+
) -> tuple[list[tuple[Source, CourseEntry]], list[str]]:
|
|
478
|
+
"""
|
|
479
|
+
The courses in the sources that match ``term`` (see ``CourseEntry.matches``), or
|
|
480
|
+
all of them.
|
|
481
|
+
|
|
482
|
+
Returns
|
|
483
|
+
-------
|
|
484
|
+
tuple[list[tuple[Source, CourseEntry]], list[str]]
|
|
485
|
+
The courses, with their sources, and problems with the sources.
|
|
486
|
+
"""
|
|
487
|
+
indexes = load_indexes(refresh=refresh)
|
|
488
|
+
courses = [
|
|
489
|
+
(result.source, entry)
|
|
490
|
+
for result in indexes
|
|
491
|
+
if result.index is not None
|
|
492
|
+
for entry in result.index.courses.values()
|
|
493
|
+
if term is None or entry.matches(term)
|
|
494
|
+
]
|
|
495
|
+
return courses, _warnings(indexes)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def cached_courses() -> list[tuple[Source, CourseEntry]]:
|
|
499
|
+
"""The courses in the cached indexes of the sources, without fetching anything, e.g.
|
|
500
|
+
to offer them in the course selector."""
|
|
501
|
+
try:
|
|
502
|
+
courses, _ = search(refresh=False)
|
|
503
|
+
except SourceError: # e.g. an unreadable sources file
|
|
504
|
+
return []
|
|
505
|
+
return courses
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --- writing indexes ---
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def build_index(
|
|
512
|
+
directory: Path, out: Path | None = None, *, name: str | None = None
|
|
513
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
514
|
+
"""
|
|
515
|
+
Write an index of the course archives in ``directory`` and its subdirectories to
|
|
516
|
+
``out`` (``index.json`` in ``directory`` by default), linking to them relative to
|
|
517
|
+
it. Of several versions of a course, the newest is listed. The index is named
|
|
518
|
+
``name``, or keeps the name of the index it replaces, or else is named after the
|
|
519
|
+
directory.
|
|
520
|
+
|
|
521
|
+
Returns
|
|
522
|
+
-------
|
|
523
|
+
tuple[Path, dict[str, Any]]
|
|
524
|
+
The path the index was written to, and the index.
|
|
525
|
+
"""
|
|
526
|
+
directory = Path(directory).absolute()
|
|
527
|
+
out = Path(out).absolute() if out is not None else directory / INDEX_FILE
|
|
528
|
+
if name is None and out.is_file():
|
|
529
|
+
try:
|
|
530
|
+
name = json.loads(out.read_text(encoding="utf-8")).get("name")
|
|
531
|
+
except (OSError, ValueError, AttributeError):
|
|
532
|
+
name = None
|
|
533
|
+
name = name or directory.name
|
|
534
|
+
|
|
535
|
+
courses: dict[str, dict[str, Any]] = {}
|
|
536
|
+
for zip_path in sorted(directory.rglob("*.zip")):
|
|
537
|
+
try:
|
|
538
|
+
info = archive.read_archive(zip_path)
|
|
539
|
+
except archive.ArchiveError as err:
|
|
540
|
+
raise SourceError(f"{zip_path} isn't a course archive: {err}") from err
|
|
541
|
+
current = courses.get(info.slug)
|
|
542
|
+
if current is not None and not is_newer(info.version, current["version"]):
|
|
543
|
+
continue
|
|
544
|
+
rel = Path(os.path.relpath(zip_path, out.parent)).as_posix()
|
|
545
|
+
entry: dict[str, Any] = {"title": info.title}
|
|
546
|
+
if info.description:
|
|
547
|
+
entry["description"] = info.description
|
|
548
|
+
entry["version"] = info.version
|
|
549
|
+
if info.requires_pyics:
|
|
550
|
+
entry["requires_pyics"] = info.requires_pyics
|
|
551
|
+
if info.requires:
|
|
552
|
+
entry["requires"] = info.requires
|
|
553
|
+
entry["url"] = urllib.parse.quote(rel)
|
|
554
|
+
entry["sha256"] = hashlib.sha256(zip_path.read_bytes()).hexdigest()
|
|
555
|
+
if info.tags:
|
|
556
|
+
entry["tags"] = info.tags
|
|
557
|
+
courses[info.slug] = entry
|
|
558
|
+
|
|
559
|
+
index = {"pyics_index": INDEX_VERSION, "name": name, "courses": courses}
|
|
560
|
+
_write_json(out, index)
|
|
561
|
+
return out, index
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Jupyter notebook support: notebooks as exercise files, and their sections."""
|