Package the capture-layer application into a CaptureImporter class
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,8 +206,10 @@
|
|||||||
(身分判定、拆解、正名)隨行入 class 作公開 staticmethod
|
(身分判定、拆解、正名)隨行入 class 作公開 staticmethod
|
||||||
/class 常數,「哪個函式屬哪個工作」由 class 歸屬直接
|
/class 常數,「哪個函式屬哪個工作」由 class 歸屬直接
|
||||||
表達。flush 定為匯入工作的完工契約——entry method 返回
|
表達。flush 定為匯入工作的完工契約——entry method 返回
|
||||||
時自身寫入已可查詢,不再由呼叫者補 flush。實測重構前後
|
時自身寫入已可查詢,不再由呼叫者補 flush。捕捉層套用
|
||||||
工作儲存 dump 與衍生報表逐位元組相同。
|
(歌詞、Wikidata 快照)隨後同型打包為 `CaptureImporter`
|
||||||
|
(單一入口收兩個可省略路徑)。實測重構前後工作儲存
|
||||||
|
dump 與衍生報表逐位元組相同。
|
||||||
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
||||||
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
||||||
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
||||||
|
|||||||
@@ -425,13 +425,52 @@ class ArtistImporter:
|
|||||||
return folded, name
|
return folded, name
|
||||||
|
|
||||||
|
|
||||||
def load_lyrics(session: Session, directory: Path) -> None:
|
class CaptureImporter:
|
||||||
|
"""The capture-import job: applies the optional capture-layer
|
||||||
|
inputs onto the stored songs and artists."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
"""Initialize the importer.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
"""
|
||||||
|
self.__session: Session = session
|
||||||
|
|
||||||
|
def import_captures(self, lyrics_dir: Path | None,
|
||||||
|
wikidata_csv: Path | None) -> None:
|
||||||
|
"""Apply the optional capture-layer inputs onto the store.
|
||||||
|
|
||||||
|
A None input leaves its capture layer unloaded. When
|
||||||
|
``lyrics_dir`` is given, its cached lyrics files load into
|
||||||
|
the matching songs (see `__load_lyrics`). When
|
||||||
|
``wikidata_csv`` is given, it applies onto the artist rows
|
||||||
|
(see `__apply_artist_csv`). When the method returns, the
|
||||||
|
applied changes are queryable in the session.
|
||||||
|
|
||||||
|
:param lyrics_dir: The lyrics cache directory to load, or
|
||||||
|
None to skip the lyrics capture layer.
|
||||||
|
:param wikidata_csv: The Wikidata artist snapshot CSV file
|
||||||
|
to apply, or None to skip the artist capture layer.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When ``lyrics_dir`` does not exist, or
|
||||||
|
a name in ``wikidata_csv`` matches no artist.
|
||||||
|
:raises OSError: When a capture file cannot be read.
|
||||||
|
"""
|
||||||
|
if lyrics_dir is not None:
|
||||||
|
if not lyrics_dir.is_dir():
|
||||||
|
raise BuildError(
|
||||||
|
f"{lyrics_dir}: no such directory")
|
||||||
|
self.__load_lyrics(lyrics_dir)
|
||||||
|
if wikidata_csv is not None:
|
||||||
|
self.__apply_artist_csv(wikidata_csv)
|
||||||
|
self.__session.flush()
|
||||||
|
|
||||||
|
def __load_lyrics(self, directory: Path) -> None:
|
||||||
"""Load the cached lyrics files into the matching songs.
|
"""Load the cached lyrics files into the matching songs.
|
||||||
|
|
||||||
A file whose stem is not an existing song ID is skipped with
|
A file whose stem is not an existing song ID is skipped
|
||||||
a warning to the standard error.
|
with a warning to the standard error.
|
||||||
|
|
||||||
:param session: The database session, with the songs flushed.
|
|
||||||
:param directory: The existing lyrics cache directory with
|
:param directory: The existing lyrics cache directory with
|
||||||
one ``<song_id>.txt`` file per song.
|
one ``<song_id>.txt`` file per song.
|
||||||
:return: None.
|
:return: None.
|
||||||
@@ -440,24 +479,21 @@ def load_lyrics(session: Session, directory: Path) -> None:
|
|||||||
for path in sorted(directory.glob("*.txt")):
|
for path in sorted(directory.glob("*.txt")):
|
||||||
song: Song | None = None
|
song: Song | None = None
|
||||||
if path.stem.isdigit():
|
if path.stem.isdigit():
|
||||||
song = session.get(Song, int(path.stem))
|
song = self.__session.get(Song, int(path.stem))
|
||||||
if song is None:
|
if song is None:
|
||||||
print(f"warning: {path}: no song with ID"
|
print(f"warning: {path}: no song with ID"
|
||||||
f" \"{path.stem}\"", file=sys.stderr)
|
f" \"{path.stem}\"", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
song.lyrics = path.read_text(encoding="utf-8")
|
song.lyrics = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def __apply_artist_csv(self, path: Path) -> None:
|
||||||
def apply_artist_csv(session: Session, path: Path) -> None:
|
|
||||||
"""Apply an artist attribute CSV onto the artist rows.
|
"""Apply an artist attribute CSV onto the artist rows.
|
||||||
|
|
||||||
Artists match by exact name. Only the non-empty cells are
|
Artists match by exact name. Only the non-empty cells are
|
||||||
applied, field by field. The note column is ignored.
|
applied, field by field. The note column is ignored.
|
||||||
|
|
||||||
:param session: The database session, with the artists
|
:param path: The CSV file with the columns name, qid,
|
||||||
flushed.
|
gender, type, genre, country, and note.
|
||||||
:param path: The CSV file with the columns name, qid, gender,
|
|
||||||
type, genre, country, and note.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
:raises BuildError: When a name matches no artist.
|
:raises BuildError: When a name matches no artist.
|
||||||
:raises OSError: When the file cannot be read.
|
:raises OSError: When the file cannot be read.
|
||||||
@@ -465,12 +501,13 @@ def apply_artist_csv(session: Session, path: Path) -> None:
|
|||||||
with open(path, encoding="utf-8", newline="") as file:
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
row: dict[str, str]
|
row: dict[str, str]
|
||||||
for row in csv.DictReader(file):
|
for row in csv.DictReader(file):
|
||||||
artist: Artist | None = session.scalar(
|
artist: Artist | None = self.__session.scalar(
|
||||||
sa.select(Artist)
|
sa.select(Artist)
|
||||||
.where(Artist.name == row["name"]))
|
.where(Artist.name == row["name"]))
|
||||||
if artist is None:
|
if artist is None:
|
||||||
raise BuildError(
|
raise BuildError(
|
||||||
f"{path}: no artist named \"{row['name']}\"")
|
f"{path}: no artist named"
|
||||||
|
f" \"{row['name']}\"")
|
||||||
column: str
|
column: str
|
||||||
attribute: str
|
attribute: str
|
||||||
for column, attribute in ARTIST_FIELDS.items():
|
for column, attribute in ARTIST_FIELDS.items():
|
||||||
@@ -764,14 +801,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
reset_store(session)
|
reset_store(session)
|
||||||
SongImporter(session).import_songs(args.chart_csv)
|
SongImporter(session).import_songs(args.chart_csv)
|
||||||
ArtistImporter(session).import_artists()
|
ArtistImporter(session).import_artists()
|
||||||
if args.lyrics_dir is not None:
|
CaptureImporter(session).import_captures(
|
||||||
if not args.lyrics_dir.is_dir():
|
args.lyrics_dir, args.wikidata_csv)
|
||||||
raise BuildError(
|
|
||||||
f"{args.lyrics_dir}: no such directory")
|
|
||||||
load_lyrics(session, args.lyrics_dir)
|
|
||||||
if args.wikidata_csv is not None:
|
|
||||||
apply_artist_csv(session, args.wikidata_csv)
|
|
||||||
session.flush()
|
|
||||||
violations: list[str] = find_violations(
|
violations: list[str] = find_violations(
|
||||||
session, YEARS, RANKS_PER_YEAR)
|
session, YEARS, RANKS_PER_YEAR)
|
||||||
if len(violations) > 0:
|
if len(violations) > 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user