diff --git a/docs/source/reference/cli/index.md b/docs/source/reference/cli/index.md index e4d931e..8c79c26 100644 --- a/docs/source/reference/cli/index.md +++ b/docs/source/reference/cli/index.md @@ -17,6 +17,8 @@ for the full option list. ## Notes - Global CLI options are documented in {doc}`base`. +- Use `--log-file path/to/cli.log` to save CLI logs to a file while still + showing them in the terminal. - Paths with spaces should be wrapped in quotes. - Input audio is expected to be mono. - `process` uses the optional `--detection-threshold` override. diff --git a/docs/source/tutorials/run-inference-on-folder.md b/docs/source/tutorials/run-inference-on-folder.md index 48f647c..6056a11 100644 --- a/docs/source/tutorials/run-inference-on-folder.md +++ b/docs/source/tutorials/run-inference-on-folder.md @@ -97,6 +97,23 @@ What this does: - runs the model on each recording, - saves the results in `path/to/outputs`. +```{eval-rst} +.. admonition:: Save CLI logs to a file + :collapsible: closed + :class: tip dropdown + + If you want to keep a copy of the CLI logs, add ``--log-file`` before the + subcommand: + + .. code-block:: bash + + batdetect2 --log-file path/to/cli.log process directory \ + path/to/audio \ + path/to/outputs + + This writes the same CLI logs to ``path/to/cli.log`` and to the terminal. +``` + You do not need to choose a model for this first run. If you do nothing, BatDetect2 uses the built-in default UK model. diff --git a/src/batdetect2/cli/base.py b/src/batdetect2/cli/base.py index 9a991e5..bb88f81 100644 --- a/src/batdetect2/cli/base.py +++ b/src/batdetect2/cli/base.py @@ -1,5 +1,7 @@ """BatDetect2 command line interface.""" +from pathlib import Path + import click from batdetect2.cli.ascii import BATDETECT_ASCII_ART @@ -22,8 +24,17 @@ BatDetect2 count=True, help="Increase verbosity. -v for INFO, -vv for DEBUG.", ) +@click.option( + "--log-file", + type=click.Path(path_type=Path), + help="Write CLI logs to a file in addition to the terminal.", +) @click.pass_context -def cli(ctx: click.Context, verbose: int = 0): +def cli( + ctx: click.Context, + verbose: int = 0, + log_file: Path | None = None, +) -> None: """Run the BatDetect2 CLI. Use subcommands to run processing, training, evaluation, and dataset @@ -37,4 +48,4 @@ def cli(ctx: click.Context, verbose: int = 0): from batdetect2.logging import enable_logging - enable_logging(verbose) + enable_logging(verbose, log_file=log_file) diff --git a/src/batdetect2/logging.py b/src/batdetect2/logging.py index 5837407..b128451 100644 --- a/src/batdetect2/logging.py +++ b/src/batdetect2/logging.py @@ -50,7 +50,7 @@ __all__ = [ ] -def enable_logging(level: int): +def enable_logging(level: int, log_file: Path | None = None) -> None: logger.remove() if level == 0: @@ -61,6 +61,11 @@ def enable_logging(level: int): log_level = "DEBUG" logger.add(sys.stderr, level=log_level) + + if log_file is not None: + log_file.parent.mkdir(parents=True, exist_ok=True) + logger.add(log_file, level=log_level) + logger.enable("batdetect2") diff --git a/tests/test_cli/test_base.py b/tests/test_cli/test_base.py index a96a600..54918e3 100644 --- a/tests/test_cli/test_base.py +++ b/tests/test_cli/test_base.py @@ -1,9 +1,13 @@ """Behavior-focused tests for top-level CLI command discovery.""" +from pathlib import Path + from click.testing import CliRunner from batdetect2.cli import cli +BASE_DIR = Path(__file__).parent.parent.parent + def test_cli_base_help_lists_main_commands() -> None: """User story: discover available workflows from top-level help.""" @@ -11,8 +15,43 @@ def test_cli_base_help_lists_main_commands() -> None: result = CliRunner().invoke(cli, ["--help"]) assert result.exit_code == 0 + assert "--log-file" in result.output assert "process" in result.output assert "train" in result.output assert "evaluate" in result.output assert "data" in result.output assert "detect" in result.output + + +def test_cli_writes_logs_to_file_and_terminal( + tmp_path: Path, + tiny_checkpoint_path: Path, +) -> None: + """User story: save CLI logs to a file while keeping terminal output.""" + + output_dir = tmp_path / "eval_out" + log_file = tmp_path / "logs" / "cli.log" + + result = CliRunner().invoke( + cli, + [ + "--log-file", + str(log_file), + "-v", + "evaluate", + str(BASE_DIR / "example_data" / "dataset.yaml"), + "--model", + str(tiny_checkpoint_path), + "--base-dir", + str(BASE_DIR), + "--workers", + "0", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0 + assert "Initiating evaluation process..." in result.output + assert log_file.exists() + assert "Initiating evaluation process..." in log_file.read_text()