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,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."""