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..b7daf1f 100644 --- a/tests/test_cli/test_base.py +++ b/tests/test_cli/test_base.py @@ -1,18 +1,58 @@ """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.""" 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()