Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit e3af029

Browse files
committedJan 27, 2025
Refactor writing rest files
1 parent f3ee277 commit e3af029

File tree

3 files changed

+158
-135
lines changed

3 files changed

+158
-135
lines changed
 

‎beetsplug/lyrics.py

+104-129
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@
1717
from __future__ import annotations
1818

1919
import atexit
20-
import errno
2120
import itertools
2221
import math
23-
import os.path
2422
import re
23+
import textwrap
2524
from contextlib import contextmanager, suppress
2625
from dataclasses import dataclass
2726
from functools import cached_property, partial, total_ordering
2827
from html import unescape
2928
from http import HTTPStatus
29+
from itertools import groupby
30+
from pathlib import Path
3031
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
3132
from urllib.parse import quote, quote_plus, urlencode, urlparse
3233

@@ -56,41 +57,6 @@
5657
USER_AGENT = f"beets/{beets.__version__}"
5758
INSTRUMENTAL_LYRICS = "[Instrumental]"
5859

59-
# The content for the base index.rst generated in ReST mode.
60-
REST_INDEX_TEMPLATE = """Lyrics
61-
======
62-
63-
* :ref:`Song index <genindex>`
64-
* :ref:`search`
65-
66-
Artist index:
67-
68-
.. toctree::
69-
:maxdepth: 1
70-
:glob:
71-
72-
artists/*
73-
"""
74-
75-
# The content for the base conf.py generated.
76-
REST_CONF_TEMPLATE = """# -*- coding: utf-8 -*-
77-
master_doc = 'index'
78-
project = 'Lyrics'
79-
copyright = 'none'
80-
author = 'Various Authors'
81-
latex_documents = [
82-
(master_doc, 'Lyrics.tex', project,
83-
author, 'manual'),
84-
]
85-
epub_title = project
86-
epub_author = author
87-
epub_publisher = author
88-
epub_copyright = copyright
89-
epub_exclude_files = ['search.html']
90-
epub_tocdepth = 1
91-
epub_tocdup = False
92-
"""
93-
9460

9561
class NotFoundError(requests.exceptions.HTTPError):
9662
pass
@@ -868,6 +834,97 @@ def translate(self, lyrics: str) -> str:
868834
return "\n\nSource: ".join(["\n".join(translated_lines), *url])
869835

870836

837+
@dataclass
838+
class RestFiles:
839+
# The content for the base index.rst generated in ReST mode.
840+
REST_INDEX_TEMPLATE = textwrap.dedent("""
841+
Lyrics
842+
======
843+
844+
* :ref:`Song index <genindex>`
845+
* :ref:`search`
846+
847+
Artist index:
848+
849+
.. toctree::
850+
:maxdepth: 1
851+
:glob:
852+
853+
artists/*
854+
""").strip()
855+
856+
# The content for the base conf.py generated.
857+
REST_CONF_TEMPLATE = textwrap.dedent("""
858+
master_doc = "index"
859+
project = "Lyrics"
860+
copyright = "none"
861+
author = "Various Authors"
862+
latex_documents = [
863+
(master_doc, "Lyrics.tex", project, author, "manual"),
864+
]
865+
epub_exclude_files = ["search.html"]
866+
epub_tocdepth = 1
867+
epub_tocdup = False
868+
""").strip()
869+
870+
directory: Path
871+
872+
@cached_property
873+
def artists_dir(self) -> Path:
874+
dir = self.directory / "artists"
875+
dir.mkdir(parents=True, exist_ok=True)
876+
return dir
877+
878+
def write_indexes(self) -> None:
879+
"""Write conf.py and index.rst files necessary for Sphinx
880+
881+
We write minimal configurations that are necessary for Sphinx
882+
to operate. We do not overwrite existing files so that
883+
customizations are respected."""
884+
index_file = self.directory / "index.rst"
885+
if not index_file.exists():
886+
index_file.write_text(self.REST_INDEX_TEMPLATE)
887+
conf_file = self.directory / "conf.py"
888+
if not conf_file.exists():
889+
conf_file.write_text(self.REST_CONF_TEMPLATE)
890+
891+
def write_artist(self, artist: str, items: Iterable[Item]) -> None:
892+
parts = [
893+
f'{artist}\n{"=" * len(artist)}',
894+
".. contents::\n :local:",
895+
]
896+
for album, items in groupby(items, key=lambda i: i.album):
897+
parts.append(f'{album}\n{"-" * len(album)}')
898+
parts.extend(
899+
part
900+
for i in items
901+
if (title := f":index:`{i.title.strip()}`")
902+
for part in (
903+
f'{title}\n{"~" * len(title)}',
904+
textwrap.indent(i.lyrics, "| "),
905+
)
906+
)
907+
file = self.artists_dir / f"{slug(artist)}.rst"
908+
file.write_text("\n\n".join(parts).strip())
909+
910+
def write(self, items: list[Item]) -> None:
911+
self.directory.mkdir(exist_ok=True, parents=True)
912+
self.write_indexes()
913+
914+
items.sort(key=lambda i: i.albumartist)
915+
for artist, artist_items in groupby(items, key=lambda i: i.albumartist):
916+
self.write_artist(artist.strip(), artist_items)
917+
918+
d = self.directory
919+
text = f"""
920+
ReST files generated. to build, use one of:
921+
sphinx-build -b html {d} {d/"html"}
922+
sphinx-build -b epub {d} {d/"epub"}
923+
sphinx-build -b latex {d} {d/"latex"} && make -C {d/"latex"} all-pdf
924+
"""
925+
ui.print_(textwrap.dedent(text))
926+
927+
871928
class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
872929
BACKEND_BY_NAME = {
873930
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
@@ -925,15 +982,6 @@ def __init__(self):
925982
self.config["google_engine_ID"].redact = True
926983
self.config["genius_api_key"].redact = True
927984

928-
# State information for the ReST writer.
929-
# First, the current artist we're writing.
930-
self.artist = "Unknown artist"
931-
# The current album: False means no album yet.
932-
self.album = False
933-
# The current rest file content. None means the file is not
934-
# open yet.
935-
self.rest = None
936-
937985
def commands(self):
938986
cmd = ui.Subcommand("lyrics", help="fetch song lyrics")
939987
cmd.parser.add_option(
@@ -947,7 +995,7 @@ def commands(self):
947995
cmd.parser.add_option(
948996
"-r",
949997
"--write-rest",
950-
dest="writerest",
998+
dest="rest_directory",
951999
action="store",
9521000
default=None,
9531001
metavar="dir",
@@ -973,99 +1021,26 @@ def commands(self):
9731021
def func(lib, opts, args):
9741022
# The "write to files" option corresponds to the
9751023
# import_write config value.
976-
write = ui.should_write()
977-
if opts.writerest:
978-
self.writerest_indexes(opts.writerest)
979-
items = lib.items(ui.decargs(args))
1024+
items = list(lib.items(ui.decargs(args)))
9801025
for item in items:
9811026
if not opts.local_only and not self.config["local"]:
9821027
self.fetch_item_lyrics(
983-
item, write, opts.force_refetch or self.config["force"]
1028+
item,
1029+
ui.should_write(),
1030+
opts.force_refetch or self.config["force"],
9841031
)
9851032
if item.lyrics:
9861033
if opts.printlyr:
9871034
ui.print_(item.lyrics)
988-
if opts.writerest:
989-
self.appendrest(opts.writerest, item)
990-
if opts.writerest and items:
991-
# flush last artist & write to ReST
992-
self.writerest(opts.writerest)
993-
ui.print_("ReST files generated. to build, use one of:")
994-
ui.print_(
995-
" sphinx-build -b html %s _build/html" % opts.writerest
996-
)
997-
ui.print_(
998-
" sphinx-build -b epub %s _build/epub" % opts.writerest
999-
)
1000-
ui.print_(
1001-
(
1002-
" sphinx-build -b latex %s _build/latex "
1003-
"&& make -C _build/latex all-pdf"
1004-
)
1005-
% opts.writerest
1006-
)
1035+
1036+
if opts.rest_directory and (
1037+
items := [i for i in items if i.lyrics]
1038+
):
1039+
RestFiles(Path(opts.rest_directory)).write(items)
10071040

10081041
cmd.func = func
10091042
return [cmd]
10101043

1011-
def appendrest(self, directory, item):
1012-
"""Append the item to an ReST file
1013-
1014-
This will keep state (in the `rest` variable) in order to avoid
1015-
writing continuously to the same files.
1016-
"""
1017-
1018-
if slug(self.artist) != slug(item.albumartist):
1019-
# Write current file and start a new one ~ item.albumartist
1020-
self.writerest(directory)
1021-
self.artist = item.albumartist.strip()
1022-
self.rest = "%s\n%s\n\n.. contents::\n :local:\n\n" % (
1023-
self.artist,
1024-
"=" * len(self.artist),
1025-
)
1026-
1027-
if self.album != item.album:
1028-
tmpalbum = self.album = item.album.strip()
1029-
if self.album == "":
1030-
tmpalbum = "Unknown album"
1031-
self.rest += "{}\n{}\n\n".format(tmpalbum, "-" * len(tmpalbum))
1032-
title_str = ":index:`%s`" % item.title.strip()
1033-
block = "| " + item.lyrics.replace("\n", "\n| ")
1034-
self.rest += "{}\n{}\n\n{}\n\n".format(
1035-
title_str, "~" * len(title_str), block
1036-
)
1037-
1038-
def writerest(self, directory):
1039-
"""Write self.rest to a ReST file"""
1040-
if self.rest is not None and self.artist is not None:
1041-
path = os.path.join(
1042-
directory, "artists", slug(self.artist) + ".rst"
1043-
)
1044-
with open(path, "wb") as output:
1045-
output.write(self.rest.encode("utf-8"))
1046-
1047-
def writerest_indexes(self, directory):
1048-
"""Write conf.py and index.rst files necessary for Sphinx
1049-
1050-
We write minimal configurations that are necessary for Sphinx
1051-
to operate. We do not overwrite existing files so that
1052-
customizations are respected."""
1053-
try:
1054-
os.makedirs(os.path.join(directory, "artists"))
1055-
except OSError as e:
1056-
if e.errno == errno.EEXIST:
1057-
pass
1058-
else:
1059-
raise
1060-
indexfile = os.path.join(directory, "index.rst")
1061-
if not os.path.exists(indexfile):
1062-
with open(indexfile, "w") as output:
1063-
output.write(REST_INDEX_TEMPLATE)
1064-
conffile = os.path.join(directory, "conf.py")
1065-
if not os.path.exists(conffile):
1066-
with open(conffile, "w") as output:
1067-
output.write(REST_CONF_TEMPLATE)
1068-
10691044
def imported(self, _, task: ImportTask) -> None:
10701045
"""Import hook for fetching lyrics automatically."""
10711046
if self.config["auto"]:

‎docs/plugins/lyrics.rst

+5-6
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,8 @@ Rendering Lyrics into Other Formats
107107
-----------------------------------
108108

109109
The ``-r directory, --write-rest directory`` option renders all lyrics as
110-
`reStructuredText`_ (ReST) documents in ``directory`` (by default, the current
111-
directory). That directory, in turn, can be parsed by tools like `Sphinx`_ to
112-
generate HTML, ePUB, or PDF documents.
110+
`reStructuredText`_ (ReST) documents in ``directory``. That directory, in turn,
111+
can be parsed by tools like `Sphinx`_ to generate HTML, ePUB, or PDF documents.
113112

114113
Minimal ``conf.py`` and ``index.rst`` files are created the first time the
115114
command is run. They are not overwritten on subsequent runs, so you can safely
@@ -122,19 +121,19 @@ Sphinx supports various `builders`_, see a few suggestions:
122121

123122
::
124123

125-
sphinx-build -b html . _build/html
124+
sphinx-build -b html <dir> <dir>/html
126125

127126
.. admonition:: Build an ePUB3 formatted file, usable on ebook readers
128127

129128
::
130129

131-
sphinx-build -b epub3 . _build/epub
130+
sphinx-build -b epub3 <dir> <dir>/epub
132131

133132
.. admonition:: Build a PDF file, which incidentally also builds a LaTeX file
134133

135134
::
136135

137-
sphinx-build -b latex %s _build/latex && make -C _build/latex all-pdf
136+
sphinx-build -b latex <dir> <dir>/latex && make -C <dir>/latex all-pdf
138137

139138

140139
.. _Sphinx: https://www.sphinx-doc.org/

‎test/plugins/test_lyrics.py

+49
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import textwrap
1919
from functools import partial
2020
from http import HTTPStatus
21+
from pathlib import Path
2122

2223
import pytest
2324

@@ -587,3 +588,51 @@ def test_translate(self, initial_lyrics, expected):
587588
assert bing.translate(
588589
textwrap.dedent(initial_lyrics)
589590
) == textwrap.dedent(expected)
591+
592+
593+
class TestRestFiles:
594+
@pytest.fixture
595+
def rest_dir(self, tmp_path):
596+
return tmp_path
597+
598+
@pytest.fixture
599+
def rest_files(self, rest_dir):
600+
return lyrics.RestFiles(rest_dir)
601+
602+
def test_write(self, rest_dir: Path, rest_files):
603+
items = [
604+
Item(albumartist=aa, album=a, title=t, lyrics=lyr)
605+
for aa, a, t, lyr in [
606+
("Artist One", "Album One", "Song One", "Lyrics One"),
607+
("Artist One", "Album One", "Song Two", "Lyrics Two"),
608+
("Artist Two", "Album Two", "Song Three", "Lyrics Three"),
609+
]
610+
]
611+
612+
rest_files.write(items)
613+
614+
assert (rest_dir / "index.rst").exists()
615+
assert (rest_dir / "conf.py").exists()
616+
617+
artist_one_file = rest_dir / "artists" / "artist-one.rst"
618+
artist_two_file = rest_dir / "artists" / "artist-two.rst"
619+
assert artist_one_file.exists()
620+
assert artist_two_file.exists()
621+
622+
c = artist_one_file.read_text()
623+
assert (
624+
c.index("Artist One")
625+
< c.index("Album One")
626+
< c.index("Song One")
627+
< c.index("Lyrics One")
628+
< c.index("Song Two")
629+
< c.index("Lyrics Two")
630+
)
631+
632+
c = artist_two_file.read_text()
633+
assert (
634+
c.index("Artist Two")
635+
< c.index("Album Two")
636+
< c.index("Song Three")
637+
< c.index("Lyrics Three")
638+
)

0 commit comments

Comments
 (0)