Repair the coding output with a reviewed correction table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/6
|
||||
"""The majority tally of the three coding runs.
|
||||
r"""The majority tally of the three coding runs.
|
||||
|
||||
Settles the coding step: the same coding definition file is run
|
||||
three times independently, and this command counts the votes and
|
||||
@@ -15,12 +15,31 @@ three runs assign it, so three votes never tie, and it carries
|
||||
the lyric quotes of every run that assigned it, pooled,
|
||||
deduplicated, sorted by Unicode code point, and joined with a
|
||||
single ``|``: the three runs are peers, so the quote order
|
||||
follows the text alone. The three
|
||||
follows the text alone. The written quote carries the two
|
||||
characters ``\n`` where the lyric has a newline, the mirror of
|
||||
the unescaping the correction table loader does, so the coding
|
||||
table holds one row per line. The three
|
||||
archives must cover exactly the same set of song IDs, every
|
||||
record must be a successful result, and every record's "text"
|
||||
must parse to a JSON object; otherwise the tally fails and
|
||||
nothing is written.
|
||||
|
||||
Two optional inputs guard the tally. ``--corrections`` names a
|
||||
CSV file of researcher-reviewed repairs, applied to each run's
|
||||
records before anything else happens: a keyword row renames or
|
||||
drops one keyword assignment of one song in one run, and an
|
||||
evidence row rewrites or drops one lyric quote string wherever it
|
||||
appears in that song's record for that run. Its two text fields
|
||||
carry the two characters ``\n`` where the text has a newline, so
|
||||
the file holds one row per line. Every row must match, so a
|
||||
stale row fails the run. ``--valid-keywords`` names a
|
||||
plain text file of the allowed keywords, one per line; once the
|
||||
corrections are in, every keyword left in any record must appear
|
||||
in it. The order is fixed and matters: the corrections come
|
||||
first, so a repair may reunite the votes of a misspelled keyword
|
||||
that the check would otherwise reject. With neither option, no
|
||||
record is touched and no vocabulary is checked.
|
||||
|
||||
The archives identify a song as ``song-<ID>``, where ``<ID>`` is
|
||||
the song's ID in the SQLite working store. The output table does
|
||||
not carry that ID: every song is looked up in the working store
|
||||
@@ -49,6 +68,266 @@ class TallyError(Exception):
|
||||
"""An error that fails the tally."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Correction:
|
||||
"""One researcher-reviewed repair of one run's record."""
|
||||
|
||||
KEYWORD: ClassVar[str] = "keyword"
|
||||
"""The type of a row that repairs a keyword assignment."""
|
||||
EVIDENCE: ClassVar[str] = "evidence"
|
||||
"""The type of a row that repairs a lyric quote."""
|
||||
REMOVE: ClassVar[str] = "**REMOVE**"
|
||||
"""The correct term that drops what the row names instead of
|
||||
replacing it."""
|
||||
__MAX_QUOTED: ClassVar[int] = 40
|
||||
"""The number of characters of the replaced text an error
|
||||
message shows before cutting it short."""
|
||||
|
||||
line: int
|
||||
"""The line of the corrections file the row ends on."""
|
||||
song_id: int
|
||||
"""The numeric part of the song ID the row repairs."""
|
||||
run: str
|
||||
"""The basename of the run directory the row repairs."""
|
||||
type: str
|
||||
"""The type of the row, :attr:`KEYWORD` or :attr:`EVIDENCE`."""
|
||||
to_be_replaced: str
|
||||
"""The keyword or the lyric quote string the row replaces."""
|
||||
correct_term: str
|
||||
"""The replacement, or :attr:`REMOVE` to drop what the row
|
||||
names."""
|
||||
|
||||
@property
|
||||
def is_removal(self) -> bool:
|
||||
"""Whether the row drops what it names.
|
||||
|
||||
:return: True when the correct term is :attr:`REMOVE`,
|
||||
False otherwise.
|
||||
"""
|
||||
return self.correct_term == self.REMOVE
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""The identity of the row, for an error message.
|
||||
|
||||
:return: The song ID, the run, the type, and the replaced
|
||||
text, the text cut short when it is long.
|
||||
"""
|
||||
text: str = self.to_be_replaced
|
||||
if len(text) > self.__MAX_QUOTED:
|
||||
text = f"{text[:self.__MAX_QUOTED]}..."
|
||||
return (f"song-{self.song_id} {self.run} {self.type}"
|
||||
f" \"{text}\"")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorrectionTable:
|
||||
"""The researcher-reviewed repairs of the runs' records."""
|
||||
|
||||
MANUAL_CORRECTIONS_CSV: ClassVar[str] \
|
||||
= "coding-corrections.csv"
|
||||
"""The correction table CSV file's conventional name under
|
||||
``data/manual/``."""
|
||||
|
||||
path: Path
|
||||
"""The correction table CSV file the repairs came from."""
|
||||
corrections: list[Correction]
|
||||
"""The repairs, in file order."""
|
||||
|
||||
|
||||
class CorrectionsLoader:
|
||||
"""The loader of the researcher-reviewed correction table."""
|
||||
|
||||
__HEADER: tuple[str, str, str, str, str] = (
|
||||
"Song ID", "Run", "Type", "To Be Replaced", "Correct Term")
|
||||
"""The header row the correction table CSV file must carry."""
|
||||
|
||||
def __init__(self, path: Path, run_names: list[str]) -> None:
|
||||
"""Set up the loader of the correction table.
|
||||
|
||||
:param path: The correction table CSV file.
|
||||
:param run_names: The basenames of the run directories the
|
||||
command was given, in the given order.
|
||||
"""
|
||||
self.__path: Path = path
|
||||
"""The correction table CSV file."""
|
||||
self.__run_names: set[str] = set(run_names)
|
||||
"""The basenames of the given run directories."""
|
||||
|
||||
def run(self) -> CorrectionTable:
|
||||
r"""Load and validate the correction table.
|
||||
|
||||
Every row must name a song in the ``song-<ID>`` form, one
|
||||
of the runs the command was given, and a known type. The
|
||||
file is read with the CSV reader, so a quoted field may
|
||||
hold a comma or a double quote. No field holds a line
|
||||
break: the two text fields carry the two characters
|
||||
``\n`` where the text has a newline, so the file holds one
|
||||
row per line, and each of them is unescaped to a single LF
|
||||
before the repair is matched or applied, as that is what
|
||||
the archived records carry. Nothing is written.
|
||||
|
||||
:return: The repairs, in file order.
|
||||
:raises TallyError: When the file cannot be read, the
|
||||
header row is not the expected one, a row does not
|
||||
have the expected number of fields, a song ID is not
|
||||
in the ``song-<ID>`` form, a row names a run the
|
||||
command was not given, or a row has an unknown type.
|
||||
"""
|
||||
try:
|
||||
return CorrectionTable(
|
||||
path=self.__path,
|
||||
corrections=self.__load(
|
||||
self.__path, self.__run_names))
|
||||
except (OSError, ValueError) as error:
|
||||
raise TallyError(str(error)) from error
|
||||
|
||||
@classmethod
|
||||
def __load(cls, path: Path, run_names: set[str]) \
|
||||
-> list[Correction]:
|
||||
"""Read the rows of the correction table CSV file.
|
||||
|
||||
:param path: The correction table CSV file.
|
||||
:param run_names: The basenames of the given run
|
||||
directories.
|
||||
:return: The repairs, in file order.
|
||||
:raises OSError: When the file cannot be read.
|
||||
:raises ValueError: When the file is empty, the header row
|
||||
is not the expected one, or a row is invalid.
|
||||
"""
|
||||
corrections: list[Correction] = []
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
reader: Any = csv.reader(file)
|
||||
header: list[str] | None = None
|
||||
row: list[str]
|
||||
for row in reader:
|
||||
if len(row) == 0:
|
||||
continue
|
||||
if header is None:
|
||||
header = row
|
||||
if tuple(row) != cls.__HEADER:
|
||||
raise ValueError(
|
||||
f"{path}: the header row is not"
|
||||
f" \"{','.join(cls.__HEADER)}\"")
|
||||
continue
|
||||
corrections.append(cls.__parse_row(
|
||||
row, reader.line_num, path, run_names))
|
||||
if header is None:
|
||||
raise ValueError(f"{path}: no header row")
|
||||
return corrections
|
||||
|
||||
@classmethod
|
||||
def __parse_row(cls, row: list[str], line: int, path: Path,
|
||||
run_names: set[str]) -> Correction:
|
||||
"""Validate one row of the correction table.
|
||||
|
||||
:param row: The row's fields, in file order.
|
||||
:param line: The line the row ends on.
|
||||
:param path: The correction table CSV file, for the error
|
||||
message.
|
||||
:param run_names: The basenames of the given run
|
||||
directories.
|
||||
:return: The repair the row states, its two text fields
|
||||
unescaped.
|
||||
:raises ValueError: When the row does not have the
|
||||
expected number of fields, its song ID is not in the
|
||||
``song-<ID>`` form, it names a run the command was not
|
||||
given, or its type is unknown.
|
||||
"""
|
||||
label: str = f"{path}: line {line}"
|
||||
if len(row) != len(cls.__HEADER):
|
||||
raise ValueError(
|
||||
f"{label}: expected {len(cls.__HEADER)} fields,"
|
||||
f" got {len(row)}")
|
||||
song_id: int = cls.__parse_song_id(row[0], label)
|
||||
if row[1] not in run_names:
|
||||
given: str = ", ".join(sorted(run_names))
|
||||
raise ValueError(
|
||||
f"{label}: run \"{row[1]}\" is not among the given"
|
||||
f" runs {given}")
|
||||
if row[2] not in (Correction.KEYWORD, Correction.EVIDENCE):
|
||||
raise ValueError(f"{label}: unknown type \"{row[2]}\"")
|
||||
return Correction(
|
||||
line=line, song_id=song_id, run=row[1], type=row[2],
|
||||
to_be_replaced=cls.__unescape(row[3]),
|
||||
correct_term=cls.__unescape(row[4]))
|
||||
|
||||
@staticmethod
|
||||
def __unescape(text: str) -> str:
|
||||
r"""Unescape the newlines of a text field.
|
||||
|
||||
The mirror of the escaping :meth:`CodingTable.write` does
|
||||
to the ``Quote`` field it writes out.
|
||||
|
||||
:param text: The field as the CSV reader gave it.
|
||||
:return: The field with every two-character ``\n``
|
||||
sequence turned into a single LF.
|
||||
"""
|
||||
return text.replace("\\n", "\n")
|
||||
|
||||
@staticmethod
|
||||
def __parse_song_id(item_id: str, label: str) -> int:
|
||||
"""Parse the integer song ID out of a song ID field.
|
||||
|
||||
:param item_id: The song ID field, expected as
|
||||
``song-<ID>``.
|
||||
:param label: The location of the field, for the error
|
||||
message.
|
||||
:return: The parsed song ID.
|
||||
:raises ValueError: When the field is not ``song-<ID>``.
|
||||
"""
|
||||
prefix: str = "song-"
|
||||
if not item_id.startswith(prefix) \
|
||||
or not item_id[len(prefix):].isdigit():
|
||||
raise ValueError(
|
||||
f"{label}: song ID \"{item_id}\": not in"
|
||||
" \"song-<ID>\" form")
|
||||
return int(item_id[len(prefix):])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidKeywords:
|
||||
"""The keywords the coding records may carry."""
|
||||
|
||||
path: Path
|
||||
"""The keyword list file the keywords came from."""
|
||||
keywords: set[str]
|
||||
"""The allowed keywords."""
|
||||
|
||||
|
||||
class ValidKeywordsLoader:
|
||||
"""The loader of the valid keyword list."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Set up the loader of the valid keyword list.
|
||||
|
||||
:param path: The keyword list text file, one keyword per
|
||||
line.
|
||||
"""
|
||||
self.__path: Path = path
|
||||
"""The keyword list text file."""
|
||||
|
||||
def run(self) -> ValidKeywords:
|
||||
"""Load the valid keyword list.
|
||||
|
||||
Blank lines are ignored and every keyword is stripped of
|
||||
its surrounding whitespace; the file order carries no
|
||||
meaning. Nothing is written.
|
||||
|
||||
:return: The allowed keywords.
|
||||
:raises TallyError: When the file cannot be read.
|
||||
"""
|
||||
text: str
|
||||
try:
|
||||
text = self.__path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise TallyError(str(error)) from error
|
||||
return ValidKeywords(
|
||||
path=self.__path,
|
||||
keywords={x.strip() for x in text.split("\n")
|
||||
if x.strip() != ""})
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalliedCodings:
|
||||
"""The codes settled by a majority of the three coding runs."""
|
||||
@@ -84,7 +363,9 @@ class CodingTallier:
|
||||
settled code."""
|
||||
|
||||
def __init__(self, run_dir_1: Path, run_dir_2: Path,
|
||||
run_dir_3: Path) -> None:
|
||||
run_dir_3: Path,
|
||||
valid_keywords_txt: Path | None = None,
|
||||
corrections_csv: Path | None = None) -> None:
|
||||
"""Set up the tallier of the three coding runs.
|
||||
|
||||
:param run_dir_1: The first run's archive directory,
|
||||
@@ -93,11 +374,21 @@ class CodingTallier:
|
||||
containing ``output.jsonl``.
|
||||
:param run_dir_3: The third run's archive directory,
|
||||
containing ``output.jsonl``.
|
||||
:param valid_keywords_txt: The valid keyword list text
|
||||
file, or None to check no keyword.
|
||||
:param corrections_csv: The researcher-reviewed correction
|
||||
table CSV file, or None to repair no record.
|
||||
"""
|
||||
self.__run_dirs: list[Path] = [
|
||||
run_dir_1, run_dir_2, run_dir_3]
|
||||
"""The three runs' archive directories, in the given
|
||||
order."""
|
||||
self.__valid_keywords_txt: Path | None = valid_keywords_txt
|
||||
"""The valid keyword list text file, or None to check no
|
||||
keyword."""
|
||||
self.__corrections_csv: Path | None = corrections_csv
|
||||
"""The correction table CSV file, or None to repair no
|
||||
record."""
|
||||
|
||||
def run(self) -> TalliedCodings:
|
||||
"""Load the three coding runs and tally their votes.
|
||||
@@ -105,9 +396,13 @@ class CodingTallier:
|
||||
Every record of every run must be a successful result
|
||||
whose "text" parses to a JSON object of keywords mapped to
|
||||
their lyric quote lists, and the three runs must cover
|
||||
exactly the same set of song IDs. Only the keyword keys
|
||||
are counted; the quotes of a settled code are pooled for
|
||||
the output. Nothing is written.
|
||||
exactly the same set of song IDs. The researcher-reviewed
|
||||
repairs, when given, are applied to the records first, and
|
||||
every one of them must match something; the valid keyword
|
||||
list, when given, is checked against the repaired records
|
||||
next. Only the keyword keys are counted; the quotes of a
|
||||
settled code are pooled for the output. Nothing is
|
||||
written.
|
||||
|
||||
:return: The keywords at least two of the three runs
|
||||
assign, with their joined quotes, of every song the
|
||||
@@ -117,17 +412,192 @@ class CodingTallier:
|
||||
record is not a successful result, a "text" does not
|
||||
parse to a JSON object of quote string lists, a JSON
|
||||
document has a duplicate key, a run has two records of
|
||||
one song, or the three runs do not cover the same
|
||||
songs.
|
||||
one song, the three runs do not cover the same songs,
|
||||
the correction table or the valid keyword list cannot
|
||||
be read or is invalid, a correction matches nothing,
|
||||
or a keyword is not in the valid keyword list.
|
||||
"""
|
||||
corrections: CorrectionTable | None = self.__load_corrections()
|
||||
valid: ValidKeywords | None = self.__load_valid_keywords()
|
||||
runs: list[dict[int, dict[str, list[str]]]]
|
||||
try:
|
||||
runs = [self.__load_run(x) for x in self.__run_dirs]
|
||||
self.__check_same_songs(self.__run_dirs, runs)
|
||||
if corrections is not None:
|
||||
self.__correct(runs, self.__run_dirs, corrections)
|
||||
if valid is not None:
|
||||
self.__check_keywords(self.__run_dirs, runs, valid)
|
||||
except (OSError, ValueError) as error:
|
||||
raise TallyError(str(error)) from error
|
||||
return TalliedCodings(codings=self.__tally(runs))
|
||||
|
||||
def __load_corrections(self) -> CorrectionTable | None:
|
||||
"""Load the researcher-reviewed correction table.
|
||||
|
||||
:return: The repairs, or None when the caller gave no
|
||||
correction table.
|
||||
:raises TallyError: When the correction table cannot be
|
||||
read or is invalid.
|
||||
"""
|
||||
if self.__corrections_csv is None:
|
||||
return None
|
||||
return CorrectionsLoader(
|
||||
self.__corrections_csv,
|
||||
[x.name for x in self.__run_dirs]).run()
|
||||
|
||||
def __load_valid_keywords(self) -> ValidKeywords | None:
|
||||
"""Load the valid keyword list.
|
||||
|
||||
:return: The allowed keywords, or None when the caller
|
||||
gave no keyword list.
|
||||
:raises TallyError: When the keyword list cannot be read.
|
||||
"""
|
||||
if self.__valid_keywords_txt is None:
|
||||
return None
|
||||
return ValidKeywordsLoader(self.__valid_keywords_txt).run()
|
||||
|
||||
@classmethod
|
||||
def __correct(cls, runs: list[dict[int, dict[str, list[str]]]],
|
||||
run_dirs: list[Path],
|
||||
corrections: CorrectionTable) -> None:
|
||||
"""Apply the researcher-reviewed repairs to the records.
|
||||
|
||||
Each repair is applied to the runs whose directory
|
||||
basename it names, in file order. The table is
|
||||
hand-curated and is expected to be reconciled with the
|
||||
records, so a repair that matches nothing fails the run.
|
||||
|
||||
:param runs: The runs' records, in the given order,
|
||||
repaired in place.
|
||||
:param run_dirs: The runs' archive directories, in the
|
||||
same order.
|
||||
:param corrections: The repairs to apply.
|
||||
:return: None.
|
||||
:raises ValueError: When a repair matches nothing.
|
||||
"""
|
||||
applied: set[int] = set()
|
||||
index: int
|
||||
records: dict[int, dict[str, list[str]]]
|
||||
for index, records in enumerate(runs):
|
||||
position: int
|
||||
correction: Correction
|
||||
for position, correction \
|
||||
in enumerate(corrections.corrections):
|
||||
if correction.run != run_dirs[index].name:
|
||||
continue
|
||||
if cls.__correct_one(records, correction):
|
||||
applied.add(position)
|
||||
for position, correction in enumerate(
|
||||
corrections.corrections):
|
||||
if position not in applied:
|
||||
raise ValueError(
|
||||
f"{corrections.path}: line {correction.line}:"
|
||||
f" {correction.label}: matches nothing")
|
||||
|
||||
@classmethod
|
||||
def __correct_one(
|
||||
cls, records: dict[int, dict[str, list[str]]],
|
||||
correction: Correction) -> bool:
|
||||
"""Apply one repair to one run's records.
|
||||
|
||||
:param records: The run's records, keyed by the numeric
|
||||
part of the song ID, repaired in place.
|
||||
:param correction: The repair to apply.
|
||||
:return: Whether the repair matched anything.
|
||||
"""
|
||||
if correction.song_id not in records:
|
||||
return False
|
||||
keywords: dict[str, list[str]] = records[correction.song_id]
|
||||
if correction.type == Correction.KEYWORD:
|
||||
return cls.__correct_keyword(keywords, correction)
|
||||
return cls.__correct_evidence(keywords, correction)
|
||||
|
||||
@staticmethod
|
||||
def __correct_keyword(keywords: dict[str, list[str]],
|
||||
correction: Correction) -> bool:
|
||||
"""Rename or drop one keyword assignment of one record.
|
||||
|
||||
A rename onto a keyword the record already carries pools
|
||||
the two quote lists under the one keyword, which casts the
|
||||
one vote the record now states.
|
||||
|
||||
:param keywords: The song's assigned keywords and their
|
||||
lyric quotes, repaired in place.
|
||||
:param correction: The keyword repair to apply.
|
||||
:return: Whether the record carries the named keyword.
|
||||
"""
|
||||
if correction.to_be_replaced not in keywords:
|
||||
return False
|
||||
quotes: list[str] = keywords.pop(correction.to_be_replaced)
|
||||
if not correction.is_removal:
|
||||
keywords.setdefault(
|
||||
correction.correct_term, []).extend(quotes)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __correct_evidence(keywords: dict[str, list[str]],
|
||||
correction: Correction) -> bool:
|
||||
"""Rewrite or drop one lyric quote of one record.
|
||||
|
||||
The quote string is repaired under every keyword of the
|
||||
record that carries it, as one quote often grounds several
|
||||
keywords. Dropping the last quote of a keyword leaves the
|
||||
assignment standing with no quote at all.
|
||||
|
||||
:param keywords: The song's assigned keywords and their
|
||||
lyric quotes, repaired in place.
|
||||
:param correction: The evidence repair to apply.
|
||||
:return: Whether any keyword of the record carries the
|
||||
named quote.
|
||||
"""
|
||||
matched: bool = False
|
||||
keyword: str
|
||||
quotes: list[str]
|
||||
for keyword, quotes in keywords.items():
|
||||
if correction.to_be_replaced not in quotes:
|
||||
continue
|
||||
matched = True
|
||||
if correction.is_removal:
|
||||
keywords[keyword] = [
|
||||
x for x in quotes
|
||||
if x != correction.to_be_replaced]
|
||||
continue
|
||||
keywords[keyword] = [
|
||||
correction.correct_term
|
||||
if x == correction.to_be_replaced else x
|
||||
for x in quotes]
|
||||
return matched
|
||||
|
||||
@staticmethod
|
||||
def __check_keywords(
|
||||
run_dirs: list[Path],
|
||||
runs: list[dict[int, dict[str, list[str]]]],
|
||||
valid: ValidKeywords) -> None:
|
||||
"""Check every keyword against the valid keyword list.
|
||||
|
||||
:param run_dirs: The runs' archive directories, in the
|
||||
given order.
|
||||
:param runs: The runs' records, in the same order, the
|
||||
repairs already applied.
|
||||
:param valid: The allowed keywords.
|
||||
:return: None.
|
||||
:raises ValueError: When a record carries a keyword the
|
||||
list does not have.
|
||||
"""
|
||||
index: int
|
||||
records: dict[int, dict[str, list[str]]]
|
||||
for index, records in enumerate(runs):
|
||||
song_id: int
|
||||
for song_id in sorted(records):
|
||||
keyword: str
|
||||
for keyword in records[song_id]:
|
||||
if keyword in valid.keywords:
|
||||
continue
|
||||
raise ValueError(
|
||||
f"{run_dirs[index]}: song-{song_id}:"
|
||||
f" keyword \"{keyword}\": not in"
|
||||
f" {valid.path}")
|
||||
|
||||
@classmethod
|
||||
def __load_run(cls, run_dir: Path) \
|
||||
-> dict[int, dict[str, list[str]]]:
|
||||
@@ -362,13 +832,18 @@ class CodingTable:
|
||||
keyword, by Unicode code point."""
|
||||
|
||||
def write(self, output_csv: Path) -> None:
|
||||
"""Write the coding table CSV file.
|
||||
r"""Write the coding table CSV file.
|
||||
|
||||
Writes an RFC 4180 CSV file, UTF-8, with CRLF line
|
||||
endings, carrying the header row
|
||||
``Song,Artist Credit,Keyword,Quote`` and one row per
|
||||
settled keyword, in the row order. The parent directory
|
||||
is created when it does not exist.
|
||||
settled keyword, in the row order. The ``Quote`` field
|
||||
carries the two characters ``\n`` where the quotes have a
|
||||
newline, so no field holds a line break and the file
|
||||
holds one row per line; the correction table loader
|
||||
unescapes its own two text fields the same way, and
|
||||
restoring a single LF gives the lyric text back. The
|
||||
parent directory is created when it does not exist.
|
||||
|
||||
:param output_csv: The output CSV file.
|
||||
:return: None.
|
||||
@@ -379,7 +854,22 @@ class CodingTable:
|
||||
newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(self.__HEADER)
|
||||
writer.writerows(self.rows)
|
||||
writer.writerows(
|
||||
(*x[:3], self.__escape(x[3])) for x in self.rows)
|
||||
|
||||
@staticmethod
|
||||
def __escape(text: str) -> str:
|
||||
r"""Escape the newlines of the quote field.
|
||||
|
||||
The mirror of the unescaping the correction table loader
|
||||
does to its own two text fields.
|
||||
|
||||
:param text: The joined lyric quotes of one settled
|
||||
keyword.
|
||||
:return: The quotes with every LF turned into the two
|
||||
characters ``\n``.
|
||||
"""
|
||||
return text.replace("\n", "\\n")
|
||||
|
||||
|
||||
class CodingTableBuilder:
|
||||
@@ -503,11 +993,23 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"output_csv", type=Path,
|
||||
help="the output CSV file, by convention"
|
||||
f" results/{CodingTable.RESULT_CODINGS_CSV}")
|
||||
parser.add_argument(
|
||||
"--valid-keywords", type=Path, default=None,
|
||||
help="a plain text file of the allowed keywords, one per"
|
||||
" line, that every keyword left after the"
|
||||
" corrections must appear in (default: no check)")
|
||||
parser.add_argument(
|
||||
"--corrections", type=Path, default=None,
|
||||
help="the researcher-reviewed correction table CSV file,"
|
||||
" by convention"
|
||||
f" data/manual/{CorrectionTable.MANUAL_CORRECTIONS_CSV},"
|
||||
" applied to the runs' records before the tally"
|
||||
" (default: no repair)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Settle the coding by a majority of the three coding runs.
|
||||
r"""Settle the coding by a majority of the three coding runs.
|
||||
|
||||
Writes the final coding table as the given CSV file, holding
|
||||
the header row ``Song,Artist Credit,Keyword,Quote`` and one
|
||||
@@ -515,11 +1017,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
song named by its title and its stored artist credit from the
|
||||
SQLite working store, and the keyword carrying the pooled,
|
||||
deduplicated, and sorted lyric quotes of the runs that
|
||||
assigned it, joined with a single ``|``. Nothing is written
|
||||
when the three archives do not cover the same songs, a record
|
||||
is not a successful result, a record's "text" does not parse
|
||||
to a JSON object of quote string lists, or a song is not in
|
||||
the working store; the error message names what failed.
|
||||
assigned it, joined with a single ``|`` and written with the
|
||||
two characters ``\n`` where the quotes have a newline, so the
|
||||
table holds one row per line. The records are
|
||||
repaired from the ``--corrections`` table and then checked
|
||||
against the ``--valid-keywords`` list, when either is given.
|
||||
Nothing is written when the three archives do not cover the
|
||||
same songs, a record is not a successful result, a record's
|
||||
"text" does not parse to a JSON object of quote string lists,
|
||||
a correction is invalid or matches nothing, a keyword is not
|
||||
in the valid keyword list, or a song is not in the working
|
||||
store; the error message names what failed.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
@@ -529,7 +1037,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
try:
|
||||
codings: TalliedCodings = CodingTallier(
|
||||
args.run_dir_1, args.run_dir_2, args.run_dir_3).run()
|
||||
args.run_dir_1, args.run_dir_2, args.run_dir_3,
|
||||
args.valid_keywords, args.corrections).run()
|
||||
table: CodingTable = CodingTableBuilder(
|
||||
codings, args.output_csv).run()
|
||||
elapsed: str = format_duration(time.monotonic() - started)
|
||||
|
||||
@@ -114,19 +114,48 @@ class TestTallyCodings(unittest.TestCase):
|
||||
self.__write_output(self.__runs[index], [
|
||||
self.__record(x, songs[x]) for x in songs])
|
||||
|
||||
def __run_tally(self) -> tuple[int, str]:
|
||||
def __run_tally(self, *options: str) -> tuple[int, str]:
|
||||
"""Run the tally over the three run directories.
|
||||
|
||||
:param options: The optional command-line arguments.
|
||||
:return: A tuple of the exit status and the standard
|
||||
error.
|
||||
"""
|
||||
argv: list[str] = [
|
||||
*(str(x) for x in self.__runs), str(self.__output_csv)]
|
||||
*(str(x) for x in self.__runs), str(self.__output_csv),
|
||||
*options]
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = tally_codings.main(argv)
|
||||
return status, stderr.getvalue()
|
||||
|
||||
def __write_corrections(
|
||||
self, rows: list[tuple[str, str, str, str, str]]) \
|
||||
-> str:
|
||||
"""Write the correction table CSV file.
|
||||
|
||||
:param rows: The data rows, in file order.
|
||||
:return: The path of the correction table CSV file.
|
||||
"""
|
||||
path: Path = self.__dir / "corrections.csv"
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow((
|
||||
"Song ID", "Run", "Type", "To Be Replaced",
|
||||
"Correct Term"))
|
||||
writer.writerows(rows)
|
||||
return str(path)
|
||||
|
||||
def __write_valid_keywords(self, text: str) -> str:
|
||||
"""Write the valid keyword list text file.
|
||||
|
||||
:param text: The whole content of the file.
|
||||
:return: The path of the valid keyword list text file.
|
||||
"""
|
||||
path: Path = self.__dir / "valid-keywords.txt"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def __read_rows(self) -> list[list[str]]:
|
||||
"""Read the coding table CSV file.
|
||||
|
||||
@@ -222,6 +251,47 @@ class TestTallyCodings(unittest.TestCase):
|
||||
b"Alpha,A Singer,kw,"
|
||||
b"\"she said \"\"no\"\", twice\"\r\n")
|
||||
|
||||
def test_newline_in_quote_written_as_two_characters(
|
||||
self) -> None:
|
||||
r"""Test that a quote spanning two lyric lines is written
|
||||
with the two characters ``\n`` where the newline is,
|
||||
needing no RFC 4180 quoting."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] = {
|
||||
1: {"kw": ["You needed me\nTo feel a little more"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
status: int
|
||||
status, _ = self.__run_tally()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(
|
||||
self.__output_csv.read_bytes(),
|
||||
b"Song,Artist Credit,Keyword,Quote\r\n"
|
||||
b"Alpha,A Singer,kw,"
|
||||
b"You needed me\\nTo feel a little more\r\n")
|
||||
|
||||
def test_multi_line_quotes_keep_one_row_per_line(self) -> None:
|
||||
r"""Test that the file holds exactly one line per row, no
|
||||
field carrying a line break, and that turning the two
|
||||
characters ``\n`` back into a single LF gives the quotes
|
||||
as the runs wrote them."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] = {
|
||||
1: {"kw1": ["one line"],
|
||||
"kw2": ["first line\nsecond line"],
|
||||
"kw3": ["a\nb\nc", "d\ne"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
status: int
|
||||
status, _ = self.__run_tally()
|
||||
self.assertEqual(status, 0)
|
||||
data: bytes = self.__output_csv.read_bytes()
|
||||
self.assertEqual(data.count(b"\n"), 4)
|
||||
self.assertEqual(data.count(b"\r\n"), 4)
|
||||
rows: list[list[str]] = self.__read_rows()
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual(
|
||||
[x[3].replace("\\n", "\n") for x in rows[1:]],
|
||||
["one line", "first line\nsecond line", "a\nb\nc|d\ne"])
|
||||
|
||||
def test_empty_quote_lists_yield_an_empty_cell(self) -> None:
|
||||
"""Test that a settled keyword whose runs all gave an empty
|
||||
quote list carries an empty quote cell."""
|
||||
@@ -541,3 +611,395 @@ class TestTallyCodings(unittest.TestCase):
|
||||
codings, self.__output_csv).run()
|
||||
self.assertIn("song-9", str(context.exception))
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_keyword_correction_reunites_the_votes(self) -> None:
|
||||
"""Test that renaming a misspelled keyword in two runs
|
||||
joins the third run's vote and settles the code."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"womens-power": ["one"]}},
|
||||
{1: {"womens-power": ["two"]}},
|
||||
{1: {"women-power": ["three"]}},
|
||||
])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run1", "keyword", "womens-power",
|
||||
"women-power"),
|
||||
("song-1", "run2", "keyword", "womens-power",
|
||||
"women-power"),
|
||||
])
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "women-power",
|
||||
"one|three|two"]])
|
||||
|
||||
def test_keyword_correction_removal_drops_the_vote(
|
||||
self) -> None:
|
||||
"""Test that removing a keyword assignment leaves it
|
||||
casting no vote, so the code no longer settles."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"kw": ["one"], "kept": ["one"]}},
|
||||
{1: {"kw": ["two"], "kept": ["two"]}},
|
||||
{1: {"kept": ["three"]}},
|
||||
])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run2", "keyword", "kw", "**REMOVE**")])
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "kept", "one|three|two"]])
|
||||
|
||||
def test_keyword_correction_merges_into_the_existing_one(
|
||||
self) -> None:
|
||||
"""Test that renaming a keyword onto one the same record
|
||||
already carries pools their quotes into a single vote."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"womens-power": ["one"], "women-power": ["two"]}},
|
||||
{1: {"women-power": ["three"]}},
|
||||
{1: {"other": ["four"]}},
|
||||
])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run1", "keyword", "womens-power",
|
||||
"women-power")])
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "women-power",
|
||||
"one|three|two"]])
|
||||
|
||||
def test_evidence_correction_repairs_every_keyword(
|
||||
self) -> None:
|
||||
"""Test that one evidence row repairs the quote under
|
||||
every keyword of that song and run that carries it, and
|
||||
leaves the other runs alone."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"one": ["Shared line", "own"],
|
||||
"two": ["Shared line"]}},
|
||||
{1: {"one": ["shared line"], "two": ["shared line"]}},
|
||||
{1: {"one": ["shared line"], "two": ["shared line"]}},
|
||||
])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run1", "evidence", "Shared line",
|
||||
"shared line")])
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "one", "own|shared line"],
|
||||
["Alpha", "A Singer", "two", "shared line"]])
|
||||
|
||||
def test_evidence_correction_removal_keeps_the_assignment(
|
||||
self) -> None:
|
||||
"""Test that removing a quote leaves the keyword
|
||||
assignments standing, even with no quote left at all."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["hallucinated"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", x, "evidence", "hallucinated", "**REMOVE**")
|
||||
for x in ("run1", "run2", "run3")])
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "kw", ""]])
|
||||
|
||||
def test_correction_with_an_escaped_newline(self) -> None:
|
||||
r"""Test that a correction whose two text fields carry the
|
||||
two characters ``\n`` matches and replaces a quote that
|
||||
genuinely spans two lines, the file itself holding one row
|
||||
per line."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
quote: str = "You needed me\nTo feel a little more"
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": [quote]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
path: Path = self.__dir / "corrections.csv"
|
||||
path.write_bytes(
|
||||
b"Song ID,Run,Type,To Be Replaced,Correct Term\r\n"
|
||||
b"song-1,run1,evidence,"
|
||||
b"You needed me\\nTo feel a little more,"
|
||||
b"you needed me\\nTo feel a little more\r\n")
|
||||
self.assertEqual(len(path.read_bytes().split(b"\r\n")), 3)
|
||||
status: int
|
||||
status, _ = self.__run_tally("--corrections", str(path))
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "kw",
|
||||
"You needed me\\nTo feel a little more"
|
||||
"|you needed me\\nTo feel a little more"]])
|
||||
|
||||
def test_stale_correction_row_rejected(self) -> None:
|
||||
"""Test that a correction matching nothing fails the run,
|
||||
naming the row, without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run2", "evidence", "a line no run gave",
|
||||
"repaired")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("matches nothing", stderr)
|
||||
self.assertIn("song-1 run2 evidence", stderr)
|
||||
self.assertIn("a line no run gave", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_of_a_song_no_run_covers_rejected(
|
||||
self) -> None:
|
||||
"""Test that a correction naming a song outside the runs
|
||||
fails the run without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-7", "run1", "keyword", "kw", "**REMOVE**")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("song-7 run1 keyword", stderr)
|
||||
self.assertIn("matches nothing", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_of_an_unknown_run_rejected(self) -> None:
|
||||
"""Test that a correction naming a run the command was not
|
||||
given fails the run without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run4", "keyword", "kw", "**REMOVE**")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("run4", stderr)
|
||||
self.assertIn("run1, run2, run3", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_of_an_unknown_type_rejected(self) -> None:
|
||||
"""Test that a correction of an unknown type fails the run
|
||||
without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run1", "quote", "kw", "**REMOVE**")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("unknown type \"quote\"", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_with_a_malformed_song_id_rejected(
|
||||
self) -> None:
|
||||
"""Test that a correction whose song ID is not in the
|
||||
``song-<ID>`` form fails the run without writing the CSV
|
||||
file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
corrections: str = self.__write_corrections([
|
||||
("track-1", "run1", "keyword", "kw", "**REMOVE**")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("song-<ID>", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_table_header_rejected(self) -> None:
|
||||
"""Test that a correction table carrying another header
|
||||
row fails the run without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
path: Path = self.__dir / "corrections.csv"
|
||||
path.write_text(
|
||||
"Song,Run,Type,Old,New\r\n", encoding="utf-8")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", str(path))
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("header row", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_correction_row_of_the_wrong_width_rejected(
|
||||
self) -> None:
|
||||
"""Test that a correction row without all five fields
|
||||
fails the run without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
path: Path = self.__dir / "corrections.csv"
|
||||
path.write_text(
|
||||
"Song ID,Run,Type,To Be Replaced,Correct Term\r\n"
|
||||
"song-1,run1,keyword\r\n", encoding="utf-8")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", str(path))
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("expected 5 fields", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_missing_correction_table_rejected(self) -> None:
|
||||
"""Test that an unreadable correction table fails the run
|
||||
without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["the line"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--corrections", str(self.__dir / "absent.csv"))
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("absent.csv", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_off_vocabulary_keyword_rejected(self) -> None:
|
||||
"""Test that a keyword outside the valid keyword list
|
||||
fails the run, naming the run, the song and the keyword,
|
||||
without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"women-power": ["one"]}},
|
||||
{1: {"women-power": ["two"],
|
||||
"womens-power": ["three"]}},
|
||||
{1: {"women-power": ["four"]}},
|
||||
])
|
||||
valid: str = self.__write_valid_keywords("women-power\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally("--valid-keywords", valid)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("run2", stderr)
|
||||
self.assertIn("song-1", stderr)
|
||||
self.assertIn("womens-power", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_valid_keyword_list_read_loosely(self) -> None:
|
||||
"""Test that the valid keyword list ignores blank lines
|
||||
and surrounding whitespace and carries no order."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"zulu": ["q"], "alpha": ["q"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
valid: str = self.__write_valid_keywords(
|
||||
"\n zulu \n\nalpha\n\t\n")
|
||||
status: int
|
||||
status, _ = self.__run_tally("--valid-keywords", valid)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "alpha", "q"],
|
||||
["Alpha", "A Singer", "zulu", "q"]])
|
||||
|
||||
def test_corrections_checked_before_the_keyword_check(
|
||||
self) -> None:
|
||||
"""Test that the corrections are applied before the valid
|
||||
keyword check, so a misspelling the corrections repair
|
||||
does not fail the run."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"womens-power": ["one"]}},
|
||||
{1: {"womens-power": ["two"]}},
|
||||
{1: {"women-power": ["three"]}},
|
||||
])
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", x, "keyword", "womens-power", "women-power")
|
||||
for x in ("run1", "run2")])
|
||||
valid: str = self.__write_valid_keywords("women-power\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--valid-keywords", valid,
|
||||
"--corrections", corrections)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.__read_rows()[1:], [
|
||||
["Alpha", "A Singer", "women-power",
|
||||
"one|three|two"]])
|
||||
self.assertIn(
|
||||
"Done. Tallied 1 codes across 1 songs.", stderr)
|
||||
|
||||
def test_keyword_check_covers_the_unsettled_keywords(
|
||||
self) -> None:
|
||||
"""Test that a keyword only one run assigns, which never
|
||||
reaches the output table, is checked all the same."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
self.__write_codings([
|
||||
{1: {"kw": ["q"], "stray": ["q"]}},
|
||||
{1: {"kw": ["q"]}}, {1: {"kw": ["q"]}},
|
||||
])
|
||||
valid: str = self.__write_valid_keywords("kw\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally("--valid-keywords", valid)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("stray", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_missing_valid_keyword_list_rejected(self) -> None:
|
||||
"""Test that an unreadable valid keyword list fails the
|
||||
run without writing the CSV file."""
|
||||
self.__seed([("Alpha", "A Singer")])
|
||||
codings: dict[int, dict[str, list[str]]] \
|
||||
= {1: {"kw": ["q"]}}
|
||||
self.__write_codings([codings, codings, codings])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_tally(
|
||||
"--valid-keywords", str(self.__dir / "absent.txt"))
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("absent.txt", stderr)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
def test_corrections_loader_reads_the_rows(self) -> None:
|
||||
"""Test that the corrections loader alone parses the rows
|
||||
and writes no file."""
|
||||
corrections: str = self.__write_corrections([
|
||||
("song-1", "run1", "keyword", "kw", "**REMOVE**"),
|
||||
("song-2", "run3", "evidence", "a line", "A line"),
|
||||
])
|
||||
table: tally_codings.CorrectionTable \
|
||||
= tally_codings.CorrectionsLoader(
|
||||
Path(corrections),
|
||||
["run1", "run2", "run3"]).run()
|
||||
self.assertEqual(len(table.corrections), 2)
|
||||
first: tally_codings.Correction = table.corrections[0]
|
||||
self.assertEqual(first.song_id, 1)
|
||||
self.assertEqual(first.run, "run1")
|
||||
self.assertEqual(first.type, tally_codings.Correction.KEYWORD)
|
||||
self.assertEqual(first.to_be_replaced, "kw")
|
||||
self.assertTrue(first.is_removal)
|
||||
second: tally_codings.Correction = table.corrections[1]
|
||||
self.assertEqual(second.song_id, 2)
|
||||
self.assertEqual(
|
||||
second.type, tally_codings.Correction.EVIDENCE)
|
||||
self.assertEqual(second.correct_term, "A line")
|
||||
self.assertFalse(second.is_removal)
|
||||
self.assertFalse(self.__output_csv.exists())
|
||||
|
||||
Reference in New Issue
Block a user