From 7008e149725f2a756d5941c977e75e239bd02482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Thu, 6 Aug 2026 14:45:36 +0800 Subject: [PATCH] Import each command module only when its subcommand runs Co-Authored-By: Claude Opus 5 (1M context) --- .../pop_fem_audit_tools/commands/__init__.py | 114 ++++++++++++++++-- tools/tests/test_main.py | 29 ++++- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/tools/src/pop_fem_audit_tools/commands/__init__.py b/tools/src/pop_fem_audit_tools/commands/__init__.py index eca3d70..be1b7bf 100644 --- a/tools/src/pop_fem_audit_tools/commands/__init__.py +++ b/tools/src/pop_fem_audit_tools/commands/__init__.py @@ -2,11 +2,109 @@ # Copyright 2026 imacat. All rights reserved. # Authors: # imacat@mail.imacat.idv.tw (imacat), 2026/8/4 -"""The registry of the CLI subcommands.""" -from .build_db import main as build_db_command -from .cluster_keywords import main as cluster_keywords_command -from .compare_codings import main as compare_codings_command -from .export_llm_input import main as export_llm_input_command -from .fetch_artists import main as fetch_artists_command -from .fetch_lyrics import main as fetch_lyrics_command -from .run_llm import main as run_llm_command +"""The registry of the CLI subcommands. + +Each subcommand is a wrapper that imports its tool module on the +call, so that importing the registry costs nothing but this +module itself. +""" + + +def build_db_command(argv: list[str] | None = None) -> int: + """Rebuild the SQLite working store from the inputs. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .build_db import main + return main(argv) + + +def cluster_keywords_command(argv: list[str] | None = None) -> int: + """Pool the two tagging runs' keywords and cluster them. + + Writes the five fixed-named artifacts under the output + directory, creating it (with parents) if it does not exist: + the pooled keyword text file; then the group membership CSV + file, holding the clustering result alone; the group name + keyword text file, holding the same group names as a readable + list; the coding keyword set JSON file, holding the group + names plus every extra keyword given via ``--extra-keyword``; + and the run metadata JSON file, recording the command-line + choices and the environment. Each file is written as soon as + its content is computed, so when the input is rejected, or an + extra keyword duplicates a group name or another extra + keyword, the output directory holds whatever the steps before + the failing one produced, and the error message names what + failed. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .cluster_keywords import main + return main(argv) + + +def compare_codings_command(argv: list[str] | None = None) -> int: + """Compare the two coding runs and export the disagreements. + + Writes the disagreement JSON file under the output directory, + creating it (with parents) if it does not exist. When the + input is rejected, the file is not written. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .compare_codings import main + return main(argv) + + +def export_llm_input_command(argv: list[str] | None = None) -> int: + """Export the LLM input JSONL file from the working store. + + Every song is exported, unless ``--extras-per-id`` is given, + in which case only the songs its file names are. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .export_llm_input import main + return main(argv) + + +def fetch_artists_command(argv: list[str] | None = None) -> int: + """Fetch the artist metadata from Wikidata. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, misses and errors + included, non-zero on a setup error. + """ + from .fetch_artists import main + return main(argv) + + +def fetch_lyrics_command(argv: list[str] | None = None) -> int: + """Fetch the missing song lyrics from the public APIs. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, misses included, + non-zero on a setup error. + """ + from .fetch_lyrics import main + return main(argv) + + +def run_llm_command(argv: list[str] | None = None) -> int: + """Run one LLM definition file against one input and archive it. + + :param argv: The command-line arguments, or None for ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .run_llm import main + return main(argv) diff --git a/tools/tests/test_main.py b/tools/tests/test_main.py index d0f412e..cd047d5 100644 --- a/tools/tests/test_main.py +++ b/tools/tests/test_main.py @@ -11,7 +11,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from unittest import mock -from pop_fem_audit_tools import __main__ +from pop_fem_audit_tools import __main__, commands from pop_fem_audit_tools.commands import run_llm @@ -33,8 +33,31 @@ class TestDispatcher(unittest.TestCase): return status, stdout.getvalue(), stderr.getvalue() def test_run_llm_registered(self) -> None: - """Test that run-llm is bound to the run_llm tool main.""" - self.assertIs(__main__.SUBCOMMANDS["run-llm"], run_llm.main) + """Test that run-llm is bound to the run_llm wrapper.""" + self.assertIs(__main__.SUBCOMMANDS["run-llm"], + commands.run_llm_command) + + def test_every_subcommand_registered(self) -> None: + """Test that every subcommand is bound to its wrapper.""" + for name, command in __main__.SUBCOMMANDS.items(): + with self.subTest(subcommand=name): + wrapper: str = f"{name.replace('-', '_')}_command" + self.assertIs(command, getattr(commands, wrapper)) + + def test_run_llm_command_delegates(self) -> None: + """Test that the run-llm wrapper calls the run_llm main.""" + tool: mock.Mock = mock.Mock(return_value=7) + with mock.patch.object(run_llm, "main", tool): + status: int = commands.run_llm_command(["--input", "x"]) + self.assertEqual(status, 7) + tool.assert_called_once_with(["--input", "x"]) + + def test_run_llm_command_defaults_to_none(self) -> None: + """Test that the run-llm wrapper defaults the arguments.""" + tool: mock.Mock = mock.Mock(return_value=0) + with mock.patch.object(run_llm, "main", tool): + commands.run_llm_command() + tool.assert_called_once_with(None) def test_run_llm_dispatch(self) -> None: """Test that run-llm forwards the arguments and the status."""