diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1d762..edd5148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - name: Run the example notebooks run: | - cd examples + cd docs/examples for f in *.ipynb; do jupyter nbconvert --to notebook --execute $f || exit 1 done diff --git a/.gitignore b/.gitignore index d749ce2..3f077ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ pyabc2/sources/_* !pyabc2/sources/__init__.py poetry.lock venv*/ +docs/api/ # Byte-compiled / optimized / DLL files diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..5f7fae7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.11" + +python: + install: + - method: pip + path: . + extra_requirements: + - doc + +sphinx: + configuration: docs/conf.py diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..24de837 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,58 @@ +=== +API +=== + +.. currentmodule:: pyabc2 + +Note types +========== + +.. autosummary:: + :toctree: api/ + + PitchClass + Pitch + Note + +Alternative initializers: + +.. autosummary:: + :toctree: api/ + + PitchClass.from_name + PitchClass.from_pitch + + Pitch.from_name + Pitch.from_class_name + Pitch.from_class_value + Pitch.from_etf + Pitch.from_helmholtz + Pitch.from_pitch_class + + Note.from_abc + Note.from_pitch + +Key type +======== + +.. autosummary:: + :toctree: api/ + + Key + Key.parse_key + +Tune type +========= + +.. autosummary:: + :toctree: api/ + + Tune + + Tune.abc + Tune.header + Tune.title + Tune.type + Tune.key + Tune.url + Tune.measures diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..166c6a2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,37 @@ +import pyabc2 + +project = "PyABC2" +copyright = "2021\u20132025 zmoon" +author = "zmoon" + +version = pyabc2.__version__.split("+")[0] +release = pyabc2.__version__ + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "myst_nb", +] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "pandas": ("https://pandas.pydata.org/docs/", None), +} + +exclude_patterns = ["_build"] + +html_theme = "furo" + +napoleon_google_docstring = False +napoleon_numpy_docstring = True +napoleon_preprocess_types = True +napoleon_use_param = True +napoleon_use_rtype = False + +autodoc_typehints = "description" +autosummary_generate = True + +nb_execution_raise_on_error = True diff --git a/examples/modes.ipynb b/docs/examples/modes.ipynb similarity index 99% rename from examples/modes.ipynb rename to docs/examples/modes.ipynb index 20bdd7b..d2c962e 100644 --- a/examples/modes.ipynb +++ b/docs/examples/modes.ipynb @@ -5,6 +5,8 @@ "id": "0", "metadata": {}, "source": [ + "# Modes\n", + "\n", "Check the modes." ] }, diff --git a/examples/sources.ipynb b/docs/examples/sources.ipynb similarity index 92% rename from examples/sources.ipynb rename to docs/examples/sources.ipynb index 158a71f..1ae2603 100644 --- a/examples/sources.ipynb +++ b/docs/examples/sources.ipynb @@ -1,9 +1,17 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tune sources" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "0", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -12,7 +20,7 @@ }, { "cell_type": "markdown", - "id": "1", + "id": "2", "metadata": {}, "source": [ "## Norbeck" @@ -21,7 +29,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -31,7 +39,7 @@ }, { "cell_type": "markdown", - "id": "3", + "id": "4", "metadata": {}, "source": [ "## The Session" @@ -40,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -51,7 +59,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "6", "metadata": {}, "outputs": [], "source": [ diff --git a/examples/types.ipynb b/docs/examples/types.ipynb similarity index 72% rename from examples/types.ipynb rename to docs/examples/types.ipynb index 5e9e4c4..31bb4bf 100644 --- a/examples/types.ipynb +++ b/docs/examples/types.ipynb @@ -5,6 +5,8 @@ "id": "0", "metadata": {}, "source": [ + "# Types and reprs\n", + "\n", "Checking the Jupyter reprs." ] }, @@ -19,10 +21,18 @@ "from pyabc2.sources import load_example_abc" ] }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Pitch class" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -32,7 +42,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -43,17 +53,25 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "5", "metadata": {}, "outputs": [], "source": [ "Fb.equivalent_sharp" ] }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Pitch" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -63,17 +81,25 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "8", "metadata": {}, "outputs": [], "source": [ "pyabc2.Pitch.from_name(\"Fb4\")" ] }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Note" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -83,7 +109,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -93,17 +119,48 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "12", "metadata": {}, "outputs": [], "source": [ "pyabc2.Note.from_abc(\"D,,3/\")" ] }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## Interval" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "p = pyabc2.Pitch.from_name(\"D3\")\n", + "\n", + "for dv in range(-14, 32):\n", + " p_ = pyabc2.Pitch(p.value + dv)\n", + " i = p_ - p\n", + " print(p.unicode(), \"→\", p_.unicode(), \"\\t\", i)" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## Tune" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -115,7 +172,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "17", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9a5883b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +# PyABC2 + +```{module} pyabc2 + +``` + +![image](https://user-images.githubusercontent.com/15079414/195207144-83df651a-6fe9-44b1-b7bc-e4aced14a2aa.png) + +## Getting started + +Install from PyPI: +``` +pip install pyabc2 +``` +Then look at the [example notebooks](examples/types.ipynb). + +To contribute to this project, see the [instructions for developers](dev.md). + +## Credits + +Inspired in part by and some portions based on [PyABC](https://github.com/campagnola/pyabc) (`pyabc`; [MIT License](https://github.com/campagnola/pyabc/blob/master/LICENSE.txt)), hence "PyABC2" and the package name `pyabc2`. No relation to [this pyabc](https://github.com/icb-dcm/pyabc) that is on PyPI. + +```{toctree} +:caption: Examples +:hidden: + +examples/types.ipynb +examples/modes.ipynb +examples/sources.ipynb +``` + +```{toctree} +:caption: Reference +:hidden: + +api.rst +dev.md +GitHub +``` diff --git a/examples/intervals.ipynb b/examples/intervals.ipynb deleted file mode 100644 index 89145c5..0000000 --- a/examples/intervals.ipynb +++ /dev/null @@ -1,57 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "Checking the interval string representations." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "from pyabc2 import Pitch" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "p = Pitch.from_name(\"D3\")\n", - "\n", - "for dv in range(-14, 32):\n", - " p_ = Pitch(p.value + dv)\n", - " i = p_ - p\n", - " print(p.unicode(), \"→\", p_.unicode(), \"\\t\", i)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pyabc2/key.py b/pyabc2/key.py index f7eb8ae..39c9399 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -230,7 +230,13 @@ def _scale_intervals( class Key: - """Key, including mode.""" + """Key, including mode. + + Attributes + ---------- + tonic : PitchClass + Tonic of the key. + """ # TODO: maybe should move name to a .from_name for consistency with Pitch(Class) def __init__( @@ -241,15 +247,18 @@ def __init__( mode: Optional[str] = None, ): """ + Pass either `name` (key spec with tonic and mode combined) + or `tonic` and `mode`. + Parameters --------- name - Key name, e.g., `D`, `Ador`, `Bbmin`, ... + Key name (e.g., ``D``, ``Amaj``, ``Em``, ``Ador``, ``Bbmin``, ...). + (Major assumed if mode not specified.) tonic - Tonic of the key, e.g., `C`, `D`, ... + Tonic of the key (e.g., ``C``, ``D``, ...). mode - Mode specification, e.g., `m`, `min`, `dor`. - (Major assumed if mode not specified.) + Mode specification, (e.g., ``maj``, ``min``, ``dor``, ...). """ if name is not None: assert tonic is None and mode is None, "pass either `name` or `tonic`+`mode`" @@ -258,16 +267,23 @@ def __init__( name = "C" self.tonic, self._mode = Key.parse_key(name) else: + # TODO: default mode to major for consistency? assert tonic is not None and mode is not None, "pass either `name` or `tonic`+`mode`" self.tonic = PitchClass.from_name(tonic) self._mode = _validate_and_normalize_mode_name(mode) @property def mode(self) -> str: + """Full mode name (e.g., ``Major``).""" return _MODE_ABBR_TO_FULL[self._mode].capitalize() @staticmethod def parse_key(key: str) -> Tuple[PitchClass, str]: + """Parse a key spec string (e.g., ``D``, ``Amin``) + and return the tonic and mode (3-char abbreviation). + + Used when you pass ``name`` to the constructor. + """ m = re.match(r"([A-G])(\#|b)?\s*(\w+)?(.*)", key) if m is None: raise ValueError(f"Invalid key specification '{key}'") diff --git a/pyabc2/note.py b/pyabc2/note.py index 7723354..908b2a0 100644 --- a/pyabc2/note.py +++ b/pyabc2/note.py @@ -58,9 +58,18 @@ def _octave_from_abc_parts(note: str, oct: Optional[str] = None, *, base: int = class Note(Pitch): - """A note has a pitch and a duration.""" + """A note has a pitch and a duration. + + Parameters + ---------- + value + Chromatic note value relative to C0. + duration + The duration of the note, e.g. ``1/8`` for an eighth note. + """ def __init__(self, value: int, duration: Fraction = _DEFAULT_UNIT_DURATION): + # TODO: accept string duration as well and convert to Fraction? super().__init__(value) self.duration = duration @@ -90,6 +99,10 @@ def _repr_html_(self): # TODO: ties or adding multiples return f"{p}({nd1}{_DURATION_FRAC_TO_HTML[d1]})" + def unicode(self): + """*not implemented*""" + raise NotImplementedError + def __eq__(self, other): if not isinstance(other, Note): return NotImplemented @@ -104,7 +117,17 @@ def from_abc( key: Key = _DEFAULT_KEY, octave_base: int = _DEFAULT_OCTAVE_BASE, unit_duration: Fraction = _DEFAULT_UNIT_DURATION, - ): + ) -> "Note": + """Parse ABC string to note. + + The default context is: + + * C major + * octave 4 + * eighth note unit duration + + but this can be adjusted. + """ m = _RE_NOTE.match(abc) return cls._from_abc_match(m, key=key, octave_base=octave_base, unit_duration=unit_duration) @@ -116,7 +139,7 @@ def _from_abc_match( key: Key = _DEFAULT_KEY, octave_base: int = _DEFAULT_OCTAVE_BASE, unit_duration: Fraction = _DEFAULT_UNIT_DURATION, - ): + ) -> "Note": # `re.Match[str]` seems to work only in 3.9+ ? # TODO: key could be a string or Key instance to make it simpler? if m is None: @@ -195,7 +218,8 @@ def to_abc( key: Key = _DEFAULT_KEY, octave_base: int = _DEFAULT_OCTAVE_BASE, unit_duration: Fraction = _DEFAULT_UNIT_DURATION, - ): + ) -> str: + """Convert to ABC notation string.""" octave = self.octave note_name = self.class_name @@ -241,6 +265,8 @@ def to_abc( @classmethod def from_pitch(cls, p: Pitch, *, duration: Fraction = _DEFAULT_UNIT_DURATION) -> "Note": + """From pitch instance.""" + # TODO: accept string pitch name as well? note = cls(p.value, duration) note._class_name = p._class_name note._octave = p._octave @@ -248,27 +274,42 @@ def from_pitch(cls, p: Pitch, *, duration: Fraction = _DEFAULT_UNIT_DURATION) -> return note def to_pitch(self) -> Pitch: + """Convert to pitch, preserving the class name.""" p = Pitch(self.value) p._class_name = self._class_name return p + def to_note(self): + """*not implemented*""" + raise NotImplementedError + + @classmethod + def from_name(cls, *args, **kwargs): + """*not implemented*""" + raise NotImplementedError + @classmethod - def from_name(cls): + def from_etf(cls, *args, **kwargs): + """*not implemented*""" raise NotImplementedError @classmethod - def from_etf(cls): + def from_helmholtz(cls, *args, **kwargs): + """*not implemented*""" raise NotImplementedError @classmethod - def from_pitch_class(cls): + def from_pitch_class(cls, *args, **kwargs): + """*not implemented*""" raise NotImplementedError @classmethod - def from_class_name(cls): + def from_class_name(cls, *args, **kwargs): + """*not implemented*""" raise NotImplementedError @classmethod - def from_class_value(cls): + def from_class_value(cls, *args, **kwargs): + """*not implemented*""" raise NotImplementedError diff --git a/pyabc2/parse.py b/pyabc2/parse.py index 031e553..3a60261 100644 --- a/pyabc2/parse.py +++ b/pyabc2/parse.py @@ -222,6 +222,7 @@ def __init__(self, abc: str): """Revelant URL for this particular tune/setting.""" self.measures: List[List[Note]] + """Notes from "playing" the tune.""" self._parse_abc() @@ -364,7 +365,7 @@ def __eq__(self, other): def __hash__(self): return hash(self.abc) - def _repr_html_(self): + def _repr_html_(self): # pragma: no cover import uuid notation_id = str(uuid.uuid4()) @@ -379,7 +380,7 @@ def _repr_html_(self): from IPython.display import HTML, Javascript, display - html = HTML(f"
hi
") + html = HTML(f"
abcjs target
") display(html) js = Javascript(_FMT_ABCJS_RENDER_JS.format(abc=abc, notation_id=notation_id)) @@ -395,5 +396,5 @@ def print_measures(self, n: Optional[int] = None, *, note_format: str = "ABC"): raise ValueError(f"invalid note format {note_format!r}") def iter_notes(self) -> Iterator[Note]: - """Iterator (generator) for `Note`s of the tune.""" + r"""Iterator (generator) for `Note`\ s of the tune.""" return (n for m in self.measures for n in m) diff --git a/pyabc2/pitch.py b/pyabc2/pitch.py index 4621383..f05b622 100644 --- a/pyabc2/pitch.py +++ b/pyabc2/pitch.py @@ -221,10 +221,12 @@ def unicode(self): @classmethod def from_pitch(cls, p: "Pitch") -> "PitchClass": + """From pitch instance.""" return cls.from_name(p.class_name) @classmethod def from_name(cls, name: str) -> "PitchClass": + """From pitch class name (e.g., ``C``, ``F#``).""" _validate_pitch_class_name(name) value = pitch_class_value(name, mod=True) @@ -401,6 +403,7 @@ def solfege_in(self, key: "Key") -> str: return s def to_pitch(self, octave: int) -> "Pitch": + """Convert to pitch in the specified octave.""" p = Pitch.from_class_value(self.value, octave) p._class_name = self._name @@ -553,7 +556,7 @@ def piano_key_number(self) -> int: @property def n(self) -> int: - """Alias for piano_key_number.""" + """Alias for :attr:`piano_key_number`.""" return self.piano_key_number @property @@ -571,11 +574,12 @@ def equal_temperament_frequency(self) -> float: @property def etf(self) -> float: - """Alias for equal_temperament_frequency.""" + """Alias for :attr:`equal_temperament_frequency`.""" return self.equal_temperament_frequency @classmethod def from_etf(cls, f: float) -> "Pitch": + """From frequency, rounding to the nearest piano key.""" from math import log2 n_f = 12 * log2(f / 440) + 49 # piano key number @@ -623,20 +627,24 @@ def from_name(cls, name: str) -> "Pitch": @classmethod def from_class_value(cls, value: int, octave: int) -> "Pitch": + """From pitch class chromatic value and octave.""" return cls(value + octave * 12) @classmethod def from_class_name(cls, class_name: str, octave: int) -> "Pitch": + """From pitch class name and octave.""" return cls.from_name(f"{class_name}{octave}") @classmethod def from_pitch_class(cls, pc: PitchClass, octave: int) -> "Pitch": + """From pitch class instance.""" p = cls(pc.value + octave * 12) p._class_name = pc._name return p def to_pitch_class(self) -> PitchClass: + """Convert to pitch class, preserving the class name.""" # Preserve explicit name if set if self._class_name is not None: return PitchClass.from_name(self.class_name) @@ -644,6 +652,7 @@ def to_pitch_class(self) -> PitchClass: return PitchClass(self.class_value) def to_note(self, *, duration: Optional[Fraction] = None): + """Convert to note (eighth note by default).""" from .note import _DEFAULT_UNIT_DURATION, Note if duration is None: diff --git a/pyproject.toml b/pyproject.toml index 3c7cbd3..95ab428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,11 @@ dev = [ "ipython", "jupyterlab", ] +doc = [ + "furo", + "myst-nb", + "sphinx", +] [tool.black] diff --git a/tests/test_note.py b/tests/test_note.py index 498a130..24ce092 100644 --- a/tests/test_note.py +++ b/tests/test_note.py @@ -526,12 +526,25 @@ def test_note_name_preservation(): @pytest.mark.parametrize( - "meth", ["from_name", "from_etf", "from_pitch_class", "from_class_name", "from_class_value"] + "meth", + [ + "from_name", + "from_etf", + "from_pitch_class", + "from_class_name", + "from_class_value", + "to_note", + "unicode", + ], ) def test_note_to_from_nonimpl(meth): assert hasattr(Note, meth) + if meth.startswith("from_"): + args = () + else: + args = (None,) # self with pytest.raises(NotImplementedError): - getattr(Note, meth)() + getattr(Note, meth)(*args) @pytest.mark.parametrize(