Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 9 additions & 8 deletions src/sumbuddy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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').

Expand All @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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))


Expand Down
5 changes: 5 additions & 0 deletions src/sumbuddy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
70 changes: 70 additions & 0 deletions tests/test_force.py
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions tests/test_getChecksums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading