diff --git a/README.md b/README.md index a40f24a..4710938 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install sum-buddy ### Command Line Usage ``` -usage: sum-buddy [-h] [-V] [-o OUTPUT_FILE] [-i IGNORE_FILE | -H] [-a ALGORITHM] [-l LENGTH] [--archive-dive | --no-archive-dive] input_path +usage: sum-buddy [-h] [-V] [-o OUTPUT_FILE] [-f] [-i IGNORE_FILE | -H] [-a ALGORITHM] [-l LENGTH] [--archive-dive | --no-archive-dive] input_path Generate CSV with filepath, filename, and checksums for all files in a given directory (or a single file) @@ -28,7 +28,8 @@ options: -h, --help show this help message and exit -V, --version show program's version number and exit -o OUTPUT_FILE, --output-file OUTPUT_FILE - Filepath for the output CSV file + Filepath for the output CSV file; defaults to stdout + -f, --force Overwrite the output file if it already exists -i IGNORE_FILE, --ignore-file IGNORE_FILE Filepath for the ignore patterns file -H, --include-hidden Include hidden files @@ -80,6 +81,16 @@ cat examples/checksums.csv > examples/example_content/testzip.zip/dir/file.txt,file.txt,7d52c7437e9af58dac029dd11b1024df > ``` + If the output file already exists, sum-buddy refuses to overwrite it and exits with an error: +> ```console +> The output file 'examples/checksums.csv' already exists. +> Pass force=True (Python) or use the -f/--force flag (CLI) to overwrite it. +> ``` + Pass `--force` (or `-f`) to overwrite: +```bash +sum-buddy --force --output-file examples/checksums.csv examples/example_content/ +``` + - **Ignore Contents Based on Patterns:** ```bash sum-buddy --output-file examples/checksums.csv --ignore-file examples/.sbignore_except_txt examples/example_content/ @@ -183,6 +194,9 @@ get_checksums(input_path, output_file, ignore_file=ignore_file, algorithm=alg) # outputs status bar followed by # Checksums written to examples/checksums.csv +# If output_file already exists, get_checksums raises sumbuddy.exceptions.OutputFileExistsError (a subclass of FileExistsError); pass force=True to overwrite instead +get_checksums(input_path, output_file, force=True) + # To gather a list of file paths according to ignore/include patterns file_paths = gather_file_paths(input_path, ignore_file=ignore_file) # or file_paths = gather_file_paths(input_path, include_hidden=include_hidden) diff --git a/src/sumbuddy/__main__.py b/src/sumbuddy/__main__.py index 163bd3e..2a3afe4 100644 --- a/src/sumbuddy/__main__.py +++ b/src/sumbuddy/__main__.py @@ -3,14 +3,14 @@ from sumbuddy.hasher import Hasher from sumbuddy.mapper import Mapper from sumbuddy.archive import ArchiveHandler -from sumbuddy.exceptions import EmptyInputDirectoryError, NoFilesAfterFilteringError, LengthUsedForFixedLengthHashError +from sumbuddy.exceptions import EmptyInputDirectoryError, NoFilesAfterFilteringError, LengthUsedForFixedLengthHashError, OutputFileExistsError import csv import hashlib from tqdm import tqdm import sys import os -def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hidden=False, algorithm='md5', length=None, archive_dive=True): +def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hidden=False, algorithm='md5', length=None, archive_dive=True, force=False): """ Generate a CSV file with the filepath, filename, and checksum of all files in the input directory according to patterns to ignore. Checksum column is labeled by the selected algorithm (e.g., 'md5' or 'sha256'). @@ -23,7 +23,11 @@ def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hi algorithm - String. Algorithm to use for checksums. Default: 'md5', see options with 'hashlib.algorithms_available'. length - Integer [conditionally optional]. Length of the digest for SHAKE (required) and BLAKE (optional) algorithms in bytes. archive_dive - Boolean [optional]. Whether to descend into archive files and hash their members. When False, archives are hashed as opaque files. Default: True. + force - Boolean [optional]. Whether to overwrite output_filepath if it already exists. Default is False, which raises OutputFileExistsError when the file exists. """ + if output_filepath and not force and os.path.exists(output_filepath): + raise OutputFileExistsError(output_filepath) + mapper = Mapper() if os.path.isfile(input_path): @@ -89,6 +93,7 @@ def main(): parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("input_path", help="File or directory to traverse for files") parser.add_argument("-o", "--output-file", help="Filepath for the output CSV file; defaults to stdout", default=None) + parser.add_argument("-f", "--force", action="store_true", help="Overwrite the output file if it already exists") group = parser.add_mutually_exclusive_group() group.add_argument("-i", "--ignore-file", help="Filepath for the ignore patterns file") group.add_argument("-H", "--include-hidden", action="store_true", help="Include hidden files") @@ -101,11 +106,6 @@ def main(): if args.output_file and not args.output_file.endswith('.csv'): parser.error("Output file is in CSV format; extension should be '.csv'") - if args.output_file and os.path.exists(args.output_file): - overwrite = input(f"Output file '{args.output_file}' already exists. Overwrite? [y/n]: ") - if overwrite.lower() != 'y': - sys.exit("Exited without executing") - try: get_checksums( args.input_path, @@ -115,8 +115,9 @@ def main(): algorithm=args.algorithm, length=args.length, archive_dive=args.archive_dive, + force=args.force, ) - except (EmptyInputDirectoryError, NoFilesAfterFilteringError, LengthUsedForFixedLengthHashError) as e: + except (EmptyInputDirectoryError, NoFilesAfterFilteringError, LengthUsedForFixedLengthHashError, OutputFileExistsError) as e: sys.exit(str(e)) diff --git a/src/sumbuddy/exceptions.py b/src/sumbuddy/exceptions.py index 745660d..5257c33 100644 --- a/src/sumbuddy/exceptions.py +++ b/src/sumbuddy/exceptions.py @@ -17,3 +17,8 @@ class LengthUsedForFixedLengthHashError(Exception): def __init__(self, algorithm): message = f"Length paremeter is not applicable for fixed-length algorithm '{algorithm}'." super().__init__(message) + +class OutputFileExistsError(FileExistsError): + def __init__(self, output_filepath): + message = f"The output file '{output_filepath}' already exists.\nPass force=True (Python) or use the -f/--force flag (CLI) to overwrite it." + super().__init__(message) diff --git a/tests/test_force.py b/tests/test_force.py new file mode 100644 index 0000000..c89fdf3 --- /dev/null +++ b/tests/test_force.py @@ -0,0 +1,70 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sumbuddy import __main__ as sb_main +from sumbuddy import get_checksums +from sumbuddy.exceptions import OutputFileExistsError + +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" + + +def test_get_checksums_refuses_existing_output(monkeypatch, tmp_path): + """Without force, an existing output file raises before any walking or hashing, leaving the file untouched.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("sentinel content") + + with patch("sumbuddy.Mapper.gather_file_paths") as mock_gather, pytest.raises(OutputFileExistsError) as excinfo: + get_checksums("example_content", str(output_file)) + + mock_gather.assert_not_called() + assert isinstance(excinfo.value, FileExistsError) + assert output_file.read_text() == "sentinel content" + assert "force=True" in str(excinfo.value) + assert "--force" in str(excinfo.value) + + +def test_get_checksums_overwrites_with_force(monkeypatch, tmp_path): + """With force=True, an existing output file is regenerated from scratch.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("stale content") + + get_checksums("example_content", str(output_file), force=True) + + actual = output_file.read_text() + assert "stale content" not in actual + expected = (EXAMPLES_DIR / "expected_outputs" / "default.csv").read_text().splitlines() + assert sorted(actual.splitlines()) == sorted(expected) + + +def test_main_exits_when_output_exists(monkeypatch, tmp_path): + """CLI exits with a clear message, without prompting, when the output file exists.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("sentinel content") + + monkeypatch.setattr("builtins.input", lambda *_: pytest.fail("CLI prompted for input")) + monkeypatch.setattr(sys, "argv", ["sum-buddy", "-o", str(output_file), "example_content"]) + with pytest.raises(SystemExit) as excinfo: + sb_main.main() + + assert "already exists" in str(excinfo.value) + assert "--force" in str(excinfo.value) + assert output_file.read_text() == "sentinel content" + + +@pytest.mark.parametrize("flag", ["-f", "--force"]) +def test_main_force_overwrites_existing_output(monkeypatch, tmp_path, flag): + """Both force spellings overwrite an existing output file end-to-end.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("stale content") + + monkeypatch.setattr(sys, "argv", ["sum-buddy", flag, "-o", str(output_file), "example_content"]) + sb_main.main() + + assert output_file.read_text().startswith("filepath,filename,md5") diff --git a/tests/test_getChecksums.py b/tests/test_getChecksums.py index 93156ee..5e28efd 100644 --- a/tests/test_getChecksums.py +++ b/tests/test_getChecksums.py @@ -20,7 +20,7 @@ def setUp(self): @patch('builtins.open', new_callable=mock_open) @patch('sumbuddy.Hasher.checksum_file', return_value='dummychecksum') def test_get_checksums_single_file_to_file(self, mock_checksum, mock_open, mock_isfile): - get_checksums(self.input_path, self.output_filepath, ignore_file=None, include_hidden=False, algorithm=self.algorithm) + get_checksums(self.input_path, self.output_filepath, ignore_file=None, include_hidden=False, algorithm=self.algorithm, force=True) mock_open.assert_called_with(self.output_filepath, 'w', newline='') handle = mock_open() @@ -90,7 +90,7 @@ def test_get_checksums_normalizes_dot_single_file_path_in_stdout(self, mock_chec @patch('sumbuddy.Mapper.gather_file_paths', return_value=(['file1.txt', 'file2.txt'], [])) @patch('sumbuddy.Hasher.checksum_file', side_effect=lambda x, **kwargs: 'dummychecksum') def test_get_checksums_to_file(self, mock_checksum, mock_gather, mock_open, mock_exists, mock_abspath): - get_checksums(self.input_path, self.output_filepath, ignore_file=None, include_hidden=False, algorithm=self.algorithm) + get_checksums(self.input_path, self.output_filepath, ignore_file=None, include_hidden=False, algorithm=self.algorithm, force=True) mock_open.assert_called_with(self.output_filepath, 'w', newline='') handle = mock_open()