chordsym 0.0.2__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,50 @@
1
+ # wads CI — calls the reusable workflow hosted in i2mint/wads.
2
+ #
3
+ # All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
4
+ # To customize the workflow itself (rare), replace this file with the
5
+ # full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
6
+ #
7
+ # Pinning: `@master` floats with wads. If you need version stability for
8
+ # a release-sensitive repo, change `@master` to a wads tag (e.g. `@0.2.15`;
9
+ # tags have no `v` prefix). A stub whose `secrets:` block passes the JSON
10
+ # transport (the default below) needs a tag from a release after 0.2.14 —
11
+ # older tags don't declare that secret and GitHub then rejects the
12
+ # workflow at parse time.
13
+ # CI failure does not block a published release — it blocks the publish
14
+ # step itself — so floating master is generally safe.
15
+ #
16
+ # Permissions: GitHub validates that the caller grants AT LEAST the
17
+ # permissions any job in the called workflow requests — at workflow-parse
18
+ # time, not at run-time, even if the job would be skipped via `if:`.
19
+ # The reusable workflow needs:
20
+ # contents: write for the publish job's version-bump push-back
21
+ # and for the github-pages job's gh-pages branch push
22
+ # pages: write for the github-pages job's REST API Pages config
23
+ # Both default to `write` on org-account GITHUB_TOKEN and need to be
24
+ # granted explicitly on personal-account callers (where the default is
25
+ # read-only). No `id-token: write` needed — the publish-github-pages
26
+ # action uses peaceiris/actions-gh-pages (branch-based) + REST API,
27
+ # not the OIDC `actions/deploy-pages` flow.
28
+ name: Continuous Integration
29
+ # The TypeScript twin (ts/) has its own workflow (npm-ci-ts.yml); a change only there
30
+ # does not re-run or re-release the Python package. The generated grammar file is the
31
+ # exception: tests/test_grammar.py checks it against chordsym/_grammar.py.
32
+ on:
33
+ push:
34
+ paths: ["**", "!ts/**", "ts/src/grammar.generated.ts", "!.github/workflows/npm-ci-ts.yml"]
35
+ pull_request:
36
+ paths: ["**", "!ts/**", "ts/src/grammar.generated.ts", "!.github/workflows/npm-ci-ts.yml"]
37
+ jobs:
38
+ ci:
39
+ uses: i2mint/wads/.github/workflows/uv-ci.yml@master
40
+ permissions:
41
+ contents: write
42
+ pages: write
43
+ # Transport (NAMED, legacy): explicitly passes only the secrets
44
+ # listed below (PYPI_PASSWORD + those declared in
45
+ # [tool.wads.ci.env]). Every name must be in the frozen wads
46
+ # superset (wads/ci_secrets.py) or GitHub rejects the workflow
47
+ # at parse time. The default JSON transport has no such limit;
48
+ # regenerate with `wads-migrate ci-to-stub` to switch.
49
+ secrets:
50
+ PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
@@ -0,0 +1,42 @@
1
+ # wads NPM CI — calls the reusable workflow hosted in i2mint/wads.
2
+ #
3
+ # All configuration lives in ts/package.json under the "wads"
4
+ # key (wads.ci.*). This workflow only runs when the JS subdirectory changes,
5
+ # so it never collides with the Python ci.yml.
6
+ #
7
+ # Publishing to npm is OPT-IN: set wads.ci.publish.enabled=true in package.json
8
+ # and include the marker (default "[publish-npm]") in your commit message.
9
+ # Publishing uses npm trusted publishing (OIDC); there is no NPM_TOKEN secret.
10
+ # On npmjs.com: package -> Settings -> Trusted Publisher -> GitHub Actions, with
11
+ # this repo and THIS file's name (npm-ci-ts.yml), or run
12
+ # npm trust github <pkg> --file npm-ci-ts.yml --repo <owner>/<repo> --allow-publish -y
13
+ # The package must exist on npm first: publish the very first version locally.
14
+ #
15
+ # On-demand CI (opt-in, mirrors [tool.wads.ci.trigger] on the Python side):
16
+ # set wads.ci.trigger.mode="on-demand" in package.json and the reusable
17
+ # workflow's validate/publish jobs run only when the commit SUBJECT contains
18
+ # wads.ci.trigger.runCiMarker (default "[run ci]"), or the run is a manual
19
+ # `gh workflow run npm-ci-ts.yml --ref <branch>` (always allowed).
20
+ # To do locally what CI would have done: `npm run ci-local` (lint, test,
21
+ # build — add that script to package.json if it isn't there already).
22
+ name: NPM CI (ts)
23
+ # fixtures/ holds the golden cases both twins run, so a change there re-runs this one too.
24
+ on:
25
+ push:
26
+ paths:
27
+ - "ts/**"
28
+ - "fixtures/**"
29
+ - ".github/workflows/npm-ci-ts.yml"
30
+ pull_request:
31
+ paths:
32
+ - "ts/**"
33
+ - "fixtures/**"
34
+ - ".github/workflows/npm-ci-ts.yml"
35
+ jobs:
36
+ npm-ci:
37
+ uses: i2mint/wads/.github/workflows/npm-ci.yml@master
38
+ permissions:
39
+ contents: read
40
+ id-token: write # npm provenance / OIDC trusted publishing
41
+ with:
42
+ package-dir: ts
@@ -0,0 +1,129 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+ docs/*
81
+
82
+ # PyBuilder
83
+ target/
84
+
85
+ # Jupyter Notebook
86
+ .ipynb_checkpoints
87
+
88
+ # pyenv
89
+ .python-version
90
+
91
+ # celery beat schedule file
92
+ celerybeat-schedule
93
+
94
+ # SageMath parsed files
95
+ *.sage.py
96
+
97
+ # Environments
98
+ .env
99
+ .venv
100
+ env/
101
+ venv/
102
+ ENV/
103
+ env.bak/
104
+ venv.bak/
105
+
106
+ # Spyder project settings
107
+ .spyderproject
108
+ .spyproject
109
+
110
+ # Rope project settings
111
+ .ropeproject
112
+
113
+ # mkdocs documentation
114
+ /site
115
+
116
+ # mypy
117
+ .mypy_cache/
118
+
119
+ # PyCharm
120
+ .idea
121
+ __pycache__/
122
+ *.egg-info/
123
+ dist/
124
+ .venv/
125
+ .pytest_cache/
126
+ .ruff_cache/
127
+
128
+ # Node / the TypeScript twin (ts/)
129
+ node_modules/
chordsym-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: chordsym
3
+ Version: 0.0.2
4
+ Summary: Chord symbols without dependencies: parse, pitch classes, key estimation, transposition
5
+ Project-URL: Homepage, https://github.com/thorwhalen/chordsym
6
+ Project-URL: Repository, https://github.com/thorwhalen/chordsym
7
+ Project-URL: Documentation, https://thorwhalen.github.io/chordsym
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: chords,harmony,music,transposition
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Sound/Audio
15
+ Requires-Python: >=3.10
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # chordsym
23
+
24
+ Chord symbols without dependencies (standard library only): is this token a chord, which root and quality, which pitch classes, which key does a song's chord list suggest, and how do the chords read moved by N semitones.
25
+
26
+ ```python
27
+ >>> from chordsym import parse_chord, chord_tones, transpose_chord, transposition_table, estimate_key
28
+ >>> parse_chord("F#m7"), chord_tones("Cmaj7")
29
+ ((6, 'minor'), (0, 4, 7, 11))
30
+ >>> transpose_chord("C/E", 3, flats=True)
31
+ 'Eb/G'
32
+ >>> transposition_table(["G", "D", "Em", "C"], 3)["chords"]
33
+ {'G': 'Bb', 'D': 'F', 'Em': 'Gm', 'C': 'Eb'}
34
+ ```
35
+
36
+ It reads the spellings found in scraped chord charts (`C6/9`, `Bm5-/7`, Brazilian `7M`, `E7(#9)`, `D/F#`), not only textbook ones. Pitch classes are ints 0-11 (C = 0).
37
+
38
+ ## In the browser: the TypeScript twin
39
+
40
+ The same functions ship to npm as `chordsym` (in `ts/`), with camelCase names and keyword arguments as an options object:
41
+
42
+ ```ts
43
+ import { parseChord, transposeChord, estimateKey } from "chordsym";
44
+
45
+ parseChord("Am7"); // [9, "minor"]
46
+ transposeChord("C/E", 3, { flats: true }); // "Eb/G"
47
+ estimateKey(["Am", "Dm", "E7"]); // { tonic: 9, minor: true }
48
+ ```
49
+
50
+ The two are kept identical by construction, not by care:
51
+
52
+ - **One grammar.** Every regular expression and table lives in `chordsym/_grammar.py`, written in the subset of regex syntax that means the same in Python and JavaScript (no `\d`, `\s`, `\w`, `\b`). `python -m chordsym._codegen` writes it to `ts/src/grammar.generated.ts`, and a test fails if that file is stale.
53
+ - **One set of golden cases.** `fixtures/cases.json` holds about 1,500 calls with their expected results, errors included (`ValueError` in Python is `RangeError` in TypeScript). pytest and vitest both run every one and compare them as JSON text, so order matters. To add a case, append it and run `python -m chordsym._fixtures`, which records the Python results; review the diff, since a changed expectation is a behaviour change on both sides.
54
+ - **A wide net before releases.** `python scripts/twin_differential.py` (after `cd ts && npm run build`) runs about 75,000 generated chord spellings and 3,000 songs through both and reports any difference.
55
+
56
+ Where the languages differ, the TypeScript side says so in its types: `transpositionTable(...).chords` is a `Map` (a plain object would reorder integer-like keys), and a `Key` is a plain `{ tonic, minor }` object checked by every function that takes one (`keyEquals` compares two).
57
+
58
+ Versions: an npm release carries the version of the Python release it was cut from (lockstep at release points, so `chordsym@X.Y.Z` on npm and PyPI are the same behaviour).
59
+
60
+ Seeded from songleaf (thorwhalen/songleaf#31). Planned: dialects (Harte, Chordonomicon, Nashville/Roman) as strategies; pychord and music21 as test oracles only.
@@ -0,0 +1,39 @@
1
+ # chordsym
2
+
3
+ Chord symbols without dependencies (standard library only): is this token a chord, which root and quality, which pitch classes, which key does a song's chord list suggest, and how do the chords read moved by N semitones.
4
+
5
+ ```python
6
+ >>> from chordsym import parse_chord, chord_tones, transpose_chord, transposition_table, estimate_key
7
+ >>> parse_chord("F#m7"), chord_tones("Cmaj7")
8
+ ((6, 'minor'), (0, 4, 7, 11))
9
+ >>> transpose_chord("C/E", 3, flats=True)
10
+ 'Eb/G'
11
+ >>> transposition_table(["G", "D", "Em", "C"], 3)["chords"]
12
+ {'G': 'Bb', 'D': 'F', 'Em': 'Gm', 'C': 'Eb'}
13
+ ```
14
+
15
+ It reads the spellings found in scraped chord charts (`C6/9`, `Bm5-/7`, Brazilian `7M`, `E7(#9)`, `D/F#`), not only textbook ones. Pitch classes are ints 0-11 (C = 0).
16
+
17
+ ## In the browser: the TypeScript twin
18
+
19
+ The same functions ship to npm as `chordsym` (in `ts/`), with camelCase names and keyword arguments as an options object:
20
+
21
+ ```ts
22
+ import { parseChord, transposeChord, estimateKey } from "chordsym";
23
+
24
+ parseChord("Am7"); // [9, "minor"]
25
+ transposeChord("C/E", 3, { flats: true }); // "Eb/G"
26
+ estimateKey(["Am", "Dm", "E7"]); // { tonic: 9, minor: true }
27
+ ```
28
+
29
+ The two are kept identical by construction, not by care:
30
+
31
+ - **One grammar.** Every regular expression and table lives in `chordsym/_grammar.py`, written in the subset of regex syntax that means the same in Python and JavaScript (no `\d`, `\s`, `\w`, `\b`). `python -m chordsym._codegen` writes it to `ts/src/grammar.generated.ts`, and a test fails if that file is stale.
32
+ - **One set of golden cases.** `fixtures/cases.json` holds about 1,500 calls with their expected results, errors included (`ValueError` in Python is `RangeError` in TypeScript). pytest and vitest both run every one and compare them as JSON text, so order matters. To add a case, append it and run `python -m chordsym._fixtures`, which records the Python results; review the diff, since a changed expectation is a behaviour change on both sides.
33
+ - **A wide net before releases.** `python scripts/twin_differential.py` (after `cd ts && npm run build`) runs about 75,000 generated chord spellings and 3,000 songs through both and reports any difference.
34
+
35
+ Where the languages differ, the TypeScript side says so in its types: `transpositionTable(...).chords` is a `Map` (a plain object would reorder integer-like keys), and a `Key` is a plain `{ tonic, minor }` object checked by every function that takes one (`keyEquals` compares two).
36
+
37
+ Versions: an npm release carries the version of the Python release it was cut from (lockstep at release points, so `chordsym@X.Y.Z` on npm and PyPI are the same behaviour).
38
+
39
+ Seeded from songleaf (thorwhalen/songleaf#31). Planned: dialects (Harte, Chordonomicon, Nashville/Roman) as strategies; pychord and music21 as test oracles only.
@@ -0,0 +1,28 @@
1
+ """chordsym: chord symbols without dependencies — parse, pitch classes, keys, transposition.
2
+
3
+ >>> from chordsym import parse_chord, transpose_chord, estimate_key
4
+ >>> parse_chord("Am7"), transpose_chord("Am7", 2), estimate_key(["Am", "Dm", "E7"])
5
+ ((9, 'minor'), 'Bm7', Key(tonic=9, minor=True))
6
+ """
7
+
8
+ from chordsym.keys import Key, chord_function, estimate_key
9
+ from chordsym.parse import chord_tones, is_chord_token, parse_chord
10
+ from chordsym.transpose import (
11
+ key_name,
12
+ transpose_chord,
13
+ transposition_table,
14
+ uses_flats,
15
+ )
16
+
17
+ __all__ = [
18
+ "Key",
19
+ "chord_function",
20
+ "chord_tones",
21
+ "estimate_key",
22
+ "is_chord_token",
23
+ "key_name",
24
+ "parse_chord",
25
+ "transpose_chord",
26
+ "transposition_table",
27
+ "uses_flats",
28
+ ]
@@ -0,0 +1,72 @@
1
+ """Write the shared grammar (``chordsym._grammar``) as the TypeScript twin's ``grammar.generated.ts``.
2
+
3
+ Every public UPPER_CASE name in ``_grammar`` is emitted (as a JSON literal, ``as const``),
4
+ so a table added on the Python side reaches TypeScript without touching this module. It
5
+ also emits ``WHITESPACE``, the code points Python's ``str.isspace`` accepts, which the
6
+ TypeScript ``strip`` uses and against which the vitest suite checks the ``WS`` class.
7
+
8
+ Run ``python -m chordsym._codegen`` after editing ``_grammar.py``; ``tests/test_grammar.py``
9
+ fails while the committed TypeScript file differs from what this module renders.
10
+
11
+ >>> 'export const PATTERNS' in render_ts() and 'export const WHITESPACE' in render_ts()
12
+ True
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from chordsym import _grammar
22
+
23
+ #: The repository root (where ``ts/`` lives, next to the package).
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent
25
+
26
+ #: Where the TypeScript twin keeps the generated grammar, relative to the repository root.
27
+ TS_GRAMMAR_PATH = Path("ts") / "src" / "grammar.generated.ts"
28
+
29
+ _HEADER = """\
30
+ // GENERATED by `python -m chordsym._codegen` from chordsym/_grammar.py. Do not edit:
31
+ // edit the Python module and regenerate (tests/test_grammar.py checks this file is current).
32
+ """
33
+
34
+
35
+ def grammar_names() -> list[str]:
36
+ """The names ``_grammar`` shares: every public UPPER_CASE module attribute."""
37
+ return [n for n in vars(_grammar) if n.isupper() and not n.startswith("_")]
38
+
39
+
40
+ def whitespace_code_points() -> list[int]:
41
+ """Every code point ``str.isspace`` accepts (what ``str.strip()`` removes)."""
42
+ return [c for c in range(sys.maxunicode + 1) if chr(c).isspace()]
43
+
44
+
45
+ def _jsonable(value):
46
+ if isinstance(value, (tuple, list)):
47
+ return [_jsonable(v) for v in value]
48
+ if isinstance(value, dict):
49
+ return {str(k): _jsonable(v) for k, v in value.items()}
50
+ return value
51
+
52
+
53
+ def render_ts() -> str:
54
+ """The TypeScript source of the shared grammar, exactly as it should be committed."""
55
+ shared = {name: getattr(_grammar, name) for name in grammar_names()}
56
+ shared["WHITESPACE"] = whitespace_code_points()
57
+ lines = [_HEADER]
58
+ for name, value in shared.items():
59
+ literal = json.dumps(_jsonable(value), ensure_ascii=False)
60
+ lines.append(f"export const {name} = {literal} as const;")
61
+ return "\n".join(lines) + "\n"
62
+
63
+
64
+ def write_ts(root: Path | str = REPO_ROOT) -> Path:
65
+ """Write :func:`render_ts` to ``<root>/ts/src/grammar.generated.ts``; return the path."""
66
+ path = Path(root) / TS_GRAMMAR_PATH
67
+ path.write_text(render_ts(), encoding="utf-8")
68
+ return path
69
+
70
+
71
+ if __name__ == "__main__":
72
+ print(write_ts())
@@ -0,0 +1,149 @@
1
+ """Run and record the golden cases in ``fixtures/cases.json``, the behaviour both twins must share.
2
+
3
+ A case is ``{"fn": <public function name>, "args": [...], "kwargs": {...}, "expected": ...}``
4
+ in JSON form:
5
+
6
+ - tuples are lists; a :class:`~chordsym.Key` is ``{"tonic": int, "minor": bool}``;
7
+ - an open mapping in a result (``transposition_table(...)["chords"]``) is a list of
8
+ ``[key, value]`` pairs, because its order is part of the contract and a JSON object's
9
+ is not (JavaScript moves integer-like keys first);
10
+ - an exception is ``{"$raises": "ValueError"}`` (TypeScript: ``RangeError``) or
11
+ ``{"$raises": "TypeError"}``;
12
+ - ``kwargs`` may only name keyword-only parameters, which the TypeScript twin takes as a
13
+ trailing options object.
14
+
15
+ The file's ``functions`` list is the public API (``chordsym.__all__`` minus classes); both
16
+ test suites check that they cover exactly it. Results are compared as JSON text, so order
17
+ matters everywhere.
18
+
19
+ To add a case, append it with any ``expected`` and run ``python -m chordsym._fixtures``: it
20
+ rewrites every ``expected`` from the Python implementation, so review the diff (a changed
21
+ expectation is a behaviour change on both sides).
22
+
23
+ >>> run_case({"fn": "transpose_chord", "args": ["C/E", 3], "kwargs": {"flats": True}})
24
+ 'Eb/G'
25
+ >>> run_case({"fn": "estimate_key", "args": [["Am", "Dm", "E7"]]})
26
+ {'tonic': 9, 'minor': True}
27
+ >>> run_case({"fn": "key_name", "args": [{"tonic": 12, "minor": False}]})
28
+ {'$raises': 'ValueError'}
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import inspect
34
+ import json
35
+ from pathlib import Path
36
+
37
+ import chordsym
38
+ from chordsym.keys import Key
39
+
40
+ #: The repository root (where ``fixtures/`` and ``ts/`` live, next to the package).
41
+ REPO_ROOT = Path(__file__).resolve().parent.parent
42
+
43
+ #: The golden cases, relative to the repository root.
44
+ CASES_PATH = Path("fixtures") / "cases.json"
45
+
46
+ #: The exceptions a case may expect; the TypeScript twin throws the mapped JS error.
47
+ RAISES = (ValueError, TypeError)
48
+
49
+
50
+ def public_functions() -> tuple[str, ...]:
51
+ """The functions of the public API (``chordsym.__all__``, classes excluded)."""
52
+ return tuple(
53
+ name for name in chordsym.__all__ if inspect.isfunction(getattr(chordsym, name))
54
+ )
55
+
56
+
57
+ #: Per-function result encoders, for results whose JSON form needs more than to_json.
58
+ _ENCODERS = {
59
+ "transposition_table": lambda r: {
60
+ **r,
61
+ "chords": [list(kv) for kv in r["chords"].items()],
62
+ },
63
+ }
64
+
65
+
66
+ def _decode(value):
67
+ if isinstance(value, dict) and set(value) == {"tonic", "minor"}:
68
+ return Key(value["tonic"], value["minor"])
69
+ if isinstance(value, list):
70
+ return [_decode(v) for v in value]
71
+ return value
72
+
73
+
74
+ def to_json(value):
75
+ """``value`` in the JSON form cases use (tuples as lists, Key as a dict)."""
76
+ if isinstance(value, Key):
77
+ return {"tonic": value.tonic, "minor": value.minor}
78
+ if isinstance(value, (tuple, list)):
79
+ return [to_json(v) for v in value]
80
+ if isinstance(value, dict):
81
+ return {k: to_json(v) for k, v in value.items()}
82
+ return value
83
+
84
+
85
+ def _check_kwargs(name: str, func, kwargs: dict) -> None:
86
+ params = inspect.signature(func).parameters
87
+ for kw in kwargs:
88
+ if kw not in params or params[kw].kind is not inspect.Parameter.KEYWORD_ONLY:
89
+ raise ValueError(
90
+ f"Case for {name}: {kw!r} is not a keyword-only parameter; pass it in args"
91
+ )
92
+
93
+
94
+ def run_case(case: dict):
95
+ """Call the case's function on its arguments; return the result in JSON form."""
96
+ name = case["fn"]
97
+ if name not in public_functions():
98
+ raise ValueError(
99
+ f"Unknown function in case: {name!r}; one of {public_functions()}"
100
+ )
101
+ func = getattr(chordsym, name)
102
+ kwargs = case.get("kwargs", {})
103
+ _check_kwargs(name, func, kwargs)
104
+ try:
105
+ args = [_decode(a) for a in case.get("args", [])]
106
+ result = func(*args, **{k: _decode(v) for k, v in kwargs.items()})
107
+ except RAISES as error:
108
+ return {"$raises": type(error).__name__}
109
+ encoded = to_json(result)
110
+ return _ENCODERS[name](encoded) if name in _ENCODERS else encoded
111
+
112
+
113
+ def same(result, expected) -> bool:
114
+ """Whether two JSON values are equal, order included (what both test suites check)."""
115
+ return json.dumps(result, ensure_ascii=False) == json.dumps(
116
+ expected, ensure_ascii=False
117
+ )
118
+
119
+
120
+ def load(root: Path | str = REPO_ROOT) -> dict:
121
+ """The parsed ``<root>/fixtures/cases.json``."""
122
+ return json.loads((Path(root) / CASES_PATH).read_text(encoding="utf-8"))
123
+
124
+
125
+ def record(root: Path | str = REPO_ROOT) -> Path:
126
+ """Rewrite every case's ``expected`` (and the ``functions`` list) from Python."""
127
+ path = Path(root) / CASES_PATH
128
+ data = load(root)
129
+ data["functions"] = list(public_functions())
130
+ for case in data["cases"]:
131
+ case["expected"] = run_case(case)
132
+ path.write_text(dumps(data), encoding="utf-8")
133
+ return path
134
+
135
+
136
+ def dumps(data: dict) -> str:
137
+ """The cases file's text: one case per line, so a diff shows exactly which cases changed."""
138
+
139
+ def line(obj) -> str:
140
+ return json.dumps(obj, ensure_ascii=False)
141
+
142
+ head = {k: v for k, v in data.items() if k != "cases"}
143
+ rows = ",\n".join(" " + line(case) for case in data["cases"])
144
+ fields = "".join(f"\n {line(k)}: {line(v)}," for k, v in head.items())
145
+ return "{" + fields + '\n "cases": [\n' + rows + "\n ]\n}\n"
146
+
147
+
148
+ if __name__ == "__main__":
149
+ print(record())