composer-nb 0.1.0__tar.gz

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,6 @@
1
+ # Built by `npm run build -w dsl`; shipped in the wheel, not committed.
2
+ python/composer_nb/static/
3
+ /dist/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ *.egg-info/
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: composer-nb
3
+ Version: 0.1.0
4
+ Summary: A small language for sketching chord progressions, played inside Jupyter notebooks.
5
+ Project-URL: Homepage, https://github.com/pkhambat1/composer-nb
6
+ Project-URL: Issues, https://github.com/pkhambat1/composer-nb/issues
7
+ Author: Pezanne Khambatta
8
+ Keywords: chords,dsl,jupyter,music,notebook
9
+ Classifier: Framework :: Jupyter
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Artistic Software
13
+ Classifier: Topic :: Multimedia :: Sound/Audio
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: anywidget>=0.9
16
+ Requires-Dist: ipython>=7.23
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # composer-nb
22
+
23
+ A small language for chords and drums, played right inside Jupyter notebooks.
24
+
25
+ ```
26
+ %%music
27
+ time: 7/8
28
+ crash: 1|.|.
29
+ ride.bell: x..
30
+ kick: x..x...
31
+ snare: ....x..
32
+ ```
33
+
34
+ Each `%%music` cell renders to audio in the notebook, with a play button, a waveform, a drum grid, the chords it heard and any mistakes it found.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install composer-nb
40
+ ```
41
+
42
+ Then, in a notebook:
43
+
44
+ ```python
45
+ %load_ext composer_nb
46
+ ```
47
+
48
+ It works in JupyterLab, Jupyter Notebook 7, VS Code and Google Colab. Sounds are rendered in your browser, and the piano, guitar and drum samples load from the web, so you need an internet connection.
49
+
50
+ ## Cells that continue from each other
51
+
52
+ Give a cell a name, and later cells can start from the settings and words it ended with:
53
+
54
+ ```
55
+ %%music intro
56
+ tempo: 75
57
+ capo: 2
58
+ sound: guitar
59
+ Am E7|G D
60
+ ```
61
+
62
+ ```
63
+ %%music verse after intro
64
+ F C|Dm E7
65
+ ```
66
+
67
+ `verse` plays on guitar with a capo at fret 2, at 75 bpm, wherever it sits in the notebook and whenever you run it. If `intro` hasn't been run yet, you get an error saying so. A cell can still change anything it inherits: `tempo: 90` in `verse` speeds up just `verse`.
68
+
69
+ A cell with no name plays on its own and isn't saved.
70
+
71
+ ## From Python
72
+
73
+ `%%music` is a shortcut for `Song`, which you can use directly:
74
+
75
+ ```python
76
+ from composer_nb import Song
77
+
78
+ intro = Song("tempo: 75\nsound: guitar\nAm E7|G D", name="intro")
79
+ verse = Song("F C|Dm E7", after=intro, name="verse")
80
+ verse # shows the player
81
+ ```
82
+
83
+ Songs are ordinary Python values, so you can generate them with loops and functions:
84
+
85
+ ```python
86
+ blues = "|".join(["A7", "D7", "A7", "A7", "D7", "D7", "A7", "A7", "E7", "D7", "A7", "E7"])
87
+ Song("tempo: 120\n" + blues)
88
+ ```
89
+
90
+ ## The language
91
+
92
+ Every line is a setting, an instrument and what it plays, a line of chords, a word you can reuse, or a `--` comment. Lines next to each other play together; a blank line starts the next block, which plays after it.
93
+
94
+ ### Settings
95
+
96
+ | Setting | Default | Meaning |
97
+ | -------- | --------- | ----------------------------------------------------------------------------- |
98
+ | `time` | `4/4` | `4/4`, `7/8`, or added-up like `(3+4)/4` |
99
+ | `tempo` | `120` | Quarter notes per minute |
100
+ | `sound` | `piano` | What chord lines play on: `piano`, `epiano`, `organ`, `pad`, `bass`, `guitar` |
101
+ | `capo` | `0` | Capo fret, 0 to 12. Only for guitar |
102
+ | `key` | `C` | Key for Roman numeral chords |
103
+ | `step` | `1/16` | Length of each step in a drum loop |
104
+ | `bars` | automatic | How many bars this block plays |
105
+ | `octave` | `3` | Octave chords are voiced in |
106
+ | `kit` | `rock` | Drum sound: `rock` or `synth` |
107
+
108
+ Settings carry on into the blocks and cells below.
109
+
110
+ ### Chords
111
+
112
+ ```
113
+ sound: guitar
114
+ Am F|C G
115
+ C . . G|Am . F G|%|F . _ .
116
+ ```
117
+
118
+ `|` is a bar line and the chords in a bar split it evenly. `.` holds the chord before for another slot, `_` is silence and `%` repeats the bar before. Chord lines next to each other carry on from one another. Chords can be names (`Cmaj7`, `F#m`, `Bb7`, `C/E`) or Roman numerals that follow `key` (`I vi IV V7`). To layer a second instrument, start its line with its name: `bass: C A|F G`.
119
+
120
+ ### Drums
121
+
122
+ `kick`, `snare`, `hat`, `ride`, `crash`, `tom` and `floor` each get their own line, played either on beats or as a loop. Some have a second sound with a line of its own: `ride.bell`, `hat.open` and `hat.pedal`.
123
+
124
+ ```
125
+ kick: 1 3
126
+ snare: 2 4 accent
127
+ crash: 1|.|.
128
+ hat: X.x.X.x.
129
+ ride.bell: x..
130
+ ```
131
+
132
+ Beats are counted `1 e & a 2 e & a`, and `|` separates bars (`.` is an empty bar). A loop is a row of steps that repeats on its own: `x` hit, `X` accent, `g` ghost, `d` double, `.` rest. Every line in a block repeats until they all line up again.
133
+
134
+ ### Repeats
135
+
136
+ ```
137
+ repeat 2:
138
+ repeat 3:
139
+ kick: x..x...
140
+ crash: 1
141
+ snare: 2 4
142
+ ```
143
+
144
+ `repeat 2:` plays the lines indented under it twice. It ends at the first line that isn't indented under it, and repeats nest.
145
+
146
+ ### Words
147
+
148
+ ```
149
+ pair = X.x.
150
+ verse = Am E7|G D
151
+ hat: pair pair
152
+ verse verse
153
+ ```
154
+
155
+ ## Playground
156
+
157
+ The same language runs in the composer-nb playground, a notebook-style web app. Its source is in the [`playground/`](https://github.com/pkhambat1/composer-nb/tree/main/playground) folder of the repository.
158
+
159
+ ## Developing
160
+
161
+ This package lives in the `dsl/` folder of [the repository](https://github.com/pkhambat1/composer-nb). The parser and audio engine are JavaScript (`dsl/js/`), and the Python side (`dsl/python/composer_nb/`) passes cell sources to them through an [anywidget](https://anywidget.dev). Build the widget before installing:
162
+
163
+ ```bash
164
+ npm install
165
+ npm run build -w dsl
166
+ pip install -e "dsl[test]"
167
+ pytest dsl/tests/python
168
+ ```
@@ -0,0 +1,148 @@
1
+ # composer-nb
2
+
3
+ A small language for chords and drums, played right inside Jupyter notebooks.
4
+
5
+ ```
6
+ %%music
7
+ time: 7/8
8
+ crash: 1|.|.
9
+ ride.bell: x..
10
+ kick: x..x...
11
+ snare: ....x..
12
+ ```
13
+
14
+ Each `%%music` cell renders to audio in the notebook, with a play button, a waveform, a drum grid, the chords it heard and any mistakes it found.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install composer-nb
20
+ ```
21
+
22
+ Then, in a notebook:
23
+
24
+ ```python
25
+ %load_ext composer_nb
26
+ ```
27
+
28
+ It works in JupyterLab, Jupyter Notebook 7, VS Code and Google Colab. Sounds are rendered in your browser, and the piano, guitar and drum samples load from the web, so you need an internet connection.
29
+
30
+ ## Cells that continue from each other
31
+
32
+ Give a cell a name, and later cells can start from the settings and words it ended with:
33
+
34
+ ```
35
+ %%music intro
36
+ tempo: 75
37
+ capo: 2
38
+ sound: guitar
39
+ Am E7|G D
40
+ ```
41
+
42
+ ```
43
+ %%music verse after intro
44
+ F C|Dm E7
45
+ ```
46
+
47
+ `verse` plays on guitar with a capo at fret 2, at 75 bpm, wherever it sits in the notebook and whenever you run it. If `intro` hasn't been run yet, you get an error saying so. A cell can still change anything it inherits: `tempo: 90` in `verse` speeds up just `verse`.
48
+
49
+ A cell with no name plays on its own and isn't saved.
50
+
51
+ ## From Python
52
+
53
+ `%%music` is a shortcut for `Song`, which you can use directly:
54
+
55
+ ```python
56
+ from composer_nb import Song
57
+
58
+ intro = Song("tempo: 75\nsound: guitar\nAm E7|G D", name="intro")
59
+ verse = Song("F C|Dm E7", after=intro, name="verse")
60
+ verse # shows the player
61
+ ```
62
+
63
+ Songs are ordinary Python values, so you can generate them with loops and functions:
64
+
65
+ ```python
66
+ blues = "|".join(["A7", "D7", "A7", "A7", "D7", "D7", "A7", "A7", "E7", "D7", "A7", "E7"])
67
+ Song("tempo: 120\n" + blues)
68
+ ```
69
+
70
+ ## The language
71
+
72
+ Every line is a setting, an instrument and what it plays, a line of chords, a word you can reuse, or a `--` comment. Lines next to each other play together; a blank line starts the next block, which plays after it.
73
+
74
+ ### Settings
75
+
76
+ | Setting | Default | Meaning |
77
+ | -------- | --------- | ----------------------------------------------------------------------------- |
78
+ | `time` | `4/4` | `4/4`, `7/8`, or added-up like `(3+4)/4` |
79
+ | `tempo` | `120` | Quarter notes per minute |
80
+ | `sound` | `piano` | What chord lines play on: `piano`, `epiano`, `organ`, `pad`, `bass`, `guitar` |
81
+ | `capo` | `0` | Capo fret, 0 to 12. Only for guitar |
82
+ | `key` | `C` | Key for Roman numeral chords |
83
+ | `step` | `1/16` | Length of each step in a drum loop |
84
+ | `bars` | automatic | How many bars this block plays |
85
+ | `octave` | `3` | Octave chords are voiced in |
86
+ | `kit` | `rock` | Drum sound: `rock` or `synth` |
87
+
88
+ Settings carry on into the blocks and cells below.
89
+
90
+ ### Chords
91
+
92
+ ```
93
+ sound: guitar
94
+ Am F|C G
95
+ C . . G|Am . F G|%|F . _ .
96
+ ```
97
+
98
+ `|` is a bar line and the chords in a bar split it evenly. `.` holds the chord before for another slot, `_` is silence and `%` repeats the bar before. Chord lines next to each other carry on from one another. Chords can be names (`Cmaj7`, `F#m`, `Bb7`, `C/E`) or Roman numerals that follow `key` (`I vi IV V7`). To layer a second instrument, start its line with its name: `bass: C A|F G`.
99
+
100
+ ### Drums
101
+
102
+ `kick`, `snare`, `hat`, `ride`, `crash`, `tom` and `floor` each get their own line, played either on beats or as a loop. Some have a second sound with a line of its own: `ride.bell`, `hat.open` and `hat.pedal`.
103
+
104
+ ```
105
+ kick: 1 3
106
+ snare: 2 4 accent
107
+ crash: 1|.|.
108
+ hat: X.x.X.x.
109
+ ride.bell: x..
110
+ ```
111
+
112
+ Beats are counted `1 e & a 2 e & a`, and `|` separates bars (`.` is an empty bar). A loop is a row of steps that repeats on its own: `x` hit, `X` accent, `g` ghost, `d` double, `.` rest. Every line in a block repeats until they all line up again.
113
+
114
+ ### Repeats
115
+
116
+ ```
117
+ repeat 2:
118
+ repeat 3:
119
+ kick: x..x...
120
+ crash: 1
121
+ snare: 2 4
122
+ ```
123
+
124
+ `repeat 2:` plays the lines indented under it twice. It ends at the first line that isn't indented under it, and repeats nest.
125
+
126
+ ### Words
127
+
128
+ ```
129
+ pair = X.x.
130
+ verse = Am E7|G D
131
+ hat: pair pair
132
+ verse verse
133
+ ```
134
+
135
+ ## Playground
136
+
137
+ The same language runs in the composer-nb playground, a notebook-style web app. Its source is in the [`playground/`](https://github.com/pkhambat1/composer-nb/tree/main/playground) folder of the repository.
138
+
139
+ ## Developing
140
+
141
+ This package lives in the `dsl/` folder of [the repository](https://github.com/pkhambat1/composer-nb). The parser and audio engine are JavaScript (`dsl/js/`), and the Python side (`dsl/python/composer_nb/`) passes cell sources to them through an [anywidget](https://anywidget.dev). Build the widget before installing:
142
+
143
+ ```bash
144
+ npm install
145
+ npm run build -w dsl
146
+ pip install -e "dsl[test]"
147
+ pytest dsl/tests/python
148
+ ```
@@ -0,0 +1,21 @@
1
+ """Stop a build that would ship without the widget's JavaScript.
2
+
3
+ The widget is compiled by `npm run build -w dsl` into python/composer_nb/static/,
4
+ which git ignores. Without this check, forgetting that step produces a wheel
5
+ that installs fine but shows nothing in the notebook.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
11
+
12
+
13
+ class CustomBuildHook(BuildHookInterface):
14
+ def initialize(self, version, build_data):
15
+ static = Path(self.root) / "python" / "composer_nb" / "static"
16
+ missing = [name for name in ("widget.js", "widget.css") if not (static / name).is_file()]
17
+ if missing:
18
+ raise RuntimeError(
19
+ f"{', '.join(missing)} missing from {static}. "
20
+ "Build the widget first: npm run build -w dsl"
21
+ )
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "composer-nb"
7
+ version = "0.1.0"
8
+ description = "A small language for sketching chord progressions, played inside Jupyter notebooks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Pezanne Khambatta" }]
12
+ keywords = ["music", "chords", "jupyter", "notebook", "dsl"]
13
+ classifiers = [
14
+ "Framework :: Jupyter",
15
+ "Intended Audience :: Education",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Artistic Software",
18
+ "Topic :: Multimedia :: Sound/Audio",
19
+ ]
20
+ dependencies = [
21
+ "anywidget>=0.9",
22
+ "ipython>=7.23",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=7"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/pkhambat1/composer-nb"
30
+ Issues = "https://github.com/pkhambat1/composer-nb/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["python/composer_nb"]
34
+ artifacts = ["python/composer_nb/static/*"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["python", "README.md", "hatch_build.py"]
38
+ artifacts = ["python/composer_nb/static/*"]
39
+
40
+ # Refuses to build without the compiled widget; see hatch_build.py.
41
+ [tool.hatch.build.hooks.custom]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests/python"]
@@ -0,0 +1,38 @@
1
+ """composer-nb: a small language for chords and drums, played inside notebooks.
2
+
3
+ In Jupyter, load the cell magic and write music in `%%music` cells:
4
+
5
+ %load_ext composer_nb
6
+
7
+ %%music intro
8
+ tempo 75
9
+ sound guitar
10
+ play {
11
+ chords: Am E7|G D
12
+ kick: loop x-------
13
+ snare: loop ----x---
14
+ }
15
+
16
+ Or build songs from Python with `Song`.
17
+ """
18
+
19
+ from importlib.metadata import PackageNotFoundError, version
20
+
21
+ from .song import Song
22
+ from .widget import MusicWidget
23
+
24
+ try:
25
+ __version__ = version("composer-nb")
26
+ except PackageNotFoundError: # running from a source checkout that isn't installed
27
+ __version__ = "0.0.0"
28
+
29
+ __all__ = ["MusicWidget", "Song", "__version__"]
30
+
31
+
32
+ def load_ipython_extension(ipython):
33
+ """Called by `%load_ext composer_nb`; registers the `%%music` cell magic and its Tab completion."""
34
+ from .completer import register
35
+ from .magic import MusicMagics
36
+
37
+ ipython.register_magics(MusicMagics)
38
+ register(ipython)
@@ -0,0 +1,148 @@
1
+ """Tab completion inside `%%music` cells.
2
+
3
+ Jupyter asks the kernel what could come next, and IPython only knows Python, so the
4
+ extension adds a matcher of its own. The word lists mirror dsl/js/language.js;
5
+ tests/python/test_completer.py fails if the two drift apart.
6
+ """
7
+
8
+ import re
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from .song import Song
12
+
13
+ DRUMS = ["crash", "ride", "hat", "tom", "floor", "snare", "kick"]
14
+ VARIATIONS = {"ride": ["bell"], "hat": ["open", "pedal"]}
15
+ INSTRUMENTS = ["piano", "epiano", "organ", "pad", "bass", "guitar"]
16
+ SETTINGS = ["time", "tempo", "step", "sound", "key", "capo", "octave", "kit"]
17
+ MODIFIERS = ["accent", "ghost", "double"]
18
+ KITS = ["rock", "synth"]
19
+
20
+ LANES = [lane for d in DRUMS for lane in [d, *(f"{d}.{v}" for v in VARIATIONS.get(d, []))]]
21
+ PARTS = ["chords", *LANES]
22
+ SETTING_VALUES = {"sound": INSTRUMENTS, "kit": KITS}
23
+
24
+ MAGIC = "%%music"
25
+ WORD_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s*=", re.M)
26
+ PATTERN_RE = re.compile(r"\bpattern\s+([A-Za-z_]\w*)")
27
+ # The instrument line the cursor is on: its name, then what's been written after the colon.
28
+ PART_RE = re.compile(r"(?:^|[\s{])([a-z]+(?:\.[a-z]+)?):([^:{}]*)$")
29
+ NUMBER_RE = re.compile(r"^\d+$|\)$")
30
+
31
+ Option = Tuple[str, str] # the text to insert and its kind, which picks the icon
32
+
33
+
34
+ def _options(texts, kind) -> List[Option]:
35
+ return [(t, kind) for t in texts]
36
+
37
+
38
+ def _magic_line(line: str, songs: Dict[str, Song]) -> Optional[Tuple[str, List[Option]]]:
39
+ """Completions for the `%%music [name] [after <name>]` line itself."""
40
+ if not line.startswith(MAGIC + " "):
41
+ return None # still typing the magic's name, which IPython completes
42
+ fragment = re.search(r"\w*$", line).group()
43
+ words = line[len(MAGIC) : len(line) - len(fragment)].split()
44
+ if words and words[-1] == "after":
45
+ return fragment, _options(sorted(songs), "variable")
46
+ if len(words) == 1:
47
+ return fragment, [("after ", "keyword")]
48
+ return fragment, []
49
+
50
+
51
+ def _statement(head: str, body: str, patterns: List[str]) -> List[Option]:
52
+ """What can come next on a line that isn't an instrument's."""
53
+ inside = (body + head).count("{") > (body + head).count("}")
54
+ words = re.split(r"[{}]", head)[-1].split()
55
+ names = _options(patterns, "variable")
56
+ parts = _options([p + ": " for p in PARTS], "property")
57
+ if not words:
58
+ settings = _options([s + " " for s in SETTINGS], "keyword")
59
+ if inside:
60
+ return parts + names + settings
61
+ return settings + [("pattern ", "keyword"), ("play ", "keyword")]
62
+ first, last = words[0], words[-1]
63
+ if first in ("play", "pattern") and len(words) > 1 and NUMBER_RE.search(last):
64
+ return _options(["bars ", "bar "], "keyword")
65
+ if first == "play":
66
+ return names + (parts if len(words) == 1 else [])
67
+ if first == "time" and len(words) > 1 and "over" not in words:
68
+ return [("over ", "keyword")]
69
+ if first in SETTING_VALUES and len(words) == 1:
70
+ return _options(SETTING_VALUES[first], "value")
71
+ if inside and first not in SETTINGS and first != "pattern":
72
+ return names # a line that plays patterns in order: intro verse * 2
73
+ return []
74
+
75
+
76
+ def complete(text: str, cursor: int, songs: Optional[Dict[str, Song]] = None):
77
+ """Completions at `cursor` in a `%%music` cell (`text` includes the magic line).
78
+
79
+ Returns (fragment, options): the part of a word already typed, and what could
80
+ replace it. None means the cursor isn't somewhere this language applies.
81
+ """
82
+ songs = songs or {}
83
+ before = text[:cursor]
84
+ start = before.rfind("\n") + 1
85
+ line = before[start:]
86
+ if start == 0:
87
+ return _magic_line(line, songs)
88
+ if "//" in line:
89
+ return "", []
90
+ fragment = re.search(r"[A-Za-z_]\w*$|$", line).group()
91
+ head = line[: len(line) - len(fragment)]
92
+ magic_line, _, body = before[:start].partition("\n")
93
+
94
+ # Names defined above the cursor, in this cell or the cells it continues from.
95
+ after = re.search(r"\bafter\s+(\w+)", magic_line)
96
+ inherited = songs[after.group(1)].chain if after and after.group(1) in songs else []
97
+ defined = "\n".join([*inherited, body])
98
+ words = sorted(set(WORD_RE.findall(defined)))
99
+ patterns = sorted(set(PATTERN_RE.findall(defined)))
100
+
101
+ variation = re.search(r"([a-z]+)\.$", head)
102
+ part = PART_RE.search(head)
103
+ if variation: # ride. -> ride.bell
104
+ options = _options([v + ": " for v in VARIATIONS.get(variation.group(1), [])], "property")
105
+ elif part and part.group(1) in PARTS:
106
+ written = part.group(2).split()
107
+ options = _options(words, "variable")
108
+ if not written:
109
+ options = [("loop ", "keyword")] + options
110
+ elif part.group(1) != "chords" and any(w[0].isdigit() for w in written):
111
+ options = _options(MODIFIERS, "keyword") # beats take accent, ghost, double
112
+ else:
113
+ options = _statement(head, body, patterns)
114
+ return fragment, [o for o in options if o[0].startswith(fragment) and o[0] != fragment]
115
+
116
+
117
+ def register(shell) -> None:
118
+ """Add the `%%music` matcher to `shell`'s completer, once."""
119
+ try:
120
+ from IPython.core.completer import SimpleCompletion, context_matcher
121
+ except ImportError: # IPython before 8.6 has no matcher API
122
+ return
123
+ identifier = "composer_nb.music_matcher"
124
+ matchers = shell.Completer.custom_matchers
125
+ if any(getattr(m, "matcher_identifier", None) == identifier for m in matchers):
126
+ return
127
+
128
+ @context_matcher(identifier=identifier)
129
+ def music_matcher(context):
130
+ text = context.full_text
131
+ result = None
132
+ if text.startswith(MAGIC):
133
+ lines = text.split("\n")
134
+ cursor = sum(len(l) + 1 for l in lines[: context.cursor_line]) + context.cursor_position
135
+ songs = {k: v for k, v in shell.user_ns.items() if isinstance(v, Song)}
136
+ result = complete(text, cursor, songs)
137
+ if result is None:
138
+ return {"completions": [], "suppress": False}
139
+ fragment, options = result
140
+ return {
141
+ "completions": [SimpleCompletion(text=t, type=kind) for t, kind in options],
142
+ "matched_fragment": fragment,
143
+ # Python names mean nothing in a music cell, so IPython's own matchers stay out.
144
+ # (IPython only honours this when there's something to offer.)
145
+ "suppress": True,
146
+ }
147
+
148
+ matchers.append(music_matcher)
@@ -0,0 +1,53 @@
1
+ import keyword
2
+ from typing import Optional, Tuple
3
+
4
+ from IPython.core.error import UsageError
5
+ from IPython.core.magic import Magics, cell_magic, magics_class
6
+
7
+ from .song import Song
8
+
9
+ USAGE = "Usage: %%music [name] [after <name>]"
10
+
11
+
12
+ def parse_magic_line(line: str) -> Tuple[Optional[str], Optional[str]]:
13
+ """Split the `%%music` line into (name, after)."""
14
+ words = line.split()
15
+ name = after = None
16
+ if words and words[0] != "after":
17
+ name = words.pop(0)
18
+ if words:
19
+ if words[0] != "after" or len(words) != 2:
20
+ raise UsageError(USAGE)
21
+ after = words[1]
22
+ for word in (name, after):
23
+ if word is not None and (not word.isidentifier() or keyword.iskeyword(word)):
24
+ raise UsageError(
25
+ f"{word!r} can't be a cell name. Use letters, digits and underscores, like intro or verse_2."
26
+ )
27
+ if name is not None and name == after:
28
+ raise UsageError(f"{name} can't continue from itself.")
29
+ return name, after
30
+
31
+
32
+ @magics_class
33
+ class MusicMagics(Magics):
34
+ @cell_magic
35
+ def music(self, line, cell):
36
+ """Play a cell of composer-nb source.
37
+
38
+ %%music play this cell on its own
39
+ %%music intro ...and save it as `intro`
40
+ %%music verse after intro start from the settings and definitions `intro` ended with
41
+ """
42
+ name, after_name = parse_magic_line(line)
43
+ after = None
44
+ if after_name is not None:
45
+ if after_name not in self.shell.user_ns:
46
+ raise UsageError(f"{after_name} hasn't been run yet. Run its cell first.")
47
+ after = self.shell.user_ns[after_name]
48
+ if not isinstance(after, Song):
49
+ raise UsageError(f"{after_name} is a {type(after).__name__}, not a %%music cell.")
50
+ song = Song(cell, after=after, name=name)
51
+ if name is not None:
52
+ self.shell.user_ns[name] = song
53
+ return song