songleaf 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,120 @@
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
songleaf-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,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: songleaf
3
+ Version: 0.0.2
4
+ Summary: Dense, readable one-page song sheets (lyrics + chords + score snippets) from searchable sources
5
+ Project-URL: Homepage, https://github.com/thorwhalen/songleaf
6
+ Project-URL: Repository, https://github.com/thorwhalen/songleaf
7
+ Project-URL: Documentation, https://thorwhalen.github.io/songleaf
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: chords,lead-sheet,lyrics,music,pdf,song-sheet
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: cw<0.2,>=0.1.4
20
+ Requires-Dist: dol
21
+ Requires-Dist: pandas
22
+ Requires-Dist: rapidfuzz>=3
23
+ Requires-Dist: reportlab>=4
24
+ Requires-Dist: sung>=0.0.26
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
31
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # songleaf
35
+
36
+ Dense, readable one-page song sheets: lyrics and chords on one A4 page, with the lyrics as large as will fit.
37
+
38
+ ```bash
39
+ pip install songleaf
40
+ python -m songleaf sheet "wonderwall oasis" # -> a one-page PDF in ~/.local/share/songleaf/sheets/
41
+ ```
42
+
43
+ The default source is the Kaggle *chords-and-lyrics* corpus (about 135,000 songs), read from a local copy of its zip (see [Sources](#sources)).
44
+
45
+ ## What the sheet looks like
46
+
47
+ - **One page, largest type that fits.** The lyric font size is found by search for each song. On 1,500 randomly sampled corpus songs, the median came out around 22 pt, and 1,498 of the 1,500 fit on one page. A song that cannot fit at the minimum size spills onto a second page.
48
+ - **Chords take no line of their own.** They are smaller and blue, start over the syllable they land on, and overlap the tops of the letters of their lyric line.
49
+ - **Lines share rows.** Consecutive lines of a paragraph are packed onto one row, separated by a light `/`. A blank line or a new section starts a new row.
50
+ - **Small labels.** Sections are grey prefixes (`V1`, `Ch`, `Br`); title, artist, capo and key are one small line at the top.
51
+
52
+ ## Command line
53
+
54
+ ```bash
55
+ python -m songleaf search "wonderwall oasis" # fuzzy: typos and word order don't matter
56
+ python -m songleaf search --artist oasis --lyrics "roads winding"
57
+ python -m songleaf sheet "wonderwall oasis" --output wonderwall.pdf
58
+ python -m songleaf sheet "wonderwall oasis" --pick 2 # the second-best match
59
+ python -m songleaf songs # songs already stored
60
+ python -m songleaf sheet kaggle_chords:1234 # a song by its key: no search
61
+ python -m songleaf sheet kaggle_chords:1234 --refresh # fetch it again from its source
62
+ ```
63
+
64
+ The `songleaf` console script is the same command.
65
+
66
+ ## Python
67
+
68
+ ```python
69
+ import songleaf
70
+
71
+ hits = songleaf.search("wonderwall oasis")
72
+ song = songleaf.get_song(hits[0].key) # a Song: lyrics text + annotations
73
+ songleaf.render_dense_a4(song, "wonderwall.pdf") # {'font_size': ..., 'pages': 1, ...}
74
+
75
+ songleaf.sheet("wonderwall oasis") # search, store and render in one call
76
+ ```
77
+
78
+ ## The song model
79
+
80
+ A `Song` is lyrics text plus *standoff* annotations anchored on character offsets, so annotation layers never touch the text:
81
+
82
+ - `section`: spans its lines, `{"label": "Chorus"}`;
83
+ - `chord`: a point on the first character of the syllable the chord lands on, `{"symbol": "G/B", "timing": "at"}` (`"before"` marks a chord that sounds ahead of that syllable);
84
+ - `score`: a link from a stretch of lyrics to a score snippet, `{"source", "id", "url", "format"}`.
85
+
86
+ Metadata (title, artist, capo, key) and provenance (source, id, url, licence) travel with the song, and `Song.to_dict()` is its JSON form.
87
+
88
+ ```python
89
+ from songleaf import Song, chord, section, parse_chords_over_lyrics
90
+
91
+ song = Song(
92
+ "Paper boats drift", [section(0, 17, "Verse 1"), chord(0, "G"), chord(6, "D")]
93
+ )
94
+ song = parse_chords_over_lyrics(
95
+ "G D\nPaper boats drift"
96
+ ) # the same chords, from a chart
97
+ ```
98
+
99
+ ## Storage
100
+
101
+ Songs are kept in a `MutableMapping` (`songleaf.song_store()`): one JSON file per song under `~/.local/share/songleaf/songs/`. Rendered sheets go to `~/.local/share/songleaf/sheets/`. Set `SONGLEAF_DATA_DIR` to move both.
102
+
103
+ ## Sources
104
+
105
+ `KaggleChordsSource` reads the [chords-and-lyrics dataset](https://www.kaggle.com/datasets/eitanbentora/chords-and-lyrics-dataset) through `sung`. It needs a local copy of the zip: point `SUNG_CHORDS_AND_LYRICS_ZIP` at it, or keep it where `haggle` downloads it (`$HAGGLE_ROOTDIR/zips/eitanbentora/chords-and-lyrics-dataset.zip`). Without a local copy, `sung` downloads it from Kaggle, which needs Kaggle credentials. The corpus was scraped from a chords site, so keep it to personal use.
106
+
107
+ Loading the corpus takes about five seconds on the first search in a process.
108
+
109
+ ## Extending
110
+
111
+ Three keyword arguments are the extension points, on `sheet` (and `search` for sources):
112
+
113
+ - `sources=`: objects with a `name`, `search(query, *, title, artist, lyrics, limit)` returning `Hit`s, and `get(song_id)` returning a `Song`;
114
+ - `store=`: any `MutableMapping[str, Song]` (a `dict` works);
115
+ - `renderer=`: any `(song, output) -> dict` function.
116
+
117
+ ```python
118
+ songleaf.sheet("paper boats", sources=[my_source], store={}, renderer=my_renderer)
119
+ ```
@@ -0,0 +1,86 @@
1
+ # songleaf
2
+
3
+ Dense, readable one-page song sheets: lyrics and chords on one A4 page, with the lyrics as large as will fit.
4
+
5
+ ```bash
6
+ pip install songleaf
7
+ python -m songleaf sheet "wonderwall oasis" # -> a one-page PDF in ~/.local/share/songleaf/sheets/
8
+ ```
9
+
10
+ The default source is the Kaggle *chords-and-lyrics* corpus (about 135,000 songs), read from a local copy of its zip (see [Sources](#sources)).
11
+
12
+ ## What the sheet looks like
13
+
14
+ - **One page, largest type that fits.** The lyric font size is found by search for each song. On 1,500 randomly sampled corpus songs, the median came out around 22 pt, and 1,498 of the 1,500 fit on one page. A song that cannot fit at the minimum size spills onto a second page.
15
+ - **Chords take no line of their own.** They are smaller and blue, start over the syllable they land on, and overlap the tops of the letters of their lyric line.
16
+ - **Lines share rows.** Consecutive lines of a paragraph are packed onto one row, separated by a light `/`. A blank line or a new section starts a new row.
17
+ - **Small labels.** Sections are grey prefixes (`V1`, `Ch`, `Br`); title, artist, capo and key are one small line at the top.
18
+
19
+ ## Command line
20
+
21
+ ```bash
22
+ python -m songleaf search "wonderwall oasis" # fuzzy: typos and word order don't matter
23
+ python -m songleaf search --artist oasis --lyrics "roads winding"
24
+ python -m songleaf sheet "wonderwall oasis" --output wonderwall.pdf
25
+ python -m songleaf sheet "wonderwall oasis" --pick 2 # the second-best match
26
+ python -m songleaf songs # songs already stored
27
+ python -m songleaf sheet kaggle_chords:1234 # a song by its key: no search
28
+ python -m songleaf sheet kaggle_chords:1234 --refresh # fetch it again from its source
29
+ ```
30
+
31
+ The `songleaf` console script is the same command.
32
+
33
+ ## Python
34
+
35
+ ```python
36
+ import songleaf
37
+
38
+ hits = songleaf.search("wonderwall oasis")
39
+ song = songleaf.get_song(hits[0].key) # a Song: lyrics text + annotations
40
+ songleaf.render_dense_a4(song, "wonderwall.pdf") # {'font_size': ..., 'pages': 1, ...}
41
+
42
+ songleaf.sheet("wonderwall oasis") # search, store and render in one call
43
+ ```
44
+
45
+ ## The song model
46
+
47
+ A `Song` is lyrics text plus *standoff* annotations anchored on character offsets, so annotation layers never touch the text:
48
+
49
+ - `section`: spans its lines, `{"label": "Chorus"}`;
50
+ - `chord`: a point on the first character of the syllable the chord lands on, `{"symbol": "G/B", "timing": "at"}` (`"before"` marks a chord that sounds ahead of that syllable);
51
+ - `score`: a link from a stretch of lyrics to a score snippet, `{"source", "id", "url", "format"}`.
52
+
53
+ Metadata (title, artist, capo, key) and provenance (source, id, url, licence) travel with the song, and `Song.to_dict()` is its JSON form.
54
+
55
+ ```python
56
+ from songleaf import Song, chord, section, parse_chords_over_lyrics
57
+
58
+ song = Song(
59
+ "Paper boats drift", [section(0, 17, "Verse 1"), chord(0, "G"), chord(6, "D")]
60
+ )
61
+ song = parse_chords_over_lyrics(
62
+ "G D\nPaper boats drift"
63
+ ) # the same chords, from a chart
64
+ ```
65
+
66
+ ## Storage
67
+
68
+ Songs are kept in a `MutableMapping` (`songleaf.song_store()`): one JSON file per song under `~/.local/share/songleaf/songs/`. Rendered sheets go to `~/.local/share/songleaf/sheets/`. Set `SONGLEAF_DATA_DIR` to move both.
69
+
70
+ ## Sources
71
+
72
+ `KaggleChordsSource` reads the [chords-and-lyrics dataset](https://www.kaggle.com/datasets/eitanbentora/chords-and-lyrics-dataset) through `sung`. It needs a local copy of the zip: point `SUNG_CHORDS_AND_LYRICS_ZIP` at it, or keep it where `haggle` downloads it (`$HAGGLE_ROOTDIR/zips/eitanbentora/chords-and-lyrics-dataset.zip`). Without a local copy, `sung` downloads it from Kaggle, which needs Kaggle credentials. The corpus was scraped from a chords site, so keep it to personal use.
73
+
74
+ Loading the corpus takes about five seconds on the first search in a process.
75
+
76
+ ## Extending
77
+
78
+ Three keyword arguments are the extension points, on `sheet` (and `search` for sources):
79
+
80
+ - `sources=`: objects with a `name`, `search(query, *, title, artist, lyrics, limit)` returning `Hit`s, and `get(song_id)` returning a `Song`;
81
+ - `store=`: any `MutableMapping[str, Song]` (a `dict` works);
82
+ - `renderer=`: any `(song, output) -> dict` function.
83
+
84
+ ```python
85
+ songleaf.sheet("paper boats", sources=[my_source], store={}, renderer=my_renderer)
86
+ ```
@@ -0,0 +1,192 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ ]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "songleaf"
9
+ version = "0.0.2"
10
+ description = "Dense, readable one-page song sheets (lyrics + chords + score snippets) from searchable sources"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ license = "MIT"
14
+ keywords = [
15
+ "music",
16
+ "lyrics",
17
+ "chords",
18
+ "song-sheet",
19
+ "lead-sheet",
20
+ "pdf",
21
+ ]
22
+ authors = [
23
+ { name = "Thor Whalen" },
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 3 - Alpha",
27
+ "Intended Audience :: End Users/Desktop",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Topic :: Multimedia :: Sound/Audio",
32
+ ]
33
+ dependencies = [
34
+ "cw>=0.1.4,<0.2",
35
+ "dol",
36
+ "pandas",
37
+ "rapidfuzz>=3",
38
+ "reportlab>=4",
39
+ "sung>=0.0.26",
40
+ ]
41
+
42
+ [project.scripts]
43
+ songleaf = "songleaf.__main__:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/thorwhalen/songleaf"
47
+ Repository = "https://github.com/thorwhalen/songleaf"
48
+ Documentation = "https://thorwhalen.github.io/songleaf"
49
+
50
+ [project.optional-dependencies]
51
+ dev = [
52
+ "pytest>=7.0",
53
+ "pytest-cov>=4.0",
54
+ "ruff>=0.1.0",
55
+ ]
56
+ docs = [
57
+ "sphinx>=6.0",
58
+ "sphinx-rtd-theme>=1.0",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["songleaf"]
63
+
64
+ # An allowlist, so a source distribution never ships anything but code, tests and docs.
65
+ [tool.hatch.build.targets.sdist]
66
+ include = [
67
+ "songleaf/**/*.py",
68
+ "tests/**/*.py",
69
+ "README.md",
70
+ "LICENSE",
71
+ ]
72
+
73
+ [tool.ruff]
74
+ line-length = 88
75
+ target-version = "py310"
76
+ exclude = [
77
+ "**/*.ipynb",
78
+ ".git",
79
+ ".venv",
80
+ "build",
81
+ "dist",
82
+ "tests",
83
+ "examples",
84
+ "scrap",
85
+ ]
86
+
87
+ [tool.ruff.lint]
88
+ select = [
89
+ "D100",
90
+ ]
91
+ ignore = [
92
+ "D203",
93
+ "E501",
94
+ "B905",
95
+ ]
96
+
97
+ [tool.ruff.lint.pydocstyle]
98
+ convention = "google"
99
+
100
+ [tool.ruff.lint.per-file-ignores]
101
+ "**/tests/*" = [
102
+ "D",
103
+ ]
104
+ "**/examples/*" = [
105
+ "D",
106
+ ]
107
+ "**/scrap/*" = [
108
+ "D",
109
+ ]
110
+
111
+ [tool.pytest.ini_options]
112
+ minversion = "6.0"
113
+ testpaths = [
114
+ "tests",
115
+ ]
116
+ doctest_optionflags = [
117
+ "NORMALIZE_WHITESPACE",
118
+ "ELLIPSIS",
119
+ ]
120
+
121
+ [tool.wads.ci]
122
+ project_name = ""
123
+
124
+ [tool.wads.ci.commands]
125
+ pre_test = []
126
+ test = []
127
+ post_test = []
128
+ lint = []
129
+ format = []
130
+
131
+ [tool.wads.ci.env]
132
+ required_envvars = []
133
+ test_envvars = []
134
+ extra_envvars = []
135
+
136
+ [tool.wads.ci.env.defaults]
137
+
138
+ [tool.wads.ci.quality.ruff]
139
+ enabled = true
140
+
141
+ [tool.wads.ci.quality.black]
142
+ enabled = false
143
+
144
+ [tool.wads.ci.quality.mypy]
145
+ enabled = false
146
+
147
+ [tool.wads.ci.testing]
148
+ enabled = true
149
+ python_versions = [
150
+ "3.10",
151
+ "3.12",
152
+ ]
153
+ pytest_args = [
154
+ "-v",
155
+ "--tb=short",
156
+ ]
157
+ coverage_enabled = true
158
+ coverage_threshold = 0
159
+ coverage_report_format = [
160
+ "term",
161
+ "xml",
162
+ ]
163
+ exclude_paths = [
164
+ "examples",
165
+ "scrap",
166
+ ]
167
+ test_on_windows = true
168
+
169
+ [tool.wads.ci.metrics]
170
+ enabled = true
171
+ config_path = ".github/umpyre-config.yml"
172
+ storage_branch = "code-metrics"
173
+ python_version = "3.10"
174
+ force_run = false
175
+
176
+ [tool.wads.ci.build]
177
+ sdist = true
178
+ wheel = true
179
+
180
+ [tool.wads.ci.publish]
181
+ enabled = true
182
+ skip_ci_marker = "[skip ci]"
183
+ publish_marker = "[publish]"
184
+
185
+ [tool.wads.ci.docs]
186
+ enabled = true
187
+ builder = "epythet"
188
+ ignore_paths = [
189
+ "tests/",
190
+ "scrap/",
191
+ "examples/",
192
+ ]
@@ -0,0 +1,43 @@
1
+ """songleaf: dense, readable one-page song sheets from searchable sources.
2
+
3
+ Find a song, keep it as lyrics plus anchored annotations (sections, chords,
4
+ provenance, score links), and render it onto one A4 page with the lyrics as
5
+ large as will fit.
6
+
7
+ >>> import songleaf
8
+ >>> song = songleaf.parse_chords_over_lyrics("C G\\nHello there my friend")
9
+ >>> [a.body["symbol"] for a in song.of_kind("chord")]
10
+ ['C', 'G']
11
+
12
+ From a query to a PDF, on the Kaggle chords-and-lyrics corpus::
13
+
14
+ songleaf.sheet("wonderwall oasis") # -> {'path': '.../sheets/oasis-wonderwall.pdf', ...}
15
+
16
+ or ``python -m songleaf sheet "wonderwall oasis"``.
17
+ """
18
+
19
+ from songleaf.model import Annotation, Song, chord, score_link, section
20
+ from songleaf.parse import parse_chords_over_lyrics
21
+ from songleaf.render import DenseStyle, fit_font_size, render_dense_a4
22
+ from songleaf.sources import Hit, KaggleChordsSource, get_song, search
23
+ from songleaf.store import song_store
24
+ from songleaf.tools import sheet, songs
25
+
26
+ __all__ = [
27
+ "Annotation",
28
+ "DenseStyle",
29
+ "Hit",
30
+ "KaggleChordsSource",
31
+ "Song",
32
+ "chord",
33
+ "fit_font_size",
34
+ "get_song",
35
+ "parse_chords_over_lyrics",
36
+ "render_dense_a4",
37
+ "score_link",
38
+ "search",
39
+ "section",
40
+ "sheet",
41
+ "song_store",
42
+ "songs",
43
+ ]
@@ -0,0 +1,55 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``python -m songleaf``: the command line, ``cw`` over :data:`songleaf.tools.TOOLS`.
3
+
4
+ Examples::
5
+
6
+ python -m songleaf search "wonderwall oasis"
7
+ python -m songleaf sheet "wonderwall oasis" # -> a one-page A4 PDF
8
+ python -m songleaf sheet kaggle_chords:1234 --output sheet.pdf
9
+ python -m songleaf songs
10
+ """
11
+
12
+ import functools
13
+ import inspect
14
+
15
+ import cw
16
+
17
+ from songleaf import tools
18
+
19
+ #: Dependency-injection parameters: seams for library callers, not CLI options.
20
+ _SEAMS = ("sources", "store", "renderer")
21
+
22
+
23
+ def _with_expected_errors(tool):
24
+ """A failure the user can act on becomes one line on stderr, not a traceback."""
25
+
26
+ @functools.wraps(tool)
27
+ def command(*args, **kwargs):
28
+ try:
29
+ return tool(*args, **kwargs)
30
+ except (LookupError, RuntimeError, ValueError) as error:
31
+ raise cw.CommandError(str(error)) from error
32
+
33
+ return command
34
+
35
+
36
+ def main(argv=None):
37
+ """Run one songleaf command."""
38
+ config = {
39
+ tool.__name__: {
40
+ name: cw.HIDE
41
+ for name in _SEAMS
42
+ if name in inspect.signature(tool).parameters
43
+ }
44
+ for tool in tools.TOOLS
45
+ }
46
+ commands = [_with_expected_errors(tool) for tool in tools.TOOLS]
47
+ raise SystemExit(
48
+ cw.dispatch(
49
+ commands, argv, prog="songleaf", convention=cw.MODERN, config=config
50
+ )
51
+ )
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()