diff --git a/docs/project_structure.md b/docs/project_structure.md index ea3b88a..2d76cdf 100644 --- a/docs/project_structure.md +++ b/docs/project_structure.md @@ -44,9 +44,10 @@ pop-fem-audit/ │ │ ├── fetch_lyrics.py # fetch missing lyrics from the │ │ │ # public APIs into the lyrics dir │ │ ├── models.py # SQLAlchemy ORM 資料模型 -│ │ └── run_llm.py # API runner:2+1 協定、Batch API、 -│ │ # 寫入引數指定的 runs 目錄;執行 -│ │ # 方式 pop-fem-audit-tools run-llm +│ │ ├── run_llm.py # API runner:2+1 協定、Batch API、 +│ │ │ # 寫入引數指定的 runs 目錄;執行 +│ │ │ # 方式 pop-fem-audit-tools run-llm +│ │ └── utils.py # 共用工具(format_duration) │ └── tests/ # 單元測試(unittest) ├── runs/ # 每次執行的完整稽核紀錄(進 git) │ └── <階段>/<日期>-<定義檔版本>/ diff --git a/tools/docs/source/pop_fem_audit_tools.rst b/tools/docs/source/pop_fem_audit_tools.rst index 074bccd..f2b03d9 100644 --- a/tools/docs/source/pop_fem_audit_tools.rst +++ b/tools/docs/source/pop_fem_audit_tools.rst @@ -60,6 +60,14 @@ pop\_fem\_audit\_tools.run\_llm module :show-inheritance: :undoc-members: +pop\_fem\_audit\_tools.utils module +----------------------------------- + +.. automodule:: pop_fem_audit_tools.utils + :members: + :show-inheritance: + :undoc-members: + Module contents --------------- diff --git a/tools/src/pop_fem_audit_tools/fetch_artists.py b/tools/src/pop_fem_audit_tools/fetch_artists.py index a1c9f50..02c4542 100644 --- a/tools/src/pop_fem_audit_tools/fetch_artists.py +++ b/tools/src/pop_fem_audit_tools/fetch_artists.py @@ -37,6 +37,7 @@ from sqlalchemy.orm import Session from . import VERSION from .database import ds from .models import Artist, Song, SongArtist +from .utils import format_duration API_URL: str = "https://www.wikidata.org/w/api.php" """The URL of the Wikidata API endpoint.""" @@ -779,25 +780,6 @@ def write_snapshot(file: TextIO) -> None: 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: """Fetch the artist metadata from Wikidata. diff --git a/tools/src/pop_fem_audit_tools/fetch_lyrics.py b/tools/src/pop_fem_audit_tools/fetch_lyrics.py index e8a092e..acd86db 100644 --- a/tools/src/pop_fem_audit_tools/fetch_lyrics.py +++ b/tools/src/pop_fem_audit_tools/fetch_lyrics.py @@ -43,6 +43,7 @@ from .models import ( Song, SongArtist, ) +from .utils import format_duration PROVENANCE_FIELDS: Sequence[str] = ( "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, non-zero on a setup error. """ + started: float = time.monotonic() args: argparse.Namespace = parse_args(argv) fetcher: LyricsFetcher = LyricsFetcher() fetched: int = 0 @@ -264,6 +266,9 @@ def main(argv: list[str] | None = None) -> int: return 1 finally: 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) return 0 diff --git a/tools/src/pop_fem_audit_tools/utils.py b/tools/src/pop_fem_audit_tools/utils.py new file mode 100644 index 0000000..1221b0b --- /dev/null +++ b/tools/src/pop_fem_audit_tools/utils.py @@ -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}" diff --git a/tools/tests/test_fetch_artists.py b/tools/tests/test_fetch_artists.py index 9ba1a18..dac99df 100644 --- a/tools/tests/test_fetch_artists.py +++ b/tools/tests/test_fetch_artists.py @@ -587,17 +587,6 @@ class TestFetchArtists(unittest.TestCase): urlopen.assert_not_called() 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: """Test the exact wording and timing of the summary line.""" diff --git a/tools/tests/test_fetch_lyrics.py b/tools/tests/test_fetch_lyrics.py index 582bed7..aa61d5a 100644 --- a/tools/tests/test_fetch_lyrics.py +++ b/tools/tests/test_fetch_lyrics.py @@ -170,7 +170,8 @@ class TestFetchLyrics(unittest.TestCase): ["1", "lyrics.ovh", "api-fetch"]) self.assertNotEqual(rows[1][3], "") 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: """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.__provenance.exists()) 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: """Test that a cached song triggers no HTTP request.""" diff --git a/tools/tests/test_utils.py b/tools/tests/test_utils.py new file mode 100644 index 0000000..e3d7f87 --- /dev/null +++ b/tools/tests/test_utils.py @@ -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")