Export human-readable songs and artists reports to the derived data layer
This commit is contained in:
@@ -5,13 +5,13 @@
|
||||
"""The builder of the SQLite working store.
|
||||
|
||||
Rebuilds the working store from scratch out of the committed
|
||||
inputs: the year-end chart CSV, given as the positional
|
||||
command-line argument, and the optional capture inputs, each
|
||||
given as an option: the lyrics cache directory, the Wikidata
|
||||
artist snapshot CSV, and the manual artist overrides CSV. An
|
||||
omitted option leaves its capture layer unloaded; a given
|
||||
option whose path does not exist fails the build. Missing
|
||||
tables
|
||||
inputs: the year-end chart CSV and the output directory for the
|
||||
review CSV files, given as the two positional command-line
|
||||
arguments, and the optional capture inputs, each given as an
|
||||
option: the lyrics cache directory, the Wikidata artist snapshot
|
||||
CSV, and the manual artist overrides CSV. An omitted option
|
||||
leaves its capture layer unloaded; a given option whose path does
|
||||
not exist fails the build. Missing tables
|
||||
are created on a fresh store; existing tables are never altered,
|
||||
as the schema lifecycle belongs to the migrations. Every rebuild
|
||||
deletes all the rows, loads the data, and validates it in one
|
||||
@@ -38,6 +38,12 @@ collapse onto a single artist row. The stored artist name is the
|
||||
first-seen spelling, except for the names listed in
|
||||
``CANONICAL_ARTIST_NAMES``, which always store the canonical
|
||||
spelling regardless of which variant is seen first.
|
||||
|
||||
On a successful build, two review CSV files, ``songs.csv`` and
|
||||
``artists.csv``, are (re)written under the given output directory,
|
||||
mirroring the stored songs and artists without their IDs; see
|
||||
`CSVExporter`. A failed build leaves any existing review CSV
|
||||
files untouched, matching the store rollback.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
@@ -46,7 +52,7 @@ import sys
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
from typing import Any, Self
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -156,6 +162,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"chart_csv", type=Path,
|
||||
help="the year-end chart CSV file")
|
||||
parser.add_argument(
|
||||
"derived_dir", type=Path,
|
||||
help="the output directory for the review CSV files")
|
||||
parser.add_argument(
|
||||
"--lyrics-dir", type=Path, default=None,
|
||||
help="the lyrics cache directory to load")
|
||||
@@ -527,6 +536,156 @@ def reset_store(session: Session) -> None:
|
||||
session.execute(sa.delete(model))
|
||||
|
||||
|
||||
class CSVExporter:
|
||||
"""Writes the review CSV files mirroring the working store."""
|
||||
|
||||
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions")
|
||||
"""The header row of ``songs.csv``, for human readers."""
|
||||
__ARTISTS_HEADER: tuple[str, ...] = (
|
||||
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
|
||||
"Songs")
|
||||
"""The header row of ``artists.csv``, for human readers."""
|
||||
|
||||
def __init__(self, session: Session, derived_dir: Path) -> None:
|
||||
"""Initialize the exporter.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:param derived_dir: The output directory for the review CSV
|
||||
files.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
self.__derived_dir: Path = derived_dir
|
||||
|
||||
def write(self) -> None:
|
||||
"""Write the review CSV files mirroring the loaded data.
|
||||
|
||||
Fully overwrites ``songs.csv`` and ``artists.csv`` under the
|
||||
output directory, creating it when missing, with normal
|
||||
minimal CSV quoting. Neither file carries a song or an
|
||||
artist ID; a multi-valued field is a plain joined string,
|
||||
itself CSV-quoted as a whole only when its content requires
|
||||
it: "/" joins the chart appearances of one song, and "|"
|
||||
joins the distinct songs credited to one artist.
|
||||
|
||||
:return: None.
|
||||
:raises OSError: When a CSV file cannot be written.
|
||||
"""
|
||||
self.__derived_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "songs.csv", self.__SONGS_HEADER,
|
||||
self.__songs_rows())
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "artists.csv", self.__ARTISTS_HEADER,
|
||||
self.__artists_rows())
|
||||
|
||||
@staticmethod
|
||||
def __write_csv(path: Path, header: Sequence[str],
|
||||
rows: Iterable[Sequence[str]]) -> None:
|
||||
"""Write a CSV file with LF line endings, fully overwritten.
|
||||
|
||||
:param path: The output CSV file.
|
||||
:param header: The header row.
|
||||
:param rows: The data rows, in the given order.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
@staticmethod
|
||||
def __sorted_chart_entries(song: Song) -> list[ChartEntry]:
|
||||
"""Sort the chart entries of a song by year then rank.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The chart entries, ordered by year then rank.
|
||||
"""
|
||||
return sorted(
|
||||
song.chart_entries, key=lambda x: (x.year, x.rank))
|
||||
|
||||
@classmethod
|
||||
def __song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the songs.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
@classmethod
|
||||
def __formatted_song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the artists.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
def __songs_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``songs.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded title, then
|
||||
the case-folded artist credit.
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
self.__session.scalars(sa.select(Song)),
|
||||
key=lambda x: (x.title.casefold(),
|
||||
x.artist_credit.casefold()))
|
||||
rows: list[list[str]] = []
|
||||
song: Song
|
||||
for song in songs:
|
||||
row: list[str] = [
|
||||
song.title, song.artist_credit,
|
||||
self.__song_positions(song)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
def __artist_songs(cls, artist: Artist) -> str:
|
||||
"""Format the credited songs for the artists.csv value.
|
||||
|
||||
:param artist: The artist with its song credits loaded.
|
||||
:return: The credited songs, each formatted as
|
||||
"TITLE (YEAR#RANK[/YEAR#RANK...])" with its chart
|
||||
appearances, sorted alphabetically case-folded by
|
||||
title, joined by "|".
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
(x.song for x in artist.song_artists),
|
||||
key=lambda x: x.title.casefold())
|
||||
entries: list[str] = [
|
||||
f"{song.title} ({cls.__formatted_song_positions(song)})"
|
||||
for song in songs]
|
||||
return "|".join(entries)
|
||||
|
||||
def __artists_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``artists.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded name.
|
||||
"""
|
||||
artists: list[Artist] = sorted(
|
||||
self.__session.scalars(sa.select(Artist)),
|
||||
key=lambda x: x.name.casefold())
|
||||
rows: list[list[str]] = []
|
||||
artist: Artist
|
||||
for artist in artists:
|
||||
row: list[str] = [
|
||||
artist.name, artist.wikidata_qid or "",
|
||||
artist.gender or "", artist.type or "",
|
||||
artist.genre or "", artist.country or "",
|
||||
self.__artist_songs(artist)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Rebuild the SQLite working store from the inputs.
|
||||
|
||||
@@ -562,6 +721,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f"error: {violation}", file=sys.stderr)
|
||||
return 1
|
||||
counts = StoreCounts.get_instance(session)
|
||||
CSVExporter(session, args.derived_dir).write()
|
||||
session.commit()
|
||||
except (OSError, BuildError) as error:
|
||||
session.rollback()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""Unit tests for the working store builder module."""
|
||||
import csv
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -253,6 +254,7 @@ class TestBuildDB(unittest.TestCase):
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.__dir: Path = Path(tmp.name)
|
||||
self.__chart: Path = self.__dir / "chart.csv"
|
||||
self.__derived: Path = self.__dir / "derived"
|
||||
self.__lyrics: Path = self.__dir / "lyrics"
|
||||
self.__wikidata: Path = \
|
||||
self.__dir / "artists_wikidata.csv"
|
||||
@@ -290,9 +292,41 @@ class TestBuildDB(unittest.TestCase):
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = build_db.main(
|
||||
[str(self.__chart), *options])
|
||||
[str(self.__chart), str(self.__derived), *options])
|
||||
return status, stderr.getvalue()
|
||||
|
||||
def __read_csv_rows(self, name: str) -> list[list[str]]:
|
||||
"""Read the data rows of a derived CSV file.
|
||||
|
||||
:param name: The CSV file name under the derived directory.
|
||||
:return: The data rows, the header row excluded.
|
||||
"""
|
||||
with open(self.__derived / name, encoding="utf-8",
|
||||
newline="") as file:
|
||||
rows: list[list[str]] = list(csv.reader(file))
|
||||
return rows[1:]
|
||||
|
||||
def __read_csv_header(self, name: str) -> list[str]:
|
||||
"""Read the header row of a derived CSV file.
|
||||
|
||||
:param name: The CSV file name under the derived directory.
|
||||
:return: The header row.
|
||||
"""
|
||||
with open(self.__derived / name, encoding="utf-8",
|
||||
newline="") as file:
|
||||
return next(csv.reader(file))
|
||||
|
||||
@staticmethod
|
||||
def __split_joined_field(value: str,
|
||||
separator: str = "|") -> list[str]:
|
||||
"""Split a joined field into its entries.
|
||||
|
||||
:param value: The joined field value.
|
||||
:param separator: The join separator.
|
||||
:return: The entries, in the given order.
|
||||
"""
|
||||
return value.split(separator)
|
||||
|
||||
def __session(self) -> Session:
|
||||
"""Open a database session closed on test cleanup.
|
||||
|
||||
@@ -594,3 +628,130 @@ class TestBuildDB(unittest.TestCase):
|
||||
self.assertIn(str(self.__overrides), stderr)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||
|
||||
REVIEW_CHART_CSV: str = (
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,banana,Artist B\n"
|
||||
"2016,2,\"Me, Myself & I\",Billie\n"
|
||||
"2017,1,\"Me, Myself & I\",Billie\n"
|
||||
"2017,2,Apple,Artist A\n")
|
||||
"""The chart CSV fixture exercising the review CSV ordering,
|
||||
positions, and the outer-field comma quoting."""
|
||||
|
||||
def test_derived_dir_created_when_missing(self) -> None:
|
||||
"""Test that the derived directory is created when
|
||||
missing."""
|
||||
self.assertFalse(self.__derived.exists())
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertTrue(self.__derived.is_dir())
|
||||
self.assertTrue((self.__derived / "songs.csv").is_file())
|
||||
self.assertTrue((self.__derived / "artists.csv").is_file())
|
||||
|
||||
def test_songs_csv_written(self) -> None:
|
||||
"""Test the songs.csv header, the rows, the title
|
||||
ordering, and the "/"-joined "positions" value."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertEqual(
|
||||
self.__read_csv_header("songs.csv"),
|
||||
["Title", "Artists", "Positions"])
|
||||
rows: list[list[str]] = self.__read_csv_rows("songs.csv")
|
||||
self.assertEqual(
|
||||
[row[:2] for row in rows],
|
||||
[["Apple", "Artist A"],
|
||||
["banana", "Artist B"],
|
||||
["Me, Myself & I", "Billie"]])
|
||||
self.assertEqual(
|
||||
[self.__split_joined_field(row[2], "/") for row in rows],
|
||||
[["2017#2"], ["2016#1"], ["2016#2", "2017#1"]])
|
||||
|
||||
def test_songs_csv_uses_minimal_quoting(self) -> None:
|
||||
"""Test that songs.csv uses normal minimal CSV quoting: a
|
||||
numeric-looking title is written unquoted, like every other
|
||||
field not otherwise requiring quoting."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,679,Artist A\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
content: str = (self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertIn("679,Artist A,2016#1", content)
|
||||
|
||||
def test_artists_csv_written(self) -> None:
|
||||
"""Test the artists.csv header, the rows, the name
|
||||
ordering, and the "|"-joined "TITLE (YEAR#RANK...)" song
|
||||
encoding."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertEqual(
|
||||
self.__read_csv_header("artists.csv"),
|
||||
["Name", "Wikidata QID", "Gender", "Type", "Genre",
|
||||
"Country", "Songs"])
|
||||
rows: list[list[str]] = self.__read_csv_rows("artists.csv")
|
||||
self.assertEqual(
|
||||
[row[0] for row in rows],
|
||||
["Artist A", "Artist B", "Billie"])
|
||||
self.assertEqual(
|
||||
[self.__split_joined_field(row[6]) for row in rows],
|
||||
[["Apple (2017#2)"],
|
||||
["banana (2016#1)"],
|
||||
["Me, Myself & I (2016#2/2017#1)"]])
|
||||
|
||||
def test_artists_csv_songs_field_quoted_for_comma_title(
|
||||
self) -> None:
|
||||
"""Test that the outer "Songs" field is CSV-quoted as a
|
||||
whole when the joined value contains a comma, from a
|
||||
comma-bearing credited title."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
content: str = (self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertIn(
|
||||
'"Me, Myself & I (2016#2/2017#1)"', content)
|
||||
self.assertIn("Apple (2017#2)\n", content)
|
||||
|
||||
def test_derived_csvs_have_no_ids(self) -> None:
|
||||
"""Test that neither derived CSV file exposes a song or an
|
||||
artist ID column."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertNotIn(
|
||||
"id", self.__read_csv_header("songs.csv"))
|
||||
self.assertNotIn(
|
||||
"id", self.__read_csv_header("artists.csv"))
|
||||
|
||||
def test_derived_csvs_have_crlf_line_endings(self) -> None:
|
||||
"""Test that the derived CSV files use CRLF line
|
||||
endings."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
||||
self.assertGreater(raw.count(b"\r\n"), 0)
|
||||
self.assertEqual(raw.count(b"\r"), raw.count(b"\r\n"))
|
||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n"))
|
||||
|
||||
def test_failed_build_leaves_derived_csvs_untouched(self) -> None:
|
||||
"""Test that a failed build does not touch the existing
|
||||
derived CSV files."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
songs_before: str = (self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8")
|
||||
artists_before: str = \
|
||||
(self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertEqual(
|
||||
(self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8"),
|
||||
songs_before)
|
||||
self.assertEqual(
|
||||
(self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8"),
|
||||
artists_before)
|
||||
|
||||
Reference in New Issue
Block a user