From ff1bd747399b3d2558aea6889e9f2404a100039d Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 7 Apr 2023 11:39:32 -0400 Subject: [PATCH 01/20] test str, repr, and __eq__ --- tests/test_key.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_key.py b/tests/test_key.py index 9486a39..0ea8f4d 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -100,3 +100,15 @@ def test_scvs_consistency(): scvs0 = key.scale_chromatic_values scvs = [pc.value_in(key) for pc in key.scale] assert scvs0 == scvs + + +def test_str(): + assert str(Key("C")) == "Cmaj" + + +def test_repr(): + assert repr(Key("C")) == "Key(tonic=C, mode='Major')" + + +def test_inequality(): + assert Key("C") != "this is not a Key" From ce3ffdd6ce347c21e360616e7177189e12ebebd9 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 7 Apr 2023 12:43:01 -0400 Subject: [PATCH 02/20] test invalid keys --- tests/test_key.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_key.py b/tests/test_key.py index 0ea8f4d..1a468f5 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -31,13 +31,28 @@ def many_possible_key_name_inputs(): return [k + m for k, m in product(keys, modes)] -@pytest.mark.parametrize("key_name", many_possible_key_name_inputs()) +@pytest.mark.parametrize("key_name", [""] + many_possible_key_name_inputs()) def test_parse_key_basic_succeeds(key_name): # TODO: could create a shorter version with a few selected examples and mark this one as long # Attempt to create key using key string provided. Key(name=key_name) +def test_default_key(): + k = Key("") + assert k.tonic.name == "C" + + +def test_parse_key_invalid_base_fails(): + with pytest.raises(ValueError, match="Invalid key specification"): + Key.parse_key("X") + + +def test_parse_key_invalid_mode_fails(): + with pytest.raises(ValueError, match="Unrecognized mode specification"): + Key.parse_key("Bad mode + extras warning") + + def test_key_sig(): k = Key("Amaj") assert k.key_signature == ["F#", "C#", "G#"] From 406f62ea587722a8afd64448013af40da557c6f7 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 7 Apr 2023 13:46:19 -0400 Subject: [PATCH 03/20] test flat scales and invalid scale intervals (which plausibly could be allowed) --- tests/test_key.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/test_key.py b/tests/test_key.py index 1a468f5..6082e3b 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -5,7 +5,13 @@ import pytest -from pyabc2.key import CMAJ_LETTERS, MODE_VALUES, Key, _mode_chromatic_scale_degrees +from pyabc2.key import ( + CMAJ_LETTERS, + MODE_VALUES, + Key, + _mode_chromatic_scale_degrees, + _scale_intervals, +) def many_possible_key_name_inputs(): @@ -53,13 +59,20 @@ def test_parse_key_invalid_mode_fails(): Key.parse_key("Bad mode + extras warning") -def test_key_sig(): +def test_sharp_key_sig(): k = Key("Amaj") assert k.key_signature == ["F#", "C#", "G#"] for n in "FCG": assert k.accidentals[n] == "#" +def test_flat_key_sig(): + k = Key("Eb") + assert k.key_signature == ["Bb", "Eb", "Ab"] + for n in "BEA": + assert k.accidentals[n] == "b" + + def test_relatives(): Am = Key("Am") C = Key("C") @@ -127,3 +140,13 @@ def test_repr(): def test_inequality(): assert Key("C") != "this is not a Key" + + +def test_whole_tone_scale_intervals_fails(): + with pytest.raises(AssertionError): + _scale_intervals([0, 2, 4, 6, 8, 10]) + + +def test_blues_scale_intervals_fails(): + with pytest.raises(ValueError, match=r"strange interval \(not W/H\)"): + _scale_intervals([0, 2, 3, 5, 6, 9, 10]) From 46fe4d4cbf57cb9685bc7f8cd4548c3e863ca558 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 7 Apr 2023 14:02:41 -0400 Subject: [PATCH 04/20] test invalid mode --- tests/test_key.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_key.py b/tests/test_key.py index 6082e3b..70d12c3 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -90,6 +90,11 @@ def test_mode_chromatic_scale_degrees(mode, acc_format): assert all(len(s) in [1, 2, 5] and s[-1:-2:-1] in "1234567" for s in csds) +def test_invalid_mode_chromatic_scale_degrees_fails(): + with pytest.raises(ValueError, match="invalid mode name"): + _mode_chromatic_scale_degrees("invalid") + + @pytest.mark.parametrize("mode", MODE_VALUES) def test_mode_scale_degrees_wrt_major(mode): # They should be the same no matter the tonic is. From 09d2004e464e106d80fa0a2db233e31d313e43cd Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 7 Apr 2023 14:09:16 -0400 Subject: [PATCH 05/20] remove redundant --- tests/test_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_key.py b/tests/test_key.py index 70d12c3..fe64b12 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -37,7 +37,7 @@ def many_possible_key_name_inputs(): return [k + m for k, m in product(keys, modes)] -@pytest.mark.parametrize("key_name", [""] + many_possible_key_name_inputs()) +@pytest.mark.parametrize("key_name", many_possible_key_name_inputs()) def test_parse_key_basic_succeeds(key_name): # TODO: could create a shorter version with a few selected examples and mark this one as long # Attempt to create key using key string provided. From f6307c04cbc94a390272374b12443217faabb093 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Wed, 19 Apr 2023 07:06:36 -0400 Subject: [PATCH 06/20] capture warning --- tests/test_key.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_key.py b/tests/test_key.py index fe64b12..079b074 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -56,7 +56,8 @@ def test_parse_key_invalid_base_fails(): def test_parse_key_invalid_mode_fails(): with pytest.raises(ValueError, match="Unrecognized mode specification"): - Key.parse_key("Bad mode + extras warning") + with pytest.warns(UserWarning, match="extra info"): + Key.parse_key("Bad mode + extras warning") def test_sharp_key_sig(): From c335f54616f7e9d90885441e8bea9b61c82a273e Mon Sep 17 00:00:00 2001 From: mccalluc Date: Wed, 19 Apr 2023 07:24:00 -0400 Subject: [PATCH 07/20] capture output and test print_* methods --- tests/test_key.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_key.py b/tests/test_key.py index 079b074..2f38c62 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -156,3 +156,33 @@ def test_whole_tone_scale_intervals_fails(): def test_blues_scale_intervals_fails(): with pytest.raises(ValueError, match=r"strange interval \(not W/H\)"): _scale_intervals([0, 2, 3, 5, 6, 9, 10]) + + +def test_print_scale(capsys): + Key("C").print_scale() + output = capsys.readouterr().out + assert output == "C D E F G A B \n" + + +def test_print_scale_degrees_wrt_major(capsys): + Key("C").print_scale_degrees_wrt_major() + output = capsys.readouterr().out + assert output == "1 2 3 4 5 6 7 \n" + + +def test_print_chromatic_scale_degrees(capsys): + Key("C").print_chromatic_scale_degrees() + output = capsys.readouterr().out + assert output == "1 #1 2 #2 3 4 #4 5 #5 6 #6 7 \n" + + +def test_print_scale_chromatic_values(capsys): + Key("C").print_scale_chromatic_values() + output = capsys.readouterr().out + assert output == "0 2 4 5 7 9 11\n" + + +def test_print_intervals(capsys): + Key("C").print_intervals() + output = capsys.readouterr().out + assert output == "W W H W W W H\n" From 70c2d1bde82550eaf2c78e0ca742e0da3c0d591a Mon Sep 17 00:00:00 2001 From: mccalluc Date: Wed, 19 Apr 2023 07:31:11 -0400 Subject: [PATCH 08/20] assert -> ValueError --- pyabc2/key.py | 3 ++- tests/test_key.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index 1209f80..e4e5a67 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -185,7 +185,8 @@ def _scale_intervals( Whether to return 7 intervals by computing the interval from scale degree 7 to 8, by default True. """ - assert len(values) == 7 + if len(values) != 7: + raise ValueError(f"scales should contain 7 intervals, not {len(values)}") if include_upper: values.append(12) diff --git a/tests/test_key.py b/tests/test_key.py index 2f38c62..934043b 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -149,7 +149,7 @@ def test_inequality(): def test_whole_tone_scale_intervals_fails(): - with pytest.raises(AssertionError): + with pytest.raises(ValueError, match=r"should contain 7 intervals, not 6"): _scale_intervals([0, 2, 4, 6, 8, 10]) From 932becf09d3266ff191204a9bba8ff24cfd29e83 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Wed, 19 Apr 2023 07:43:42 -0400 Subject: [PATCH 09/20] test dash interval format --- tests/test_key.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_key.py b/tests/test_key.py index 934043b..c952290 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -182,7 +182,13 @@ def test_print_scale_chromatic_values(capsys): assert output == "0 2 4 5 7 9 11\n" -def test_print_intervals(capsys): +def test_print_intervals_wh(capsys): Key("C").print_intervals() output = capsys.readouterr().out assert output == "W W H W W W H\n" + + +def test_print_intervals_dash(capsys): + Key("C").print_intervals(fmt="-") + output = capsys.readouterr().out + assert output == "|--|--|-|--|--|--|-\n" From fd2e4618208c215d59159cf241d683244e246edb Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 28 Apr 2023 22:01:14 -0400 Subject: [PATCH 10/20] add more scale guard clauses --- pyabc2/key.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index e4e5a67..43c69d1 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -186,7 +186,11 @@ def _scale_intervals( by default True. """ if len(values) != 7: - raise ValueError(f"scales should contain 7 intervals, not {len(values)}") + raise ValueError(f"expected 7 values, got {len(values)}") + if values[0] != 0: + raise ValueError(f"first value should be 0, not {values[0]}") + if values[-1] < 12: + raise ValueError(f"last value should be < 12, not {values[-1]}") if include_upper: values.append(12) From b0c7a4fecca64f8b3d4375989b20492a6ac3ce78 Mon Sep 17 00:00:00 2001 From: mccalluc Date: Fri, 28 Apr 2023 22:03:35 -0400 Subject: [PATCH 11/20] update content of test and rename --- tests/test_key.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_key.py b/tests/test_key.py index c952290..165b13f 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -149,11 +149,11 @@ def test_inequality(): def test_whole_tone_scale_intervals_fails(): - with pytest.raises(ValueError, match=r"should contain 7 intervals, not 6"): + with pytest.raises(ValueError, match=r"expected 7 values, got 6"): _scale_intervals([0, 2, 4, 6, 8, 10]) -def test_blues_scale_intervals_fails(): +def test_scale_with_large_interval_fails(): with pytest.raises(ValueError, match=r"strange interval \(not W/H\)"): _scale_intervals([0, 2, 3, 5, 6, 9, 10]) From 492096fe543ef79132ce10cce2d8b7f91f0a09d9 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:05:43 -0500 Subject: [PATCH 12/20] Fix cond --- pyabc2/key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index 43c69d1..436ecc4 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -189,7 +189,7 @@ def _scale_intervals( raise ValueError(f"expected 7 values, got {len(values)}") if values[0] != 0: raise ValueError(f"first value should be 0, not {values[0]}") - if values[-1] < 12: + if not values[-1] < 12: raise ValueError(f"last value should be < 12, not {values[-1]}") if include_upper: From 6bd47de2b46793f027d132ce0f6f7b9c159fced8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:12:53 -0500 Subject: [PATCH 13/20] Test the other added checks --- tests/test_key.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_key.py b/tests/test_key.py index 165b13f..7469820 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -153,6 +153,16 @@ def test_whole_tone_scale_intervals_fails(): _scale_intervals([0, 2, 4, 6, 8, 10]) +def test_nonzero_start_fails(): + with pytest.raises(ValueError, match=r"first value should be 0"): + _scale_intervals([1] * 7) + + +def test_beyond_octave_fails(): + with pytest.raises(ValueError, match=r"last value should be < 12"): + _scale_intervals([0, 2, 4, 5, 7, 9, 13]) + + def test_scale_with_large_interval_fails(): with pytest.raises(ValueError, match=r"strange interval \(not W/H\)"): _scale_intervals([0, 2, 3, 5, 6, 9, 10]) From 3e48b6158a8e2f1f54db3c27bc020495267f976c Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:15:09 -0500 Subject: [PATCH 14/20] Test acc fmt validation --- tests/test_key.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_key.py b/tests/test_key.py index 7469820..6becd79 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -91,11 +91,16 @@ def test_mode_chromatic_scale_degrees(mode, acc_format): assert all(len(s) in [1, 2, 5] and s[-1:-2:-1] in "1234567" for s in csds) -def test_invalid_mode_chromatic_scale_degrees_fails(): +def test_invalid_mode_chromatic_scale_degrees_bad_mode(): with pytest.raises(ValueError, match="invalid mode name"): _mode_chromatic_scale_degrees("invalid") +def test_invalid_mode_chromatic_scale_degrees_bad_acc_fmt(): + with pytest.raises(ValueError, match="invalid `acc_format`"): + _mode_chromatic_scale_degrees("ionian", acc_fmt="invalid") + + @pytest.mark.parametrize("mode", MODE_VALUES) def test_mode_scale_degrees_wrt_major(mode): # They should be the same no matter the tonic is. From 6ad8ea17313c296ae352ab9196ea9a04d4141d39 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:34:36 -0500 Subject: [PATCH 15/20] Test more relative code paths --- pyabc2/key.py | 1 + tests/test_key.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pyabc2/key.py b/pyabc2/key.py index 436ecc4..83c0ce3 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -339,6 +339,7 @@ def relative(self, mode: str, *, match_acc: bool = False) -> "Key": if match_acc: # Select flat or sharp to match the current key name + # (haven't identified a case where the result is different than below though) # TODO: PitchClass from value with acc option? if "#" in key.name: diff --git a/tests/test_key.py b/tests/test_key.py index 6becd79..ef4fbc1 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -81,6 +81,12 @@ def test_relatives(): assert C.relative("aeolian") == C.relative_minor == Am assert Key("Ador").relative_major == Key("G") + assert Am.relative("major", match_acc=True) == C + assert Key("Cb").relative("dorian") == Key("Dbdor") + assert Key("Cb").relative("dorian", match_acc=True) == Key("Dbdor") + assert Key("C#").relative("dorian", match_acc=True) == Key("D#dor") + assert Key("C#").relative("dorian") == Key("D#dor") + @pytest.mark.parametrize( ("mode", "acc_format"), [(m, a) for m, a in product(MODE_VALUES, ["#", "b", "#/b", "b/#"])] From ec3e44b06129c7387ec612d84d649c3527858b6b Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:41:27 -0500 Subject: [PATCH 16/20] Tweak error messages including one missing f --- pyabc2/key.py | 10 +++++----- tests/test_key.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index 83c0ce3..edb0d87 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -203,7 +203,7 @@ def _scale_intervals( elif dv == 1: interval = "H" else: - raise ValueError("strange interval (not W/H)") + raise ValueError(f"strange interval {dv} (not W/H)") intervals.append(interval) # TODO: should make a class for interval! @@ -292,8 +292,8 @@ def parse_key(key: str) -> Tuple[PitchClass, str]: try: mode = _validate_and_normalize_mode_name(mode) - except ValueError: - raise ValueError("Unrecognized mode specification '{mode}' from key '{key}'") + except ValueError as e: + raise ValueError(f"Unrecognized mode specification '{mode}' from key '{key}'") from e return PitchClass.from_name(base + acc), mode # TODO: probably should either return a Key or be private / maybe outside class @@ -365,8 +365,8 @@ def relative(self, mode: str, *, match_acc: bool = False) -> "Key": tonic_name = tonic_es.name elif tonic_ef.nat == new_nat: tonic_name = tonic_ef.name - else: - raise Exception + else: # pragma: no cover + raise AssertionError else: tonic_name = tonic.name diff --git a/tests/test_key.py b/tests/test_key.py index ef4fbc1..bab1fc2 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -175,7 +175,7 @@ def test_beyond_octave_fails(): def test_scale_with_large_interval_fails(): - with pytest.raises(ValueError, match=r"strange interval \(not W/H\)"): + with pytest.raises(ValueError, match=r"strange interval 3 \(not W/H\)"): _scale_intervals([0, 2, 3, 5, 6, 9, 10]) From 285132a549f50c35f2eced02eff0f9e96bad8f78 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:50:07 -0500 Subject: [PATCH 17/20] Use ValueError for arg validation --- pyabc2/key.py | 7 +++++-- tests/test_key.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index edb0d87..2cd8dfb 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -255,14 +255,17 @@ def __init__( Mode specification, e.g., `m`, `min`, `dor`. (Major assumed if mode not specified.) """ + msg = "pass either just `name` or both `tonic` and `mode`" if name is not None: - assert tonic is None and mode is None, "pass either `name` or `tonic`+`mode`" + if not (tonic is None and mode is None): + raise ValueError(msg) # Handle occasional `K:` line used to indicate default key (C) and tune start if name == "": name = "C" self.tonic, self._mode = Key.parse_key(name) else: - assert tonic is not None and mode is not None, "pass either `name` or `tonic`+`mode`" + if not (tonic is not None and mode is not None): + raise ValueError(msg) self.tonic = PitchClass.from_name(tonic) self._mode = _validate_and_normalize_mode_name(mode) diff --git a/tests/test_key.py b/tests/test_key.py index bab1fc2..2d7b814 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -49,6 +49,18 @@ def test_default_key(): assert k.tonic.name == "C" +def test_arg_mix_fails(): + with pytest.raises(ValueError, match="pass either just `name` or both `tonic` and `mode`"): + Key(name="C", tonic="D") + + +def test_tonic_mode_both_needed(): + # Maybe will default to major in the future, but currently requiring both + # be passed and mode be valid. + with pytest.raises(ValueError, match="pass either just `name` or both `tonic` and `mode`"): + Key(tonic="C") + + def test_parse_key_invalid_base_fails(): with pytest.raises(ValueError, match="Invalid key specification"): Key.parse_key("X") From 66ffd74772b5cd69e7088fe7f0c4e4d10828c873 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 12:56:04 -0500 Subject: [PATCH 18/20] Clarify [skip ci] --- pyabc2/key.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index d821ea2..b1e6c30 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -359,7 +359,8 @@ def relative(self, mode: str, *, match_acc: bool = False) -> "Key": if match_acc: # Select flat or sharp to match the current key name - # (haven't identified a case where the result is different than below though) + # (haven't identified a case where the result is different than + # the default approach below though) # TODO: PitchClass from value with acc option? if "#" in key.name: From fcfc6928f67872cd44e490a2d3153fbf838da84e Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 13:28:06 -0500 Subject: [PATCH 19/20] This may have been where I was going with this --- pyabc2/key.py | 10 +++++++--- tests/test_key.py | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index b1e6c30..da06716 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -358,17 +358,21 @@ def relative(self, mode: str, *, match_acc: bool = False) -> "Key": tonic_ef = tonic.equivalent_flat if match_acc: - # Select flat or sharp to match the current key name + # Select accidentals to match the current key name # (haven't identified a case where the result is different than # the default approach below though) # TODO: PitchClass from value with acc option? if "#" in key.name: - if tonic_es.acc == "#": # single sharp used + if tonic_es.acc == key.acc: tonic_name = tonic_es.name + else: + raise ValueError(f"unable to match accidentals ({key} -> {tonic_es})") elif "b" in key.name: - if tonic_ef.acc == "b": # single flat used + if tonic_ef.acc == key.acc: tonic_name = tonic_ef.name + else: + raise ValueError(f"unable to match accidentals ({key} -> {tonic_ef})") else: tonic_name = tonic.name diff --git a/tests/test_key.py b/tests/test_key.py index 5639438..32f8a4e 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -100,6 +100,15 @@ def test_relatives(): assert Key("C#").relative("dorian", match_acc=True) == Key("D#dor") assert Key("C#").relative("dorian") == Key("D#dor") + # Note key spec parsing currently only supports single #/b, + # while pitch class parsing (used when passing tonic) supports up to 2. + assert Key("Cbdorian").relative_major == Key(tonic="Bbb", mode="Major") + with pytest.raises(ValueError, match="unable to match accidentals"): + Key("Cbdorian").relative("Major", match_acc=True) + assert Key("E#major").relative("Dorian") == Key(tonic="F##", mode="Dorian") + with pytest.raises(ValueError, match="unable to match accidentals"): + Key("E#major").relative("Dorian", match_acc=True) + @pytest.mark.parametrize( ("mode", "acc_format"), [(m, a) for m, a in product(MODE_VALUES, ["#", "b", "#/b", "b/#"])] From 9a4fc6303322267b13111d1a534980e5cf6d607b Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 22 Jun 2025 13:46:56 -0500 Subject: [PATCH 20/20] Use candidate loop instead --- pyabc2/key.py | 25 +++++++++++-------------- pyabc2/pitch.py | 2 ++ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pyabc2/key.py b/pyabc2/key.py index da06716..17e351a 100644 --- a/pyabc2/key.py +++ b/pyabc2/key.py @@ -359,23 +359,20 @@ def relative(self, mode: str, *, match_acc: bool = False) -> "Key": if match_acc: # Select accidentals to match the current key name - # (haven't identified a case where the result is different than + # (haven't identified a case where a successful result is different than # the default approach below though) - # TODO: PitchClass from value with acc option? - if "#" in key.name: - if tonic_es.acc == key.acc: - tonic_name = tonic_es.name - else: - raise ValueError(f"unable to match accidentals ({key} -> {tonic_es})") - elif "b" in key.name: - if tonic_ef.acc == key.acc: - tonic_name = tonic_ef.name - else: - raise ValueError(f"unable to match accidentals ({key} -> {tonic_ef})") + # Note = is allowed for explicit natural, but it wouldn't be added automatically + orig_acc = key.acc.replace("=", "") + for tonic_cand in [tonic_es, tonic_ef, tonic]: + if tonic_cand.acc == orig_acc: + tonic_name = tonic_cand.name + break else: - tonic_name = tonic.name - + raise ValueError( + f"unable to match accidentals ({key}{mode0} -> " + f"{{{tonic_es}, {tonic_ef}, {tonic}}}{mode})" + ) else: # Use the letter that it should be in the scale sd0 = MODE_SCALE_DEGREE[mode0] diff --git a/pyabc2/pitch.py b/pyabc2/pitch.py index f05b622..9de4539 100644 --- a/pyabc2/pitch.py +++ b/pyabc2/pitch.py @@ -236,6 +236,8 @@ def from_name(cls, name: str) -> "PitchClass": return pc + # TODO: PitchClass from value with acc option (to hint name)? + @property def equivalent_sharp(self) -> "PitchClass": pcnew = self - 1