Import the settled coding into the working store
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,12 @@ pop-fem-audit/
|
|||||||
- **`prompts/` 檔名不帶版本號**:版本即 git 歷史,失敗的
|
- **`prompts/` 檔名不帶版本號**:版本即 git 歷史,失敗的
|
||||||
版本不保留;論文引用的單位是 `runs/` 內隨執行保存的定義檔
|
版本不保留;論文引用的單位是 `runs/` 內隨執行保存的定義檔
|
||||||
快照(每個執行目錄自我完備),不需檔名可指的版本名。
|
快照(每個執行目錄自我完備),不需檔名可指的版本名。
|
||||||
|
- **工作儲存的資料表**:`songs`、`chart_entries`、`artists`、
|
||||||
|
`song_artists`、`codings`(定案編碼:一歌一標籤一列,`quotes`
|
||||||
|
存該標籤所據的歌詞引述,多句以 `|` 相接)。定案表
|
||||||
|
`results/codings.csv` 經 `build-db --codings` 匯入,與其餘資料
|
||||||
|
同一交易,儲存不會半建;詳見 `research-plan.md`「資料儲存與
|
||||||
|
模型」。
|
||||||
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
||||||
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
||||||
一律以文字 commit。「可再生仍 commit」的例外有二:
|
一律以文字 commit。「可再生仍 commit」的例外有二:
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
- **資料模型**:`songs`(歌曲實體,含 lyrics nullable 欄位)、
|
- **資料模型**:`songs`(歌曲實體,含 lyrics nullable 欄位)、
|
||||||
`chart_entries`(1 歌—N 筆榜單紀錄)、`artists`(歌手實體:
|
`chart_entries`(1 歌—N 筆榜單紀錄)、`artists`(歌手實體:
|
||||||
Wikidata QID、性別、型態、曲風、國籍)、`song_artists`
|
Wikidata QID、性別、型態、曲風、國籍)、`song_artists`
|
||||||
(M—N 關聯:角色、署名順序)。領域不變量(恰 1000 筆榜單、
|
(M—N 關聯:角色、署名順序)、`codings`(定案編碼:一歌一
|
||||||
|
標籤一列,`quotes` 為該標籤所據的歌詞引述,多句以 `|` 相接,
|
||||||
|
無證據者為空字串)。領域不變量(恰 1000 筆榜單、
|
||||||
每歌至少一 primary 歌手等)檢查內建於 `build-db`,違規即
|
每歌至少一 primary 歌手等)檢查內建於 `build-db`,違規即
|
||||||
建置失敗。
|
建置失敗。
|
||||||
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
Rebuilds the working store from scratch out of the committed
|
Rebuilds the working store from scratch out of the committed
|
||||||
inputs: the year-end chart CSV and the output directory for the
|
inputs: the year-end chart CSV and the output directory for the
|
||||||
review CSV files, given as the two positional command-line
|
review CSV files, given as the two positional command-line
|
||||||
arguments, and the optional capture inputs, each given as an
|
arguments, and the optional inputs, each given as an
|
||||||
option: the lyrics cache directory and the Wikidata artist
|
option: the lyrics cache directory, the Wikidata artist
|
||||||
snapshot CSV. An omitted option
|
snapshot CSV, and the settled coding table CSV. An omitted option
|
||||||
leaves its capture layer unloaded; a given option whose path does
|
leaves its layer unloaded; a given option whose path does
|
||||||
not exist fails the build. Missing tables
|
not exist fails the build. Missing tables
|
||||||
are created on a fresh store; existing tables are never altered,
|
are created on a fresh store; existing tables are never altered,
|
||||||
as the schema lifecycle belongs to the migrations. Every rebuild
|
as the schema lifecycle belongs to the migrations. Every rebuild
|
||||||
@@ -63,6 +63,7 @@ from ..database import Base, ds
|
|||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
ChartEntry,
|
ChartEntry,
|
||||||
|
Coding,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
SongArtist,
|
SongArtist,
|
||||||
@@ -104,6 +105,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wikidata-csv", type=Path, default=None,
|
"--wikidata-csv", type=Path, default=None,
|
||||||
help="the Wikidata artist snapshot CSV file to apply")
|
help="the Wikidata artist snapshot CSV file to apply")
|
||||||
|
parser.add_argument(
|
||||||
|
"--codings", type=Path, default=None,
|
||||||
|
help="the settled coding table CSV file to import")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -589,6 +593,110 @@ class CaptureImporter:
|
|||||||
setattr(artist, attribute, row[column])
|
setattr(artist, attribute, row[column])
|
||||||
|
|
||||||
|
|
||||||
|
class CodingImporter:
|
||||||
|
"""The coding-import job: loads the settled coding table onto
|
||||||
|
the stored songs."""
|
||||||
|
|
||||||
|
COLUMNS: tuple[str, ...] = (
|
||||||
|
"Song", "Artist Credit", "Keyword", "Quote")
|
||||||
|
"""The required columns of the coding CSV file."""
|
||||||
|
NEWLINE_ESCAPE: str = "\\n"
|
||||||
|
"""The two characters standing for a newline in the quote
|
||||||
|
column, so that the CSV file is one row per line."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
"""Initialize the importer.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
"""
|
||||||
|
self.__session: Session = session
|
||||||
|
|
||||||
|
def import_codings(self, path: Path | None) -> None:
|
||||||
|
"""Load the settled coding table onto the stored songs.
|
||||||
|
|
||||||
|
A None input leaves the coding unloaded. Otherwise every
|
||||||
|
row of the CSV file yields one coding of the song named by
|
||||||
|
its title and artist credit, with the quote column stored
|
||||||
|
verbatim except that each two-character ``\\n`` escape
|
||||||
|
becomes a newline; an empty quote column stores an empty
|
||||||
|
string. When the method returns, the imported codings are
|
||||||
|
queryable in the session.
|
||||||
|
|
||||||
|
:param path: The settled coding table CSV file to import,
|
||||||
|
or None to skip the coding.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When the file lacks a required column,
|
||||||
|
a row names a song that the store does not have, or two
|
||||||
|
rows name the same song and keyword.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
songs: dict[tuple[str, str], Song] = {
|
||||||
|
(x.title, x.artist_credit): x
|
||||||
|
for x in self.__session.scalars(sa.select(Song))}
|
||||||
|
seen: set[tuple[int, str]] = set()
|
||||||
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
|
reader: csv.DictReader[str] = csv.DictReader(file)
|
||||||
|
self.__check_columns(path, reader.fieldnames)
|
||||||
|
row: dict[str, str]
|
||||||
|
for row in reader:
|
||||||
|
self.__import_coding(path, songs, seen, row)
|
||||||
|
self.__session.flush()
|
||||||
|
|
||||||
|
def __import_coding(self, path: Path,
|
||||||
|
songs: dict[tuple[str, str], Song],
|
||||||
|
seen: set[tuple[int, str]],
|
||||||
|
row: dict[str, str]) -> None:
|
||||||
|
"""Store one coding row.
|
||||||
|
|
||||||
|
:param path: The coding CSV file, for the error messages.
|
||||||
|
:param songs: The stored songs, keyed by the title and the
|
||||||
|
artist credit.
|
||||||
|
:param seen: The (song ID, keyword) pairs already stored,
|
||||||
|
updated with the pair of this row.
|
||||||
|
:param row: The coding CSV row.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When the row names a song that the
|
||||||
|
store does not have, or its song and keyword repeat an
|
||||||
|
earlier row.
|
||||||
|
"""
|
||||||
|
key: tuple[str, str] = (row["Song"], row["Artist Credit"])
|
||||||
|
song: Song | None = songs.get(key)
|
||||||
|
if song is None:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: no song \"{row['Song']}\" by"
|
||||||
|
f" \"{row['Artist Credit']}\"")
|
||||||
|
coding_key: tuple[int, str] = (song.id, row["Keyword"])
|
||||||
|
if coding_key in seen:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: duplicated coding: \"{row['Song']}\" by"
|
||||||
|
f" \"{row['Artist Credit']}\", keyword"
|
||||||
|
f" \"{row['Keyword']}\"")
|
||||||
|
seen.add(coding_key)
|
||||||
|
self.__session.add(Coding(
|
||||||
|
song=song, keyword=row["Keyword"],
|
||||||
|
quotes=row["Quote"].replace(self.NEWLINE_ESCAPE, "\n")))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __check_columns(cls, path: Path,
|
||||||
|
fieldnames: Sequence[str] | None) -> None:
|
||||||
|
"""Verify the coding CSV file has the required columns.
|
||||||
|
|
||||||
|
:param path: The coding CSV file.
|
||||||
|
:param fieldnames: The header row of the file, or None when
|
||||||
|
the file is empty.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When a required column is absent.
|
||||||
|
"""
|
||||||
|
header: Sequence[str] = fieldnames or ()
|
||||||
|
missing: list[str] = [
|
||||||
|
x for x in cls.COLUMNS if x not in header]
|
||||||
|
if len(missing) > 0:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: missing column(s): {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StoreCounts:
|
class StoreCounts:
|
||||||
"""The row counts of the working store, for the build summary."""
|
"""The row counts of the working store, for the build summary."""
|
||||||
@@ -603,6 +711,8 @@ class StoreCounts:
|
|||||||
"""The number of the song-artist credits."""
|
"""The number of the song-artist credits."""
|
||||||
songs_with_lyrics: int
|
songs_with_lyrics: int
|
||||||
"""The number of the songs with lyrics."""
|
"""The number of the songs with lyrics."""
|
||||||
|
codings: int
|
||||||
|
"""The number of the settled codings."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls, session: Session) -> Self:
|
def get_instance(cls, session: Session) -> Self:
|
||||||
@@ -630,7 +740,9 @@ class StoreCounts:
|
|||||||
.select_from(SongArtist)),
|
.select_from(SongArtist)),
|
||||||
songs_with_lyrics=count(
|
songs_with_lyrics=count(
|
||||||
sa.select(sa.func.count()).select_from(Song)
|
sa.select(sa.func.count()).select_from(Song)
|
||||||
.where(Song.lyrics.is_not(None))))
|
.where(Song.lyrics.is_not(None))),
|
||||||
|
codings=count(
|
||||||
|
sa.select(sa.func.count()).select_from(Coding)))
|
||||||
|
|
||||||
|
|
||||||
def reset_store(session: Session) -> None:
|
def reset_store(session: Session) -> None:
|
||||||
@@ -640,7 +752,7 @@ def reset_store(session: Session) -> None:
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
model: type[Base]
|
model: type[Base]
|
||||||
for model in (SongArtist, ChartEntry, Song, Artist):
|
for model in (Coding, SongArtist, ChartEntry, Song, Artist):
|
||||||
session.execute(sa.delete(model))
|
session.execute(sa.delete(model))
|
||||||
|
|
||||||
|
|
||||||
@@ -811,6 +923,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
ArtistImporter(session).import_artists()
|
ArtistImporter(session).import_artists()
|
||||||
CaptureImporter(session).import_captures(
|
CaptureImporter(session).import_captures(
|
||||||
args.lyrics_dir, args.wikidata_csv)
|
args.lyrics_dir, args.wikidata_csv)
|
||||||
|
CodingImporter(session).import_codings(args.codings)
|
||||||
counts = StoreCounts.get_instance(session)
|
counts = StoreCounts.get_instance(session)
|
||||||
CSVExporter(session, args.derived_dir).write()
|
CSVExporter(session, args.derived_dir).write()
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -824,6 +937,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
f" {counts.chart_entries} chart entries,"
|
f" {counts.chart_entries} chart entries,"
|
||||||
f" {counts.artists} artists,"
|
f" {counts.artists} artists,"
|
||||||
f" {counts.credits} credits,"
|
f" {counts.credits} credits,"
|
||||||
f" {counts.songs_with_lyrics} songs with lyrics",
|
f" {counts.songs_with_lyrics} songs with lyrics,"
|
||||||
|
f" {counts.codings} codings",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
The schema covers the year-end chart data: songs with their
|
The schema covers the year-end chart data: songs with their
|
||||||
lyrics, their yearly chart entries, the individual artists, and
|
lyrics, their yearly chart entries, the individual artists, and
|
||||||
the song-artist credits with the role and order.
|
the song-artist credits with the role and order. It also covers
|
||||||
|
the settled coding of the songs: the keywords assigned to each
|
||||||
|
song with the lyric quotes they are grounded in.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import enum
|
import enum
|
||||||
@@ -45,6 +47,9 @@ class Song(Base):
|
|||||||
song_artists: Mapped[list[SongArtist]] \
|
song_artists: Mapped[list[SongArtist]] \
|
||||||
= relationship(back_populates="song")
|
= relationship(back_populates="song")
|
||||||
"""The song-artist credits of the song."""
|
"""The song-artist credits of the song."""
|
||||||
|
codings: Mapped[list[Coding]] \
|
||||||
|
= relationship(back_populates="song")
|
||||||
|
"""The settled codings of the song."""
|
||||||
__table_args__ = (sa.UniqueConstraint(title, artist_credit),)
|
__table_args__ = (sa.UniqueConstraint(title, artist_credit),)
|
||||||
"""The table-level constraints."""
|
"""The table-level constraints."""
|
||||||
|
|
||||||
@@ -112,3 +117,20 @@ class SongArtist(Base):
|
|||||||
sa.CheckConstraint(role.in_([x.value for x in Role]),
|
sa.CheckConstraint(role.in_([x.value for x in Role]),
|
||||||
name="ck_song_artists_role"),)
|
name="ck_song_artists_role"),)
|
||||||
"""The table-level constraints."""
|
"""The table-level constraints."""
|
||||||
|
|
||||||
|
|
||||||
|
class Coding(Base):
|
||||||
|
"""A settled coding keyword of a song, with its lyric quotes."""
|
||||||
|
__tablename__ = "codings"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
song_id: Mapped[int] = mapped_column(sa.ForeignKey(Song.id),
|
||||||
|
primary_key=True)
|
||||||
|
"""The ID of the coded song."""
|
||||||
|
keyword: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The coding keyword assigned to the song."""
|
||||||
|
quotes: Mapped[str] = mapped_column()
|
||||||
|
"""The lyric quotes the keyword is grounded in, joined by a
|
||||||
|
single "|", empty when the keyword carries no evidence."""
|
||||||
|
song: Mapped[Song] = relationship(back_populates="codings")
|
||||||
|
"""The coded song."""
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from pop_fem_audit_tools.database import DataSource
|
|||||||
from pop_fem_audit_tools.models import (
|
from pop_fem_audit_tools.models import (
|
||||||
Artist,
|
Artist,
|
||||||
ChartEntry,
|
ChartEntry,
|
||||||
|
Coding,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
)
|
)
|
||||||
@@ -266,6 +267,7 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.__lyrics: Path = self.__dir / "lyrics"
|
self.__lyrics: Path = self.__dir / "lyrics"
|
||||||
self.__wikidata: Path = \
|
self.__wikidata: Path = \
|
||||||
self.__dir / "artists_wikidata.csv"
|
self.__dir / "artists_wikidata.csv"
|
||||||
|
self.__codings: Path = self.__dir / "codings.csv"
|
||||||
self.__write_chart(self.CHART_CSV)
|
self.__write_chart(self.CHART_CSV)
|
||||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||||
config.set_settings(config.Settings(
|
config.set_settings(config.Settings(
|
||||||
@@ -290,6 +292,14 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
"""
|
"""
|
||||||
self.__chart.write_text(content, encoding="utf-8")
|
self.__chart.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def __write_codings(self, content: str) -> None:
|
||||||
|
"""Write the coding CSV fixture.
|
||||||
|
|
||||||
|
:param content: The CSV content.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__codings.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
def __run_build(self, *options: str) -> tuple[int, str]:
|
def __run_build(self, *options: str) -> tuple[int, str]:
|
||||||
"""Run the build with the standard error captured.
|
"""Run the build with the standard error captured.
|
||||||
|
|
||||||
@@ -821,3 +831,143 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
(self.__derived / "artists.csv").read_text(
|
(self.__derived / "artists.csv").read_text(
|
||||||
encoding="utf-8"),
|
encoding="utf-8"),
|
||||||
artists_before)
|
artists_before)
|
||||||
|
|
||||||
|
CODINGS_CSV: str = (
|
||||||
|
"Song,Artist Credit,Keyword,Quote\n"
|
||||||
|
"Hello,Adele,longing,\"Hello from the other side\\nI must"
|
||||||
|
" have called a thousand times\"\n"
|
||||||
|
"Hello,Adele,regret,I'm sorry|It's no secret\n"
|
||||||
|
"One Dance,Drake featuring Wizkid,desire,\n")
|
||||||
|
"""The coding CSV fixture: a two-line quote, two quotes joined
|
||||||
|
by "|", and an empty quote."""
|
||||||
|
|
||||||
|
def __stored_codings(self) -> dict[tuple[str, str], str]:
|
||||||
|
"""Read the stored codings keyed by the song and keyword.
|
||||||
|
|
||||||
|
:return: The stored quotes, keyed by the song title and the
|
||||||
|
keyword.
|
||||||
|
"""
|
||||||
|
session: Session = self.__session()
|
||||||
|
return {(x.song.title, x.keyword): x.quotes
|
||||||
|
for x in session.scalars(sa.select(Coding))}
|
||||||
|
|
||||||
|
def test_codings_imported(self) -> None:
|
||||||
|
"""Test that the coding CSV imports one row per song and
|
||||||
|
keyword, restoring the newline and keeping the "|"-joined
|
||||||
|
quotes verbatim."""
|
||||||
|
self.__write_codings(self.CODINGS_CSV)
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("3 codings", stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__stored_codings(),
|
||||||
|
{("Hello", "longing"):
|
||||||
|
"Hello from the other side\n"
|
||||||
|
"I must have called a thousand times",
|
||||||
|
("Hello", "regret"): "I'm sorry|It's no secret",
|
||||||
|
("One Dance", "desire"): ""})
|
||||||
|
|
||||||
|
def test_codings_reach_the_song(self) -> None:
|
||||||
|
"""Test that a stored coding reaches its song through the
|
||||||
|
relationship."""
|
||||||
|
self.__write_codings(self.CODINGS_CSV)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__run_build("--codings", str(self.__codings))[0], 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
song: Song | None = session.get(Song, 1)
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(sorted(x.keyword for x in song.codings),
|
||||||
|
["longing", "regret"])
|
||||||
|
|
||||||
|
def test_omitted_codings_leaves_table_empty(self) -> None:
|
||||||
|
"""Test that an omitted coding option leaves no codings."""
|
||||||
|
self.__write_codings(self.CODINGS_CSV)
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("0 codings", stderr)
|
||||||
|
self.assertEqual(self.__stored_codings(), {})
|
||||||
|
|
||||||
|
def test_codings_unknown_song_fails(self) -> None:
|
||||||
|
"""Test that a coding row naming an unknown song fails the
|
||||||
|
build, leaving the previous store contents intact."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
titles: dict[int, str] = self.__song_titles()
|
||||||
|
self.__write_codings(
|
||||||
|
"Song,Artist Credit,Keyword,Quote\n"
|
||||||
|
"Nowhere,Nobody,longing,a line\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("no song \"Nowhere\" by \"Nobody\"", stderr)
|
||||||
|
self.assertEqual(self.__song_titles(), titles)
|
||||||
|
self.assertEqual(self.__stored_codings(), {})
|
||||||
|
|
||||||
|
def test_codings_duplicated_keyword_fails(self) -> None:
|
||||||
|
"""Test that two rows naming the same song and keyword fail
|
||||||
|
the build, without partial data."""
|
||||||
|
self.__write_codings(
|
||||||
|
"Song,Artist Credit,Keyword,Quote\n"
|
||||||
|
"Hello,Adele,longing,a line\n"
|
||||||
|
"Hello,Adele,longing,another line\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("duplicated coding", stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||||
|
|
||||||
|
def test_codings_missing_column_fails(self) -> None:
|
||||||
|
"""Test that a coding CSV missing a required column fails
|
||||||
|
the build."""
|
||||||
|
self.__write_codings(
|
||||||
|
"Song,Artist Credit,Keyword\n"
|
||||||
|
"Hello,Adele,longing\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("missing column(s): Quote", stderr)
|
||||||
|
self.assertEqual(self.__stored_codings(), {})
|
||||||
|
|
||||||
|
def test_missing_codings_csv_fails(self) -> None:
|
||||||
|
"""Test that a given but missing coding CSV fails."""
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
|
self.assertIn(str(self.__codings), stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||||
|
|
||||||
|
def test_codings_replaced_on_rebuild(self) -> None:
|
||||||
|
"""Test that a rebuild replaces the previous codings rather
|
||||||
|
than adding to them."""
|
||||||
|
self.__write_codings(self.CODINGS_CSV)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__run_build("--codings", str(self.__codings))[0], 0)
|
||||||
|
self.__write_codings(
|
||||||
|
"Song,Artist Credit,Keyword,Quote\n"
|
||||||
|
"Shape of You,Ed Sheeran,attraction,I'm in love with"
|
||||||
|
" your body\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--codings", str(self.__codings))
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("1 codings", stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__stored_codings(),
|
||||||
|
{("Shape of You", "attraction"):
|
||||||
|
"I'm in love with your body"})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pop_fem_audit_tools.database import Base, DataSource
|
|||||||
from pop_fem_audit_tools.models import (
|
from pop_fem_audit_tools.models import (
|
||||||
Artist,
|
Artist,
|
||||||
ChartEntry,
|
ChartEntry,
|
||||||
|
Coding,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
SongArtist,
|
SongArtist,
|
||||||
@@ -47,6 +48,10 @@ class TestModels(unittest.TestCase):
|
|||||||
SongArtist(artist=Artist(name="Wizkid"),
|
SongArtist(artist=Artist(name="Wizkid"),
|
||||||
role=Role.FEATURED, position=1)]
|
role=Role.FEATURED, position=1)]
|
||||||
song.lyrics = "Baby, I like your style"
|
song.lyrics = "Baby, I like your style"
|
||||||
|
song.codings = [
|
||||||
|
Coding(keyword="desire",
|
||||||
|
quotes="Baby, I like your style|One dance"),
|
||||||
|
Coding(keyword="nightlife", quotes="")]
|
||||||
self.__session.add(song)
|
self.__session.add(song)
|
||||||
self.__session.commit()
|
self.__session.commit()
|
||||||
|
|
||||||
@@ -83,6 +88,35 @@ class TestModels(unittest.TestCase):
|
|||||||
with self.assertRaises(sa.exc.IntegrityError):
|
with self.assertRaises(sa.exc.IntegrityError):
|
||||||
self.__session.commit()
|
self.__session.commit()
|
||||||
|
|
||||||
|
def test_song_codings(self) -> None:
|
||||||
|
"""Test reading a song's codings back through the
|
||||||
|
relationship."""
|
||||||
|
self.__add_song()
|
||||||
|
self.__session.expunge_all()
|
||||||
|
song: Song | None = self.__session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "One Dance"))
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(
|
||||||
|
{(x.keyword, x.quotes) for x in song.codings},
|
||||||
|
{("desire", "Baby, I like your style|One dance"),
|
||||||
|
("nightlife", "")})
|
||||||
|
coding: Coding | None = self.__session.scalar(
|
||||||
|
sa.select(Coding).where(Coding.keyword == "desire"))
|
||||||
|
assert coding is not None
|
||||||
|
self.assertEqual(coding.song.title, "One Dance")
|
||||||
|
|
||||||
|
def test_duplicated_coding_rejected(self) -> None:
|
||||||
|
"""Test that a repeated song and keyword fails."""
|
||||||
|
self.__add_song()
|
||||||
|
song: Song | None = self.__session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "One Dance"))
|
||||||
|
assert song is not None
|
||||||
|
self.__session.add(
|
||||||
|
Coding(song_id=song.id, keyword="desire",
|
||||||
|
quotes="another line"))
|
||||||
|
with self.assertRaises(sa.exc.IntegrityError):
|
||||||
|
self.__session.commit()
|
||||||
|
|
||||||
def test_invalid_role_rejected(self) -> None:
|
def test_invalid_role_rejected(self) -> None:
|
||||||
"""Test that an invalid song-artist role fails."""
|
"""Test that an invalid song-artist role fails."""
|
||||||
self.__add_song()
|
self.__add_song()
|
||||||
|
|||||||
Reference in New Issue
Block a user