From 790bb939ffa7f0622c7ac91e8136a6a4c2b6a948 Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Mon, 27 Jul 2026 13:54:21 -0400 Subject: [PATCH 1/4] Add force flags for non-interactive overwrite Refuse to overwrite an existing output file by default in both the library and the CLI, per the consensus on #11 (option 3): - get_checksums gains force=False and raises OutputFileExistsError (subclass of FileExistsError) before any walking or hashing when the output file exists - CLI replaces the interactive overwrite prompt with -f/--force and a clear error message, making all runs non-interactive Co-Authored-By: Claude Fable 5 --- README.md | 20 +++++++- src/sumbuddy/__main__.py | 17 ++++--- src/sumbuddy/exceptions.py | 5 ++ tests/test_force.py | 100 +++++++++++++++++++++++++++++++++++++ tests/test_getChecksums.py | 4 +- 5 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 tests/test_force.py diff --git a/README.md b/README.md index a40f24a..31868de 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,11 @@ 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..9518fce 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..455bb79 --- /dev/null +++ b/tests/test_force.py @@ -0,0 +1,100 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sumbuddy import get_checksums +from sumbuddy.exceptions import OutputFileExistsError + +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" + + +def test_get_checksums_raises_when_output_exists(monkeypatch, tmp_path): + """Without force, an existing output file raises and is left untouched.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("sentinel content") + + with pytest.raises(OutputFileExistsError) as excinfo: + get_checksums("example_content", str(output_file)) + + assert output_file.read_text() == "sentinel content" + assert "force=True" in str(excinfo.value) + assert "--force" in str(excinfo.value) + + +def test_get_checksums_raises_builtin_file_exists_error(monkeypatch, tmp_path): + """OutputFileExistsError is catchable as the builtin FileExistsError.""" + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + output_file.write_text("sentinel content") + + with pytest.raises(FileExistsError): + get_checksums("example_content", str(output_file)) + + +def test_get_checksums_overwrites_with_force(monkeypatch, tmp_path): + """With force=True, an existing output file is overwritten.""" + 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) + + assert output_file.read_text().startswith("filepath,filename,md5") + + +def test_get_checksums_stdout_unaffected(monkeypatch, capsys): + """Output to stdout (no output_filepath) never triggers the overwrite guard.""" + monkeypatch.chdir(EXAMPLES_DIR) + + get_checksums("example_content") + + assert "filepath,filename,md5" in capsys.readouterr().out + + +def test_main_exits_when_output_exists(monkeypatch, tmp_path): + """CLI exits with a clear message, without prompting, when the output file exists.""" + from sumbuddy import __main__ as sb_main + + 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" + + +def test_main_force_passes_force_true(monkeypatch, tmp_path): + """-f/--force on the CLI threads force=True into get_checksums.""" + from sumbuddy import __main__ as sb_main + + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + + monkeypatch.setattr(sys, "argv", ["sum-buddy", "-f", "-o", str(output_file), "example_content"]) + with patch("sumbuddy.__main__.get_checksums") as mock_gc: + sb_main.main() + mock_gc.assert_called_once() + assert mock_gc.call_args.kwargs["force"] is True + + +def test_main_default_passes_force_false(monkeypatch, tmp_path): + """Without the flag, get_checksums receives force=False (the default).""" + from sumbuddy import __main__ as sb_main + + monkeypatch.chdir(EXAMPLES_DIR) + output_file = tmp_path / "checksums.csv" + + monkeypatch.setattr(sys, "argv", ["sum-buddy", "-o", str(output_file), "example_content"]) + with patch("sumbuddy.__main__.get_checksums") as mock_gc: + sb_main.main() + mock_gc.assert_called_once() + assert mock_gc.call_args.kwargs["force"] is False 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() From b36832020b7715c777431898e4f9ba4e33483878 Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Mon, 27 Jul 2026 14:04:58 -0400 Subject: [PATCH 2/4] Unwrap force-wrapped README comment --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 31868de..4710938 100644 --- a/README.md +++ b/README.md @@ -194,9 +194,7 @@ 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 +# 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 From e7d74f961b79138d7d88193d0078968ef63d9306 Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Mon, 27 Jul 2026 15:08:55 -0400 Subject: [PATCH 3/4] Match sibling exception message style --- src/sumbuddy/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sumbuddy/exceptions.py b/src/sumbuddy/exceptions.py index 9518fce..5257c33 100644 --- a/src/sumbuddy/exceptions.py +++ b/src/sumbuddy/exceptions.py @@ -20,5 +20,5 @@ def __init__(self, algorithm): 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." + 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) From a47ab8244567d7b7a33701f798ed26380653bd1f Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Mon, 27 Jul 2026 15:08:55 -0400 Subject: [PATCH 4/4] Consolidate force tests --- tests/test_force.py | 66 +++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 48 deletions(-) diff --git a/tests/test_force.py b/tests/test_force.py index 455bb79..c89fdf3 100644 --- a/tests/test_force.py +++ b/tests/test_force.py @@ -4,60 +4,45 @@ 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_raises_when_output_exists(monkeypatch, tmp_path): - """Without force, an existing output file raises and is left untouched.""" +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 pytest.raises(OutputFileExistsError) as excinfo: + 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_raises_builtin_file_exists_error(monkeypatch, tmp_path): - """OutputFileExistsError is catchable as the builtin FileExistsError.""" - monkeypatch.chdir(EXAMPLES_DIR) - output_file = tmp_path / "checksums.csv" - output_file.write_text("sentinel content") - - with pytest.raises(FileExistsError): - get_checksums("example_content", str(output_file)) - - def test_get_checksums_overwrites_with_force(monkeypatch, tmp_path): - """With force=True, an existing output file is overwritten.""" + """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) - assert output_file.read_text().startswith("filepath,filename,md5") - - -def test_get_checksums_stdout_unaffected(monkeypatch, capsys): - """Output to stdout (no output_filepath) never triggers the overwrite guard.""" - monkeypatch.chdir(EXAMPLES_DIR) - - get_checksums("example_content") - - assert "filepath,filename,md5" in capsys.readouterr().out + 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.""" - from sumbuddy import __main__ as sb_main - monkeypatch.chdir(EXAMPLES_DIR) output_file = tmp_path / "checksums.csv" output_file.write_text("sentinel content") @@ -72,29 +57,14 @@ def test_main_exits_when_output_exists(monkeypatch, tmp_path): assert output_file.read_text() == "sentinel content" -def test_main_force_passes_force_true(monkeypatch, tmp_path): - """-f/--force on the CLI threads force=True into get_checksums.""" - from sumbuddy import __main__ as sb_main - +@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", "-f", "-o", str(output_file), "example_content"]) - with patch("sumbuddy.__main__.get_checksums") as mock_gc: - sb_main.main() - mock_gc.assert_called_once() - assert mock_gc.call_args.kwargs["force"] is True - - -def test_main_default_passes_force_false(monkeypatch, tmp_path): - """Without the flag, get_checksums receives force=False (the default).""" - from sumbuddy import __main__ as sb_main - - monkeypatch.chdir(EXAMPLES_DIR) - output_file = tmp_path / "checksums.csv" + monkeypatch.setattr(sys, "argv", ["sum-buddy", flag, "-o", str(output_file), "example_content"]) + sb_main.main() - monkeypatch.setattr(sys, "argv", ["sum-buddy", "-o", str(output_file), "example_content"]) - with patch("sumbuddy.__main__.get_checksums") as mock_gc: - sb_main.main() - mock_gc.assert_called_once() - assert mock_gc.call_args.kwargs["force"] is False + assert output_file.read_text().startswith("filepath,filename,md5")