neuromatch-course-kit 0.1.1__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.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: neuromatch-course-kit
3
+ Version: 0.1.1
4
+ Summary: Neuromatch course kit: notebook generation, content checks and site preparation for MyST courses
5
+ Project-URL: Homepage, https://github.com/neuromatch/course-kit
6
+ Project-URL: Issues, https://github.com/neuromatch/course-kit/issues
7
+ License-Expression: BSD-3-Clause
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: ipykernel
10
+ Requires-Dist: jupytext>=1.16
11
+ Requires-Dist: mystmd==1.11.0
12
+ Requires-Dist: nbclient
13
+ Requires-Dist: nbformat
14
+ Requires-Dist: pyyaml
15
+ Provides-Extra: test
16
+ Requires-Dist: ipywidgets; extra == 'test'
17
+ Requires-Dist: matplotlib; extra == 'test'
18
+ Requires-Dist: numpy; extra == 'test'
19
+ Requires-Dist: playwright; extra == 'test'
20
+ Requires-Dist: pytest; extra == 'test'
21
+ Requires-Dist: pytest-rerunfailures; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # neuromatch-course-kit
25
+
26
+ The `nma-kit` command line tool for Neuromatch MyST courses: notebook
27
+ generation, content checks and site preparation. See the
28
+ [course kit repository](https://github.com/neuromatch/course-kit) for docs.
@@ -0,0 +1,15 @@
1
+ nma_kit/__init__.py,sha256=5a4Jy2tMbpaFNiZHNTjEexm_fHtPEgQVUuD8SDIqvRQ,128
2
+ nma_kit/cells.py,sha256=-KbtcZOEzZSStJoz1gBUVt4wUk172b8HbJAlehf7BoU,962
3
+ nma_kit/cli.py,sha256=v6mz_a9UA9V8xpN7i6xVr2_If9A5bxxGKlcMzrjr2N4,4898
4
+ nma_kit/config.py,sha256=qyPOWFe9jlQPJTrUCWBqe7zkF5i4IrczHf3LKVtWX94,3284
5
+ nma_kit/convert.py,sha256=ZJ4jeT8lkG0FKkzfwsCrINgQN5pGaUMbG6ABDVvVZxc,17872
6
+ nma_kit/feedback.py,sha256=Mdc0-NjX5upBjmTfRF_vRofrVBHSMUUeBGKuSjLKhZM,8861
7
+ nma_kit/media.py,sha256=48iANcTXztZD-amd-ENDzrigr9Zv__VLkG6qejKW2Rc,8911
8
+ nma_kit/pin.py,sha256=7NFUF5P0FksiO5Z1xv6-Zce8q-f1qraV1_awDlbpryQ,715
9
+ nma_kit/publish.py,sha256=aydvCvnAysjtJp72eUhHnOx6Sq6BwkZ2-TZFncdq7lo,2651
10
+ nma_kit/scrolly.py,sha256=nFmQMBpzm_K1sceNgRJi9TQsajID7M50EqLHUKfHURs,18385
11
+ nma_kit/solutions.py,sha256=X4YCNkxm-iMnPfn3jkccqkBq6MHeHpmxY9uKtpU-bo8,16422
12
+ neuromatch_course_kit-0.1.1.dist-info/METADATA,sha256=SS5WzRBB8UBXgJWEnZ6tW5uI9UxcZ4_RsXyFo4fwYxc,1065
13
+ neuromatch_course_kit-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ neuromatch_course_kit-0.1.1.dist-info/entry_points.txt,sha256=xlXXMTh2H8cUiIUPt59sm32FGGwJBx6qiAx8LdGSFpY,45
15
+ neuromatch_course_kit-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nma-kit = nma_kit.cli:main
nma_kit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Neuromatch course kit: notebooks, content checks and site preparation."""
2
+
3
+ __version__ = "0.1.1" # x-release-please-version
nma_kit/cells.py ADDED
@@ -0,0 +1,28 @@
1
+ """Editable cells: the `runtime:` frontmatter key (Python side).
2
+
3
+ `runtime_of` has a JavaScript twin, `runtimeOf` in js/src/cells/src/model.mjs.
4
+ Both are tested against tests/fixtures/cells/runtime.json.
5
+ """
6
+
7
+ import re
8
+
9
+ from nma_kit import scrolly as nma_scrolly
10
+
11
+ RUNTIMES = ("browser", "colab")
12
+ RUNTIME_RE = re.compile(r"""^runtime:\s*(['"]?)([A-Za-z]+)\1\s*(#.*)?$""", re.M)
13
+
14
+
15
+ def runtime_of(text: str) -> str:
16
+ """'browser' (default) or 'colab' (or the raw invalid value, for validate())."""
17
+ m = RUNTIME_RE.search(nma_scrolly.frontmatter_text(text))
18
+ return m.group(2) if m else "browser"
19
+
20
+
21
+ def validate(text: str) -> list[str]:
22
+ errors = []
23
+ runtime = runtime_of(text)
24
+ if runtime not in RUNTIMES:
25
+ errors.append(f"runtime: {runtime!r} must be one of {', '.join(RUNTIMES)}")
26
+ if runtime == "colab" and nma_scrolly.is_scrolly(text):
27
+ errors.append("runtime: colab can't be combined with layout: scrolly")
28
+ return errors
nma_kit/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ """`nma-kit`: the course kit's command line. Run it from the course root.
2
+
3
+ nma-kit check validate content and myst.yml
4
+ nma-kit notebooks [--dry-run] generate student/instructor notebooks and solution files
5
+ nma-kit render-scrolly pre-render scrolly page outputs into _build/scrolly/
6
+ nma-kit prepare-site add badges + fallback cells to the .md pages and pin the
7
+ kit URL in myst.yml (CI only, never commit the result)
8
+ nma-kit publish-notebooks push notebooks/ to the notebooks branch
9
+
10
+ Exit codes: 0 ok, 1 content problems, 2 usage or configuration errors.
11
+ """
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from nma_kit import __version__
19
+ from nma_kit import convert, scrolly
20
+ from nma_kit.config import MYST_CONFIG, MissingConfig, read_settings
21
+ from nma_kit.pin import pin_kit_url
22
+ from nma_kit.publish import PublishError, publish_notebooks
23
+
24
+
25
+ class UsageError(Exception):
26
+ pass
27
+
28
+
29
+ def _validate(check_injected: bool) -> bool:
30
+ errors = convert.validate_content(check_injected=check_injected)
31
+ if errors:
32
+ print("Content rule violations:", file=sys.stderr)
33
+ for error in errors:
34
+ print(f" - {error}", file=sys.stderr)
35
+ return not errors
36
+
37
+
38
+ def _settings():
39
+ settings = read_settings()
40
+ try:
41
+ convert.require_github_repo(settings)
42
+ except ValueError as exc:
43
+ raise UsageError(str(exc)) from exc
44
+ return settings
45
+
46
+
47
+ def cmd_check(args) -> int:
48
+ read_settings()
49
+ if not _validate(check_injected=True):
50
+ return 1
51
+ print("No content problems found.")
52
+ return 0
53
+
54
+
55
+ def cmd_notebooks(args) -> int:
56
+ settings = _settings()
57
+ if not _validate(check_injected=True):
58
+ return 1
59
+ count = convert.process_tutorials(settings, args.dry_run, prepare_site=False)
60
+ print(f"Done. Processed {count} file(s).")
61
+ return 0
62
+
63
+
64
+ def cmd_render_scrolly(args) -> int:
65
+ return scrolly.render_all(read_settings().tutorials_dir)
66
+
67
+
68
+ def cmd_prepare_site(args) -> int:
69
+ settings = _settings()
70
+ if not _validate(check_injected=False):
71
+ return 1
72
+ count = convert.process_tutorials(settings, args.dry_run, prepare_site=True)
73
+ if not args.dry_run:
74
+ text = MYST_CONFIG.read_text(encoding="utf-8")
75
+ pinned = pin_kit_url(text, args.kit_version)
76
+ if pinned != text:
77
+ MYST_CONFIG.write_text(pinned, encoding="utf-8")
78
+ print(f"Pinned course kit URLs in {MYST_CONFIG} to {args.kit_version}")
79
+ print(f"Done. Prepared {count} page(s).")
80
+ return 0
81
+
82
+
83
+ def cmd_publish_notebooks(args) -> int:
84
+ settings = read_settings()
85
+ src = Path("notebooks")
86
+ if not src.is_dir():
87
+ raise UsageError("notebooks/ not found: run `nma-kit notebooks` first")
88
+ message = args.message or "chore: regenerate notebooks" + (
89
+ f" from {os.environ['GITHUB_SHA'][:12]}" if os.environ.get("GITHUB_SHA") else "")
90
+ pushed = publish_notebooks(src, settings.notebooks_branch, args.remote, message)
91
+ print(f"Pushed notebooks to {settings.notebooks_branch}." if pushed else "No notebook changes to push.")
92
+ return 0
93
+
94
+
95
+ def build_parser() -> argparse.ArgumentParser:
96
+ parser = argparse.ArgumentParser(prog="nma-kit", description="Neuromatch course kit")
97
+ parser.add_argument("--version", action="version", version=f"nma-kit {__version__}")
98
+ sub = parser.add_subparsers(dest="command")
99
+
100
+ sub.add_parser("check", help="validate content and myst.yml").set_defaults(func=cmd_check)
101
+
102
+ p = sub.add_parser("notebooks", help="generate notebooks and solution files")
103
+ p.add_argument("--dry-run", action="store_true")
104
+ p.set_defaults(func=cmd_notebooks)
105
+
106
+ sub.add_parser("render-scrolly", help="pre-render scrolly pages").set_defaults(func=cmd_render_scrolly)
107
+
108
+ p = sub.add_parser("prepare-site", help="prepare the .md pages for `myst build` (CI only)")
109
+ p.add_argument("--dry-run", action="store_true")
110
+ p.add_argument("--kit-version", default=__version__,
111
+ help="version to pin course-kit URLs in myst.yml to (default: this CLI's)")
112
+ p.set_defaults(func=cmd_prepare_site)
113
+
114
+ p = sub.add_parser("publish-notebooks", help="push notebooks/ to the notebooks branch")
115
+ p.add_argument("--remote", default="origin")
116
+ p.add_argument("--message", default=None)
117
+ p.set_defaults(func=cmd_publish_notebooks)
118
+ return parser
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = build_parser()
123
+ args = parser.parse_args(argv)
124
+ if not getattr(args, "func", None):
125
+ parser.print_usage(sys.stderr)
126
+ return 2
127
+ try:
128
+ return args.func(args)
129
+ except (MissingConfig, UsageError) as exc:
130
+ print(f"nma-kit: {exc}", file=sys.stderr)
131
+ return 2
132
+ except PublishError as exc:
133
+ print(f"nma-kit: {exc}", file=sys.stderr)
134
+ return 1
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
nma_kit/config.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ Course settings, read from the course's myst.yml.
3
+
4
+ Kit settings live in the top-level `nma:` block, as `section: {key: value}`
5
+ with string values. js/src/shared/config.mjs reads the same block with a
6
+ YAML parser too; both sides are tested against tests/fixtures/config/settings.json.
7
+
8
+ New settings (all optional, defaults are the template's conventions):
9
+
10
+ nma:
11
+ paths:
12
+ tutorials: tutorials # where the course's .md pages live
13
+ notebooks:
14
+ branch: notebooks-branch # where generated notebooks are published
15
+ """
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path, PurePosixPath
20
+
21
+ import yaml
22
+
23
+ DEFAULT_TUTORIALS_DIR = "tutorials"
24
+ DEFAULT_NOTEBOOKS_BRANCH = "notebooks-branch"
25
+ NOTEBOOKS_DIR = "notebooks"
26
+ MYST_CONFIG = Path("myst.yml")
27
+
28
+ GITHUB_RE = re.compile(r"github\.com/([^\s'\"]+?)/?$")
29
+
30
+
31
+ class MissingConfig(Exception):
32
+ """The course's myst.yml does not exist."""
33
+
34
+ def __init__(self, path: Path):
35
+ super().__init__(f"{path} not found: run nma-kit from the course root (the folder with myst.yml)")
36
+ self.path = path
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Settings:
41
+ github_repo: str | None
42
+ tutorials_dir: str = DEFAULT_TUTORIALS_DIR
43
+ notebooks_branch: str = DEFAULT_NOTEBOOKS_BRANCH
44
+ nma: dict = field(default_factory=dict)
45
+
46
+
47
+ def _yaml(text: str) -> dict:
48
+ try:
49
+ data = yaml.safe_load(text)
50
+ except yaml.YAMLError:
51
+ return {}
52
+ return data if isinstance(data, dict) else {}
53
+
54
+
55
+ def read_nma_block(text: str) -> dict:
56
+ """The top-level `nma:` block of myst.yml ({} if absent or not a mapping)."""
57
+ block = _yaml(text).get("nma")
58
+ return block if isinstance(block, dict) else {}
59
+
60
+
61
+ def _github_repo(data: dict) -> str | None:
62
+ project = data.get("project")
63
+ url = project.get("github") if isinstance(project, dict) else None
64
+ match = GITHUB_RE.search(url.strip()) if isinstance(url, str) else None
65
+ return match.group(1) if match else None
66
+
67
+
68
+ def _setting(nma: dict, section: str, key: str, default: str) -> str:
69
+ value = (nma.get(section) or {}).get(key) if isinstance(nma.get(section), dict) else None
70
+ return value if isinstance(value, str) and value.strip() else default
71
+
72
+
73
+ def load_settings(myst_text: str) -> Settings:
74
+ data = _yaml(myst_text)
75
+ nma = read_nma_block(myst_text)
76
+ return Settings(
77
+ github_repo=_github_repo(data),
78
+ tutorials_dir=_setting(nma, "paths", "tutorials", DEFAULT_TUTORIALS_DIR).strip("/"),
79
+ notebooks_branch=_setting(nma, "notebooks", "branch", DEFAULT_NOTEBOOKS_BRANCH),
80
+ nma=nma,
81
+ )
82
+
83
+
84
+ def read_settings(path: Path = MYST_CONFIG) -> Settings:
85
+ path = Path(path)
86
+ if not path.is_file():
87
+ raise MissingConfig(path)
88
+ return load_settings(path.read_text(encoding="utf-8"))
89
+
90
+
91
+ def page_location(settings: Settings, page: str | Path) -> tuple[str, str] | None:
92
+ """(rel_dir, stem) of a page path relative to the course root, or None if
93
+ it is outside the tutorials dir. 'tutorials/W1/T1.md' -> ('W1', 'T1')."""
94
+ path = PurePosixPath(Path(page).as_posix())
95
+ try:
96
+ rel = path.relative_to(settings.tutorials_dir)
97
+ except ValueError:
98
+ return None
99
+ rel_dir = rel.parent.as_posix()
100
+ return ("" if rel_dir == "." else rel_dir), rel.stem