Build the arbitration input with a per-ID extras merge
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,11 @@ agreed. The quotes supporting a keyword never take part in the
|
|||||||
comparison; they are carried along as the evidence of the disagreed
|
comparison; they are carried along as the evidence of the disagreed
|
||||||
keyword they support. The disagreements are written as a JSON file,
|
keyword they support. The disagreements are written as a JSON file,
|
||||||
as :data:`DISAGREEMENTS_JSON`, holding only the songs the two runs
|
as :data:`DISAGREEMENTS_JSON`, holding only the songs the two runs
|
||||||
disagree on; it is what the arbitration step of the coding
|
disagree on, each song's disagreed keywords wrapped in a
|
||||||
procedure settles. The agreed half is not written; it is returned
|
"disagreements" object so that the file merges into the arbitration
|
||||||
|
input as the per-song extra parameters; it is what the arbitration
|
||||||
|
step of the coding procedure settles. The agreed half is not
|
||||||
|
written; it is returned
|
||||||
by :func:`compare_codings`, as the keyword names alone, for later
|
by :func:`compare_codings`, as the keyword names alone, for later
|
||||||
steps to consume. The step is fully deterministic; no LLM call is
|
steps to consume. The step is fully deterministic; no LLM call is
|
||||||
made.
|
made.
|
||||||
@@ -229,10 +232,13 @@ def write_disagreements(path: Path,
|
|||||||
"""Write the per-song disagreement JSON file.
|
"""Write the per-song disagreement JSON file.
|
||||||
|
|
||||||
Writes a JSON file holding a single object mapping the song ID
|
Writes a JSON file holding a single object mapping the song ID
|
||||||
to the disagreed keywords of that song, each with the quotes of
|
to a ``{"disagreements": {...}}`` object holding the disagreed
|
||||||
the run that assigned it, in the given order, UTF-8, with a
|
keywords of that song, each with the quotes of the run that
|
||||||
trailing newline. Only the songs the two runs disagree on are
|
assigned it, in the given order, UTF-8, with a trailing newline.
|
||||||
written.
|
The wrapper is what makes the file merge into the arbitration
|
||||||
|
input as the per-song extra parameters, giving a "disagreements"
|
||||||
|
sibling of the lyrics rather than loose keywords. Only the
|
||||||
|
songs the two runs disagree on are written.
|
||||||
|
|
||||||
:param path: The path of the disagreement JSON file to write.
|
:param path: The path of the disagreement JSON file to write.
|
||||||
:param disagreements: The disagreed keywords of every song,
|
:param disagreements: The disagreed keywords of every song,
|
||||||
@@ -240,9 +246,11 @@ def write_disagreements(path: Path,
|
|||||||
:return: None.
|
:return: None.
|
||||||
:raises OSError: When the file cannot be written.
|
:raises OSError: When the file cannot be written.
|
||||||
"""
|
"""
|
||||||
|
wrapped: dict[str, dict[str, Coding]] = {
|
||||||
|
song_id: {"disagreements": coding}
|
||||||
|
for song_id, coding in disagreements.items()}
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps(disagreements, ensure_ascii=False, indent=2)
|
json.dumps(wrapped, ensure_ascii=False, indent=2) + "\n",
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ object serialized as a string, its ``lyrics`` key holding the
|
|||||||
song's lyrics followed by the keys of the given extras file in
|
song's lyrics followed by the keys of the given extras file in
|
||||||
their file order, so a step that needs parameters alongside the
|
their file order, so a step that needs parameters alongside the
|
||||||
lyrics can carry them without this module knowing what they mean.
|
lyrics can carry them without this module knowing what they mean.
|
||||||
|
|
||||||
|
With ``--extras-per-id``, the same merge happens per song: the
|
||||||
|
given file maps a song ID to the extra keys of that one song, and
|
||||||
|
the export is restricted to the song IDs the file names, so a step
|
||||||
|
that revisits only some of the songs, each with its own parameters,
|
||||||
|
gets exactly those records. The two options may be given together,
|
||||||
|
in which case a record's keys are ``lyrics``, the shared extras'
|
||||||
|
keys, then that song's own keys, each group in its file order.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -50,6 +58,14 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
" becomes a JSON object string with a \"lyrics\" key"
|
" becomes a JSON object string with a \"lyrics\" key"
|
||||||
" followed by the extras' keys, instead of the bare"
|
" followed by the extras' keys, instead of the bare"
|
||||||
" lyrics string")
|
" lyrics string")
|
||||||
|
parser.add_argument(
|
||||||
|
"--extras-per-id", type=Path, default=None,
|
||||||
|
help="a JSON file holding a single JSON object that maps a"
|
||||||
|
" song ID, as \"song-<N>\", to a JSON object of extra"
|
||||||
|
" parameters for that one song; the song's object is"
|
||||||
|
" merged into its \"content\" the same way as with"
|
||||||
|
" --extras, and the export is restricted to the song"
|
||||||
|
" IDs the file names")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +88,31 @@ def _no_duplicate_keys(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
"""Load a single JSON object from a file, in file order.
|
||||||
|
|
||||||
|
:param path: The JSON file.
|
||||||
|
:param label: The kind of file, for the error messages.
|
||||||
|
:return: The object, in file order.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
:raises ValueError: When the file is not valid JSON, is not
|
||||||
|
a JSON object, or has duplicate keys.
|
||||||
|
"""
|
||||||
|
with open(path, encoding="utf-8") as file:
|
||||||
|
text: str = file.read()
|
||||||
|
try:
|
||||||
|
data: Any = json.loads(
|
||||||
|
text, object_pairs_hook=_no_duplicate_keys)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid JSON in {label} file {path}: {error}") \
|
||||||
|
from error
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} file {path} must contain a JSON object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def load_extras(path: Path) -> dict[str, Any]:
|
def load_extras(path: Path) -> dict[str, Any]:
|
||||||
"""Load the extras object from a JSON file.
|
"""Load the extras object from a JSON file.
|
||||||
|
|
||||||
@@ -81,64 +122,111 @@ def load_extras(path: Path) -> dict[str, Any]:
|
|||||||
:raises ValueError: When the file is not valid JSON, is not
|
:raises ValueError: When the file is not valid JSON, is not
|
||||||
a JSON object, has duplicate keys, or has a "lyrics" key.
|
a JSON object, has duplicate keys, or has a "lyrics" key.
|
||||||
"""
|
"""
|
||||||
with open(path, encoding="utf-8") as file:
|
data: dict[str, Any] = _load_json_object(path, "extras")
|
||||||
text: str = file.read()
|
|
||||||
try:
|
|
||||||
data: Any = json.loads(
|
|
||||||
text, object_pairs_hook=_no_duplicate_keys)
|
|
||||||
except json.JSONDecodeError as error:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid JSON in extras file {path}: {error}") \
|
|
||||||
from error
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError(
|
|
||||||
f"extras file {path} must contain a JSON object")
|
|
||||||
if "lyrics" in data:
|
if "lyrics" in data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"extras file {path} must not have a \"lyrics\" key")
|
f"extras file {path} must not have a \"lyrics\" key")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_extras_per_id(path: Path) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Load the per-ID extras object from a JSON file.
|
||||||
|
|
||||||
|
:param path: The per-ID extras JSON file, mapping a song ID,
|
||||||
|
as ``song-<N>``, to the extras of that one song.
|
||||||
|
:return: The extras of each song ID, in file order, every
|
||||||
|
song's own extras in their file order too.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
:raises ValueError: When the file is not valid JSON, is not
|
||||||
|
a JSON object, has duplicate keys, has a song whose value
|
||||||
|
is not a JSON object, or has a song with a "lyrics" key.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = _load_json_object(path, "per-ID extras")
|
||||||
|
song_id: str
|
||||||
|
extras: Any
|
||||||
|
for song_id, extras in data.items():
|
||||||
|
if not isinstance(extras, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"per-ID extras file {path}: id {song_id} must"
|
||||||
|
" have a JSON object")
|
||||||
|
if "lyrics" in extras:
|
||||||
|
raise ValueError(
|
||||||
|
f"per-ID extras file {path}: id {song_id} must not"
|
||||||
|
" have a \"lyrics\" key")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def build_lines(
|
def build_lines(
|
||||||
session: Session,
|
session: Session,
|
||||||
extras: dict[str, Any] | None = None) -> list[str]:
|
extras: dict[str, Any] | None = None,
|
||||||
"""Build the JSONL lines of every song's lyrics.
|
extras_per_id: dict[str, dict[str, Any]] | None = None) \
|
||||||
|
-> list[str]:
|
||||||
|
"""Build the JSONL lines of the exported songs' lyrics.
|
||||||
|
|
||||||
Without extras, each record's ``content`` is the bare lyrics
|
Without extras of either kind, each record's ``content`` is
|
||||||
string. With extras, ``content`` is a JSON object serialized
|
the bare lyrics string. With extras, ``content`` is a JSON
|
||||||
as a string, whose first key is ``"lyrics"`` holding the
|
object serialized as a string, whose first key is ``"lyrics"``
|
||||||
lyrics string, followed by the extras' keys in their given
|
holding the lyrics string, followed by the shared extras' keys
|
||||||
order.
|
and then the song's own per-ID extras' keys, each group in its
|
||||||
|
given order.
|
||||||
|
|
||||||
|
Every song is exported, unless per-ID extras are given, in
|
||||||
|
which case only the songs they name are.
|
||||||
|
|
||||||
:param session: The database session.
|
:param session: The database session.
|
||||||
:param extras: The extra parameters merged into every
|
:param extras: The extra parameters merged into every
|
||||||
record's content alongside the lyrics, in the order they
|
record's content alongside the lyrics, in the order they
|
||||||
are to appear, or None for the bare lyrics string.
|
are to appear, or None for none.
|
||||||
:return: The JSON lines, one per song, ordered by song ID.
|
:param extras_per_id: The extra parameters merged into the
|
||||||
:raises ValueError: When a song has no lyrics.
|
content of one record alone, keyed by that record's song
|
||||||
|
ID and in the order they are to appear, restricting the
|
||||||
|
export to the song IDs they name, or None for no such
|
||||||
|
extras and no such restriction.
|
||||||
|
:return: The JSON lines, one per exported song, ordered by
|
||||||
|
song ID.
|
||||||
|
:raises ValueError: When an exported song has no lyrics, or
|
||||||
|
the per-ID extras name a song the working store does not
|
||||||
|
have.
|
||||||
"""
|
"""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
exported: set[str] = set()
|
||||||
song: Song
|
song: Song
|
||||||
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
||||||
|
song_id: str = f"song-{song.id}"
|
||||||
|
if extras_per_id is not None and song_id not in extras_per_id:
|
||||||
|
continue
|
||||||
if song.lyrics is None:
|
if song.lyrics is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"song {song.id} \"{song.title}\": no lyrics")
|
f"song {song.id} \"{song.title}\": no lyrics")
|
||||||
content: str
|
content: str
|
||||||
if extras is None:
|
if extras is None and extras_per_id is None:
|
||||||
content = song.lyrics
|
content = song.lyrics
|
||||||
else:
|
else:
|
||||||
payload: dict[str, Any] = {"lyrics": song.lyrics}
|
payload: dict[str, Any] = {"lyrics": song.lyrics}
|
||||||
|
if extras is not None:
|
||||||
payload.update(extras)
|
payload.update(extras)
|
||||||
|
if extras_per_id is not None:
|
||||||
|
payload.update(extras_per_id[song_id])
|
||||||
content = json.dumps(payload, ensure_ascii=False)
|
content = json.dumps(payload, ensure_ascii=False)
|
||||||
record: dict[str, str] = {
|
record: dict[str, str] = {
|
||||||
"id": f"song-{song.id}", "content": content}
|
"id": song_id, "content": content}
|
||||||
lines.append(json.dumps(record, ensure_ascii=False))
|
lines.append(json.dumps(record, ensure_ascii=False))
|
||||||
|
exported.add(song_id)
|
||||||
|
if extras_per_id is not None:
|
||||||
|
missing: list[str] = sorted(set(extras_per_id) - exported)
|
||||||
|
if len(missing) > 0:
|
||||||
|
raise ValueError(
|
||||||
|
"the per-ID extras name songs the working store"
|
||||||
|
f" does not have: {', '.join(missing)}")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Export the LLM input JSONL file from the working store.
|
"""Export the LLM input JSONL file from the working store.
|
||||||
|
|
||||||
|
Every song is exported, unless ``--extras-per-id`` is given,
|
||||||
|
in which case only the songs its file names are.
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for
|
:param argv: The command-line arguments, or None for
|
||||||
``sys.argv``.
|
``sys.argv``.
|
||||||
:return: The exit status: 0 on success, non-zero on failure.
|
:return: The exit status: 0 on success, non-zero on failure.
|
||||||
@@ -150,7 +238,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
extras: dict[str, Any] | None = None
|
extras: dict[str, Any] | None = None
|
||||||
if args.extras is not None:
|
if args.extras is not None:
|
||||||
extras = load_extras(args.extras)
|
extras = load_extras(args.extras)
|
||||||
lines = build_lines(session, extras)
|
extras_per_id: dict[str, dict[str, Any]] | None = None
|
||||||
|
if args.extras_per_id is not None:
|
||||||
|
extras_per_id = load_extras_per_id(args.extras_per_id)
|
||||||
|
lines = build_lines(session, extras, extras_per_id)
|
||||||
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
|
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
|
||||||
print(f"error: {error}", file=sys.stderr)
|
print(f"error: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -79,11 +79,20 @@ class TestCompareCodings(unittest.TestCase):
|
|||||||
def __read_disagreements(self) -> dict[str, Any]:
|
def __read_disagreements(self) -> dict[str, Any]:
|
||||||
"""Read the disagreement JSON file.
|
"""Read the disagreement JSON file.
|
||||||
|
|
||||||
:return: The parsed disagreements.
|
:return: The parsed disagreements, as written, with every
|
||||||
|
song's keywords in its "disagreements" wrapper.
|
||||||
"""
|
"""
|
||||||
return json.loads(
|
return json.loads(
|
||||||
self.__disagreements_json.read_text(encoding="utf-8"))
|
self.__disagreements_json.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def __read_keywords(self, song_id: str) -> dict[str, Any]:
|
||||||
|
"""Read one song's disagreed keywords from the file.
|
||||||
|
|
||||||
|
:param song_id: The song ID.
|
||||||
|
:return: The keywords of that song, unwrapped.
|
||||||
|
"""
|
||||||
|
return self.__read_disagreements()[song_id]["disagreements"]
|
||||||
|
|
||||||
def __read_disagreement_text(self) -> str:
|
def __read_disagreement_text(self) -> str:
|
||||||
"""Read the disagreement JSON file verbatim.
|
"""Read the disagreement JSON file verbatim.
|
||||||
|
|
||||||
@@ -103,7 +112,23 @@ class TestCompareCodings(unittest.TestCase):
|
|||||||
status, _ = self.__run_compare()
|
status, _ = self.__run_compare()
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(self.__read_disagreements(), {
|
self.assertEqual(self.__read_disagreements(), {
|
||||||
"song-1": {"only-1": ["q2"], "only-2": ["q4"]}})
|
"song-1": {"disagreements": {
|
||||||
|
"only-1": ["q2"], "only-2": ["q4"]}}})
|
||||||
|
|
||||||
|
def test_keywords_wrapped_for_merging(self) -> None:
|
||||||
|
"""Test that each song's value is the object merged into
|
||||||
|
the arbitration input, holding "disagreements" alone."""
|
||||||
|
self.__write_codings(self.__run1, {
|
||||||
|
"song-1": {"only-1": ["q"]}})
|
||||||
|
self.__write_codings(self.__run2, {"song-1": {}})
|
||||||
|
status: int
|
||||||
|
status, _ = self.__run_compare()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
list(self.__read_disagreements()["song-1"].keys()),
|
||||||
|
["disagreements"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.__read_keywords("song-1"), {"only-1": ["q"]})
|
||||||
|
|
||||||
def test_quotes_do_not_take_part_in_comparison(self) -> None:
|
def test_quotes_do_not_take_part_in_comparison(self) -> None:
|
||||||
"""Test that identical key sets with different quotes
|
"""Test that identical key sets with different quotes
|
||||||
@@ -162,7 +187,7 @@ class TestCompareCodings(unittest.TestCase):
|
|||||||
status, _ = self.__run_compare()
|
status, _ = self.__run_compare()
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
list(self.__read_disagreements()["song-1"].keys()),
|
list(self.__read_keywords("song-1").keys()),
|
||||||
["alpha", "beta", "mu", "zeta"])
|
["alpha", "beta", "mu", "zeta"])
|
||||||
|
|
||||||
def test_summary_line_counts(self) -> None:
|
def test_summary_line_counts(self) -> None:
|
||||||
@@ -195,8 +220,7 @@ class TestCompareCodings(unittest.TestCase):
|
|||||||
status, _ = self.__run_compare()
|
status, _ = self.__run_compare()
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.__read_disagreements(),
|
self.__read_keywords("song-1"), {"only-1": [quote]})
|
||||||
{"song-1": {"only-1": [quote]}})
|
|
||||||
|
|
||||||
def test_non_ascii_written_verbatim(self) -> None:
|
def test_non_ascii_written_verbatim(self) -> None:
|
||||||
"""Test that the output is UTF-8 with the non-ASCII text
|
"""Test that the output is UTF-8 with the non-ASCII text
|
||||||
@@ -362,8 +386,7 @@ class TestCompareCodings(unittest.TestCase):
|
|||||||
status, _ = self.__run_compare()
|
status, _ = self.__run_compare()
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.__read_disagreements(),
|
self.__read_keywords("song-1"), {"only-1": ["q"]})
|
||||||
{"song-1": {"only-1": ["q"]}})
|
|
||||||
|
|
||||||
def test_compare_returns_agreed_keyword_names(self) -> None:
|
def test_compare_returns_agreed_keyword_names(self) -> None:
|
||||||
"""Test that the comparison function returns the agreed
|
"""Test that the comparison function returns the agreed
|
||||||
|
|||||||
@@ -76,16 +76,21 @@ class TestExportLlmInput(unittest.TestCase):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def __run_export(
|
def __run_export(
|
||||||
self, extras: Path | None = None) -> tuple[int, str]:
|
self, extras: Path | None = None,
|
||||||
|
extras_per_id: Path | None = None) -> tuple[int, str]:
|
||||||
"""Run the exporter with the standard error captured.
|
"""Run the exporter with the standard error captured.
|
||||||
|
|
||||||
:param extras: The extras JSON file, or None for none.
|
:param extras: The extras JSON file, or None for none.
|
||||||
|
:param extras_per_id: The per-ID extras JSON file, or None
|
||||||
|
for none.
|
||||||
:return: A tuple of the exit status and the standard
|
:return: A tuple of the exit status and the standard
|
||||||
error.
|
error.
|
||||||
"""
|
"""
|
||||||
argv: list[str] = [str(self.__output)]
|
argv: list[str] = [str(self.__output)]
|
||||||
if extras is not None:
|
if extras is not None:
|
||||||
argv += ["--extras", str(extras)]
|
argv += ["--extras", str(extras)]
|
||||||
|
if extras_per_id is not None:
|
||||||
|
argv += ["--extras-per-id", str(extras_per_id)]
|
||||||
stderr: io.StringIO = io.StringIO()
|
stderr: io.StringIO = io.StringIO()
|
||||||
with redirect_stderr(stderr):
|
with redirect_stderr(stderr):
|
||||||
status: int = export_llm_input.main(argv)
|
status: int = export_llm_input.main(argv)
|
||||||
@@ -101,6 +106,16 @@ class TestExportLlmInput(unittest.TestCase):
|
|||||||
path.write_text(text, encoding="utf-8")
|
path.write_text(text, encoding="utf-8")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def __write_extras_per_id(self, text: str) -> Path:
|
||||||
|
"""Write a per-ID extras file with the given raw text.
|
||||||
|
|
||||||
|
:param text: The raw file content.
|
||||||
|
:return: The path of the written per-ID extras file.
|
||||||
|
"""
|
||||||
|
path: Path = self.__dir / "extras-per-id.json"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __read_records(path: Path) -> list[dict[str, str]]:
|
def __read_records(path: Path) -> list[dict[str, str]]:
|
||||||
"""Read the JSONL records of a file.
|
"""Read the JSONL records of a file.
|
||||||
@@ -228,3 +243,115 @@ class TestExportLlmInput(unittest.TestCase):
|
|||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
self.assertIn("error:", stderr)
|
self.assertIn("error:", stderr)
|
||||||
self.assertFalse(self.__output.exists())
|
self.assertFalse(self.__output.exists())
|
||||||
|
|
||||||
|
def test_extras_per_id_merges_each_song_its_own(self) -> None:
|
||||||
|
"""Test that with per-ID extras, each record's content is
|
||||||
|
a JSON object of the lyrics followed by that song's own
|
||||||
|
keys in their file order."""
|
||||||
|
self.__seed([
|
||||||
|
("Hello", "Adele", "hello lyrics\n"),
|
||||||
|
("Umbrella", "Rihanna", "umbrella lyrics\n")])
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-1": {"b": 2, "a": 1},'
|
||||||
|
' "song-2": {"disagreements": {"k": ["q"]}}}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(None, per_id)
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
records: list[dict[str, str]] = self.__read_records(
|
||||||
|
self.__output)
|
||||||
|
self.assertEqual(len(records), 2)
|
||||||
|
first: dict[str, Any] = json.loads(records[0]["content"])
|
||||||
|
self.assertEqual(
|
||||||
|
list(first.keys()), ["lyrics", "b", "a"])
|
||||||
|
self.assertEqual(first["lyrics"], "hello lyrics\n")
|
||||||
|
self.assertEqual(first["b"], 2)
|
||||||
|
self.assertEqual(first["a"], 1)
|
||||||
|
second: dict[str, Any] = json.loads(records[1]["content"])
|
||||||
|
self.assertEqual(
|
||||||
|
list(second.keys()), ["lyrics", "disagreements"])
|
||||||
|
self.assertEqual(second["lyrics"], "umbrella lyrics\n")
|
||||||
|
self.assertEqual(
|
||||||
|
second["disagreements"], {"k": ["q"]})
|
||||||
|
|
||||||
|
def test_extras_per_id_restricts_the_export(self) -> None:
|
||||||
|
"""Test that the songs the per-ID extras do not name are
|
||||||
|
not exported."""
|
||||||
|
self.__seed([
|
||||||
|
("Hello", "Adele", "hello lyrics\n"),
|
||||||
|
("Umbrella", "Rihanna", "umbrella lyrics\n"),
|
||||||
|
("Halo", "Beyonce", "halo lyrics\n")])
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-3": {"a": 1}, "song-1": {"a": 2}}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(None, per_id)
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
records: list[dict[str, str]] = self.__read_records(
|
||||||
|
self.__output)
|
||||||
|
self.assertEqual(
|
||||||
|
[x["id"] for x in records], ["song-1", "song-3"])
|
||||||
|
self.assertIn("done: 2 songs exported", stderr)
|
||||||
|
|
||||||
|
def test_extras_per_id_unknown_id_fails(self) -> None:
|
||||||
|
"""Test that a per-ID extras file naming a song the
|
||||||
|
working store does not have is rejected."""
|
||||||
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-1": {"a": 1}, "song-9": {"a": 2}}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(None, per_id)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("song-9", stderr)
|
||||||
|
self.assertFalse(self.__output.exists())
|
||||||
|
|
||||||
|
def test_extras_per_id_after_the_shared_extras(self) -> None:
|
||||||
|
"""Test that with both extras options, a record's keys are
|
||||||
|
the lyrics, the shared extras, then that song's own."""
|
||||||
|
self.__seed([
|
||||||
|
("Hello", "Adele", "hello lyrics\n"),
|
||||||
|
("Umbrella", "Rihanna", "umbrella lyrics\n")])
|
||||||
|
extras: Path = self.__write_extras('{"shared": "s"}')
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-2": {"own": "o"}}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(extras, per_id)
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
records: list[dict[str, str]] = self.__read_records(
|
||||||
|
self.__output)
|
||||||
|
self.assertEqual([x["id"] for x in records], ["song-2"])
|
||||||
|
content: dict[str, Any] = json.loads(
|
||||||
|
records[0]["content"])
|
||||||
|
self.assertEqual(
|
||||||
|
list(content.keys()), ["lyrics", "shared", "own"])
|
||||||
|
self.assertEqual(content["lyrics"], "umbrella lyrics\n")
|
||||||
|
self.assertEqual(content["shared"], "s")
|
||||||
|
self.assertEqual(content["own"], "o")
|
||||||
|
|
||||||
|
def test_extras_per_id_non_object_value_fails(self) -> None:
|
||||||
|
"""Test that a per-ID extras file whose song does not have
|
||||||
|
a JSON object is rejected."""
|
||||||
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-1": [1, 2]}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(None, per_id)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
|
self.assertFalse(self.__output.exists())
|
||||||
|
|
||||||
|
def test_extras_per_id_with_lyrics_key_fails(self) -> None:
|
||||||
|
"""Test that a per-ID extras file whose song carries a
|
||||||
|
"lyrics" key is rejected."""
|
||||||
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
||||||
|
per_id: Path = self.__write_extras_per_id(
|
||||||
|
'{"song-1": {"lyrics": "not allowed"}}')
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_export(None, per_id)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
|
self.assertFalse(self.__output.exists())
|
||||||
|
|||||||
Reference in New Issue
Block a user