Align the fetch-lyrics summary with fetch-artists via shared utils

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:34:26 +08:00
co-authored by Claude Fable 5
parent 81975b72e4
commit 0d3cd69a0f
8 changed files with 68 additions and 36 deletions
+4 -3
View File
@@ -44,9 +44,10 @@ pop-fem-audit/
│ │ ├── fetch_lyrics.py # fetch missing lyrics from the │ │ ├── fetch_lyrics.py # fetch missing lyrics from the
│ │ │ # public APIs into the lyrics dir │ │ │ # public APIs into the lyrics dir
│ │ ├── models.py # SQLAlchemy ORM 資料模型 │ │ ├── models.py # SQLAlchemy ORM 資料模型
│ │ ── run_llm.py # API runner2+1 協定、Batch API、 │ │ ── run_llm.py # API runner2+1 協定、Batch API、
│ │ # 寫入引數指定的 runs 目錄;執行 │ │ # 寫入引數指定的 runs 目錄;執行
│ │ # 方式 pop-fem-audit-tools run-llm │ │ # 方式 pop-fem-audit-tools run-llm
│ │ └── utils.py # 共用工具(format_duration
│ └── tests/ # 單元測試(unittest │ └── tests/ # 單元測試(unittest
├── runs/ # 每次執行的完整稽核紀錄(進 git) ├── runs/ # 每次執行的完整稽核紀錄(進 git)
│ └── <階段>/<日期>-<定義檔版本>/ │ └── <階段>/<日期>-<定義檔版本>/
@@ -60,6 +60,14 @@ pop\_fem\_audit\_tools.run\_llm module
:show-inheritance: :show-inheritance:
:undoc-members: :undoc-members:
pop\_fem\_audit\_tools.utils module
-----------------------------------
.. automodule:: pop_fem_audit_tools.utils
:members:
:show-inheritance:
:undoc-members:
Module contents Module contents
--------------- ---------------
+1 -19
View File
@@ -37,6 +37,7 @@ from sqlalchemy.orm import Session
from . import VERSION from . import VERSION
from .database import ds from .database import ds
from .models import Artist, Song, SongArtist from .models import Artist, Song, SongArtist
from .utils import format_duration
API_URL: str = "https://www.wikidata.org/w/api.php" API_URL: str = "https://www.wikidata.org/w/api.php"
"""The URL of the Wikidata API endpoint.""" """The URL of the Wikidata API endpoint."""
@@ -779,25 +780,6 @@ def write_snapshot(file: TextIO) -> None:
writer.writerows(ordered) writer.writerows(ordered)
def format_duration(seconds: float) -> str:
"""Format an elapsed duration for the closing summary line.
:param seconds: The elapsed duration, in seconds.
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
once it reaches one hour.
"""
total: int = round(seconds)
hours: int
remainder: int
hours, remainder = divmod(total, 3600)
minutes: int
secs: int
minutes, secs = divmod(remainder, 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Fetch the artist metadata from Wikidata. """Fetch the artist metadata from Wikidata.
@@ -43,6 +43,7 @@ from .models import (
Song, Song,
SongArtist, SongArtist,
) )
from .utils import format_duration
PROVENANCE_FIELDS: Sequence[str] = ( PROVENANCE_FIELDS: Sequence[str] = (
"song_id", "source", "method", "acquired_at", "note") "song_id", "source", "method", "acquired_at", "note")
@@ -228,6 +229,7 @@ def main(argv: list[str] | None = None) -> int:
:return: The exit status: 0 on success, misses included, :return: The exit status: 0 on success, misses included,
non-zero on a setup error. non-zero on a setup error.
""" """
started: float = time.monotonic()
args: argparse.Namespace = parse_args(argv) args: argparse.Namespace = parse_args(argv)
fetcher: LyricsFetcher = LyricsFetcher() fetcher: LyricsFetcher = LyricsFetcher()
fetched: int = 0 fetched: int = 0
@@ -264,6 +266,9 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
finally: finally:
session.close() session.close()
print(f"done: {fetched} fetched, {missed} missed", attempted: int = fetched + missed
elapsed: str = format_duration(time.monotonic() - started)
print(f"Done. Fetched lyrics for {fetched}/{attempted}"
f" songs. {elapsed} elapsed.",
file=sys.stderr) file=sys.stderr)
return 0 return 0
+24
View File
@@ -0,0 +1,24 @@
# Tools for A Feminist Audit of Pop Music.
# Copyright 2026 imacat. All rights reserved.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
"""The shared utilities of the package."""
def format_duration(seconds: float) -> str:
"""Format an elapsed duration for the closing summary line.
:param seconds: The elapsed duration, in seconds.
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
once it reaches one hour.
"""
total: int = round(seconds)
hours: int
remainder: int
hours, remainder = divmod(total, 3600)
minutes: int
secs: int
minutes, secs = divmod(remainder, 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
-11
View File
@@ -587,17 +587,6 @@ class TestFetchArtists(unittest.TestCase):
urlopen.assert_not_called() urlopen.assert_not_called()
self.assertIn("error:", stderr) self.assertIn("error:", stderr)
def test_format_duration_under_hour(self) -> None:
"""Test the mm:ss format for a duration under one hour."""
self.assertEqual(
fetch_artists.format_duration(205), "03:25")
def test_format_duration_over_hour(self) -> None:
"""Test the h:mm:ss format once the duration reaches an
hour."""
self.assertEqual(
fetch_artists.format_duration(6439), "1:47:19")
def test_summary_line_exact_shape(self) -> None: def test_summary_line_exact_shape(self) -> None:
"""Test the exact wording and timing of the summary """Test the exact wording and timing of the summary
line.""" line."""
+4 -2
View File
@@ -170,7 +170,8 @@ class TestFetchLyrics(unittest.TestCase):
["1", "lyrics.ovh", "api-fetch"]) ["1", "lyrics.ovh", "api-fetch"])
self.assertNotEqual(rows[1][3], "") self.assertNotEqual(rows[1][3], "")
self.assertEqual(rows[1][4], "") self.assertEqual(rows[1][4], "")
self.assertIn("1 fetched, 0 missed", stderr) self.assertIn(
"Done. Fetched lyrics for 1/1 songs.", stderr)
def test_lrclib_fallback(self) -> None: def test_lrclib_fallback(self) -> None:
"""Test that an ovh miss falls back to an LRCLIB hit.""" """Test that an ovh miss falls back to an LRCLIB hit."""
@@ -247,7 +248,8 @@ class TestFetchLyrics(unittest.TestCase):
self.assertFalse((self.__lyrics / "1.txt").exists()) self.assertFalse((self.__lyrics / "1.txt").exists())
self.assertFalse(self.__provenance.exists()) self.assertFalse(self.__provenance.exists())
self.assertIn("song 1 \"Hello\": miss", stderr) self.assertIn("song 1 \"Hello\": miss", stderr)
self.assertIn("0 fetched, 1 missed", stderr) self.assertIn(
"Done. Fetched lyrics for 0/1 songs.", stderr)
def test_cached_song_skipped(self) -> None: def test_cached_song_skipped(self) -> None:
"""Test that a cached song triggers no HTTP request.""" """Test that a cached song triggers no HTTP request."""
+21
View File
@@ -0,0 +1,21 @@
# Tools for A Feminist Audit of Pop Music.
# Copyright 2026 imacat. All rights reserved.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
"""Unit tests for the shared utilities module."""
import unittest
from pop_fem_audit_tools import utils
class TestUtils(unittest.TestCase):
"""Test cases for the shared utilities."""
def test_format_duration_under_hour(self) -> None:
"""Test the mm:ss format for a duration under one hour."""
self.assertEqual(utils.format_duration(205), "03:25")
def test_format_duration_over_hour(self) -> None:
"""Test the h:mm:ss format once the duration reaches an
hour."""
self.assertEqual(utils.format_duration(6439), "1:47:19")