Import the settled code groups into the working store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -744,3 +744,8 @@
|
|||||||
逐一替換;定案表重新計票,與舊檔之逐位元差異恰為
|
逐一替換;定案表重新計票,與舊檔之逐位元差異恰為
|
||||||
`\n`→「 / 」(14,664 列不變,含「 / 」之引述 1,658
|
`\n`→「 / 」(14,664 列不變,含「 / 」之引述 1,658
|
||||||
筆);工作儲存重建後引述零換行字元。
|
筆);工作儲存重建後引述零換行字元。
|
||||||
|
|
||||||
|
- **定案分群匯入工作儲存**:`build-db` 增 `--groups` 選項,
|
||||||
|
將 `results/groups.csv` 逐欄照存入 `groups` 資料表(群、
|
||||||
|
編碼、票數),納入重建摘要與清空範圍,供群層次查詢;
|
||||||
|
重建命令自此帶 `--groups results/groups.csv`。
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
定案分群寫入 `results/groups.csv`,欄位 `Group`、
|
定案分群寫入 `results/groups.csv`,欄位 `Group`、
|
||||||
`Keyword`、`Votes`,列序先依群名、再依編碼,一律以
|
`Keyword`、`Votes`,列序先依群名、再依編碼,一律以
|
||||||
Unicode 碼位比較,換行為 CRLF。
|
Unicode 碼位比較,換行為 CRLF。
|
||||||
|
- **工作儲存**:`build-db --groups <定案分群 CSV>` 將定案
|
||||||
|
分群逐欄照存入 `groups` 資料表(群、編碼、票數),供
|
||||||
|
群層次查詢。
|
||||||
|
|
||||||
## 女性力量候選集
|
## 女性力量候選集
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ from ..database import Base, ds
|
|||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
ChartEntry,
|
ChartEntry,
|
||||||
|
CodeGroup,
|
||||||
Coding,
|
Coding,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
@@ -118,6 +119,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--codings", type=Path, default=None,
|
"--codings", type=Path, default=None,
|
||||||
help="the settled coding table CSV file to import")
|
help="the settled coding table CSV file to import")
|
||||||
|
parser.add_argument(
|
||||||
|
"--groups", type=Path, default=None,
|
||||||
|
help="the settled code group table CSV file to import")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -785,6 +789,101 @@ class CodingImporter:
|
|||||||
f"{path}: missing column(s): {', '.join(missing)}")
|
f"{path}: missing column(s): {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupImporter:
|
||||||
|
"""The group-import job: loads the settled code group table
|
||||||
|
into the working store."""
|
||||||
|
|
||||||
|
COLUMNS: tuple[str, ...] = ("Group", "Keyword", "Votes")
|
||||||
|
"""The required columns of the group CSV file."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
"""Initialize the importer.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
"""
|
||||||
|
self.__session: Session = session
|
||||||
|
|
||||||
|
def import_groups(self, path: Path | None) -> None:
|
||||||
|
"""Load the settled code group table into the store.
|
||||||
|
|
||||||
|
A None input leaves the groups unloaded. Otherwise every
|
||||||
|
row of the CSV file yields one member keyword of one
|
||||||
|
group, with the group name, the keyword, and the integer
|
||||||
|
vote count stored verbatim. When the method returns, the
|
||||||
|
imported groups are queryable in the session.
|
||||||
|
|
||||||
|
:param path: The settled code group table CSV file to
|
||||||
|
import, or None to skip the groups.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When the file lacks a required
|
||||||
|
column, a votes field is not an integer, or two rows
|
||||||
|
name the same group and keyword.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
seen: set[tuple[str, 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_group_row(path, seen, row)
|
||||||
|
self.__session.flush()
|
||||||
|
|
||||||
|
def __import_group_row(self, path: Path,
|
||||||
|
seen: set[tuple[str, str]],
|
||||||
|
row: dict[str, str]) -> None:
|
||||||
|
"""Store one group member row.
|
||||||
|
|
||||||
|
:param path: The group CSV file, for the error messages.
|
||||||
|
:param seen: The (group, keyword) pairs already stored,
|
||||||
|
updated with the pair of this row.
|
||||||
|
:param row: The group CSV row.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When the votes field is not an
|
||||||
|
integer, or the group and keyword repeat an earlier
|
||||||
|
row.
|
||||||
|
"""
|
||||||
|
key: tuple[str, str] = (row["Group"], row["Keyword"])
|
||||||
|
if key in seen:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: duplicated group member: group"
|
||||||
|
f" \"{row['Group']}\", keyword"
|
||||||
|
f" \"{row['Keyword']}\"")
|
||||||
|
seen.add(key)
|
||||||
|
votes: int
|
||||||
|
try:
|
||||||
|
votes = int(row["Votes"])
|
||||||
|
except ValueError as error:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: group \"{row['Group']}\", keyword"
|
||||||
|
f" \"{row['Keyword']}\": votes"
|
||||||
|
f" \"{row['Votes']}\" is not an integer"
|
||||||
|
) from error
|
||||||
|
self.__session.add(CodeGroup(
|
||||||
|
group=row["Group"], keyword=row["Keyword"],
|
||||||
|
votes=votes))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __check_columns(cls, path: Path,
|
||||||
|
fieldnames: Sequence[str] | None) -> None:
|
||||||
|
"""Verify the group CSV file has the required columns.
|
||||||
|
|
||||||
|
:param path: The group 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."""
|
||||||
@@ -795,6 +894,8 @@ class StoreCounts:
|
|||||||
"""The number of the artists."""
|
"""The number of the artists."""
|
||||||
codings: int
|
codings: int
|
||||||
"""The number of the settled codings."""
|
"""The number of the settled codings."""
|
||||||
|
groups: int
|
||||||
|
"""The number of the settled code group members."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls, session: Session) -> Self:
|
def get_instance(cls, session: Session) -> Self:
|
||||||
@@ -815,7 +916,9 @@ class StoreCounts:
|
|||||||
artists=count(
|
artists=count(
|
||||||
sa.select(sa.func.count()).select_from(Artist)),
|
sa.select(sa.func.count()).select_from(Artist)),
|
||||||
codings=count(
|
codings=count(
|
||||||
sa.select(sa.func.count()).select_from(Coding)))
|
sa.select(sa.func.count()).select_from(Coding)),
|
||||||
|
groups=count(
|
||||||
|
sa.select(sa.func.count()).select_from(CodeGroup)))
|
||||||
|
|
||||||
|
|
||||||
def reset_store(session: Session) -> None:
|
def reset_store(session: Session) -> None:
|
||||||
@@ -825,7 +928,8 @@ def reset_store(session: Session) -> None:
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
model: type[Base]
|
model: type[Base]
|
||||||
for model in (Coding, SongArtist, ChartEntry, Song, Artist):
|
for model in (CodeGroup, Coding, SongArtist, ChartEntry, Song,
|
||||||
|
Artist):
|
||||||
session.execute(sa.delete(model))
|
session.execute(sa.delete(model))
|
||||||
|
|
||||||
|
|
||||||
@@ -988,6 +1092,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
args.lyrics_dir, args.wikidata_csv)
|
args.lyrics_dir, args.wikidata_csv)
|
||||||
PerformerGenderDeriver(session).derive_performer_genders()
|
PerformerGenderDeriver(session).derive_performer_genders()
|
||||||
CodingImporter(session).import_codings(args.codings)
|
CodingImporter(session).import_codings(args.codings)
|
||||||
|
GroupImporter(session).import_groups(args.groups)
|
||||||
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()
|
||||||
@@ -999,6 +1104,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
session.close()
|
session.close()
|
||||||
elapsed: str = format_duration(time.monotonic() - started)
|
elapsed: str = format_duration(time.monotonic() - started)
|
||||||
print(f"Done. {counts.songs} songs/{counts.artists} artists"
|
print(f"Done. {counts.songs} songs/{counts.artists} artists"
|
||||||
f"/{counts.codings} codings. {elapsed} elapsed.",
|
f"/{counts.codings} codings/{counts.groups} group"
|
||||||
|
f" members. {elapsed} elapsed.",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -139,3 +139,17 @@ class Coding(Base):
|
|||||||
single "|", empty when the keyword carries no evidence."""
|
single "|", empty when the keyword carries no evidence."""
|
||||||
song: Mapped[Song] = relationship(back_populates="codings")
|
song: Mapped[Song] = relationship(back_populates="codings")
|
||||||
"""The coded song."""
|
"""The coded song."""
|
||||||
|
|
||||||
|
|
||||||
|
class CodeGroup(Base):
|
||||||
|
"""A settled member keyword of a semantic code group."""
|
||||||
|
__tablename__ = "groups"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
group: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The group name, as the settled group table carries it."""
|
||||||
|
keyword: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The member coding keyword."""
|
||||||
|
votes: Mapped[int] = mapped_column()
|
||||||
|
"""The number of the selection runs that selected the
|
||||||
|
keyword for the group."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
CodeGroup,
|
||||||
Coding,
|
Coding,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
@@ -268,6 +269,7 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.__wikidata: Path = \
|
self.__wikidata: Path = \
|
||||||
self.__dir / "artists_wikidata.csv"
|
self.__dir / "artists_wikidata.csv"
|
||||||
self.__codings: Path = self.__dir / "codings.csv"
|
self.__codings: Path = self.__dir / "codings.csv"
|
||||||
|
self.__groups: Path = self.__dir / "groups.csv"
|
||||||
self.__write_chart(self.CHART_CSV)
|
self.__write_chart(self.CHART_CSV)
|
||||||
config.set_settings(config.Settings(
|
config.set_settings(config.Settings(
|
||||||
SQLALCHEMY_DATABASE_URL="sqlite://",
|
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||||
@@ -1121,3 +1123,102 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.__stored_codings(),
|
self.__stored_codings(),
|
||||||
{("Shape of You", "attraction"):
|
{("Shape of You", "attraction"):
|
||||||
"I'm in love with your body"})
|
"I'm in love with your body"})
|
||||||
|
|
||||||
|
GROUPS_CSV: str = (
|
||||||
|
"Group,Keyword,Votes\n"
|
||||||
|
"masculine,dominance-and-power,3\n"
|
||||||
|
"masculine,family-and-fatherhood,2\n"
|
||||||
|
"women-power,women-power,3\n")
|
||||||
|
"""The group CSV fixture: two groups, one two-vote member."""
|
||||||
|
|
||||||
|
def __write_groups(self, content: str) -> None:
|
||||||
|
"""Write the group CSV fixture.
|
||||||
|
|
||||||
|
:param content: The CSV content.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__groups.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def __stored_groups(self) -> dict[tuple[str, str], int]:
|
||||||
|
"""Read the stored group members keyed by group and keyword.
|
||||||
|
|
||||||
|
:return: The stored votes, keyed by the group name and the
|
||||||
|
keyword.
|
||||||
|
"""
|
||||||
|
session: Session = self.__session()
|
||||||
|
return {(x.group, x.keyword): x.votes
|
||||||
|
for x in session.scalars(sa.select(CodeGroup))}
|
||||||
|
|
||||||
|
def test_groups_imported(self) -> None:
|
||||||
|
"""Test that the group CSV imports one row per group and
|
||||||
|
keyword, the votes stored as integers."""
|
||||||
|
self.__write_groups(self.GROUPS_CSV)
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--groups", str(self.__groups))
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("3 group members", stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__stored_groups(),
|
||||||
|
{("masculine", "dominance-and-power"): 3,
|
||||||
|
("masculine", "family-and-fatherhood"): 2,
|
||||||
|
("women-power", "women-power"): 3})
|
||||||
|
|
||||||
|
def test_groups_replaced_on_rebuild(self) -> None:
|
||||||
|
"""Test that a rebuild replaces the previous groups rather
|
||||||
|
than adding to them."""
|
||||||
|
self.__write_groups(self.GROUPS_CSV)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__run_build("--groups", str(self.__groups))[0], 0)
|
||||||
|
self.__write_groups(
|
||||||
|
"Group,Keyword,Votes\n"
|
||||||
|
"vulnerable,longing-and-loss,3\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--groups", str(self.__groups))
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("1 group members", stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__stored_groups(),
|
||||||
|
{("vulnerable", "longing-and-loss"): 3})
|
||||||
|
|
||||||
|
def test_duplicated_group_member_fails(self) -> None:
|
||||||
|
"""Test that two rows naming the same group and keyword
|
||||||
|
fail the build."""
|
||||||
|
self.__write_groups(
|
||||||
|
"Group,Keyword,Votes\n"
|
||||||
|
"masculine,dominance-and-power,3\n"
|
||||||
|
"masculine,dominance-and-power,2\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--groups", str(self.__groups))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("duplicated group member", stderr)
|
||||||
|
|
||||||
|
def test_group_votes_not_an_integer_fails(self) -> None:
|
||||||
|
"""Test that a non-integer votes field fails the build."""
|
||||||
|
self.__write_groups(
|
||||||
|
"Group,Keyword,Votes\n"
|
||||||
|
"masculine,dominance-and-power,three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--groups", str(self.__groups))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("is not an integer", stderr)
|
||||||
|
|
||||||
|
def test_groups_missing_column_fails(self) -> None:
|
||||||
|
"""Test that a group CSV without a required column fails
|
||||||
|
the build."""
|
||||||
|
self.__write_groups(
|
||||||
|
"Group,Keyword\n"
|
||||||
|
"masculine,dominance-and-power\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--groups", str(self.__groups))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("missing column(s): Votes", stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user