Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/sumbuddy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from sumbuddy.__about__ import __version__
from sumbuddy.__main__ import get_checksums
from sumbuddy.mapper import Mapper
from sumbuddy.hasher import Hasher
from sumbuddy.mapper import Mapper

# Create instances of the classes
mapper_instance = Mapper()
Expand All @@ -11,4 +11,4 @@
gather_file_paths = mapper_instance.gather_file_paths
checksum_file = hasher_instance.checksum_file

__all__ = ["__version__", "checksum_file", "get_checksums", "gather_file_paths"]
__all__ = ["__version__", "checksum_file", "gather_file_paths", "get_checksums"]
48 changes: 29 additions & 19 deletions src/sumbuddy/__main__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import argparse
from sumbuddy.__about__ import __version__
from sumbuddy.hasher import Hasher
from sumbuddy.mapper import Mapper
from sumbuddy.archive import ArchiveHandler
from sumbuddy.exceptions import EmptyInputDirectoryError, NoFilesAfterFilteringError, LengthUsedForFixedLengthHashError
import csv
import hashlib
from tqdm import tqdm
import sys
import os
import sys
from contextlib import nullcontext

from tqdm import tqdm

def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hidden=False, algorithm='md5', length=None, archive_dive=True):
from sumbuddy.__about__ import __version__
from sumbuddy.archive import ArchiveHandler
from sumbuddy.exceptions import (
EmptyInputDirectoryError,
LengthUsedForFixedLengthHashError,
NoFilesAfterFilteringError,
OutputFileExistsError,
)
from sumbuddy.hasher import Hasher
from sumbuddy.mapper import Mapper


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 +32,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 All @@ -49,9 +62,12 @@ def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hi

hasher = Hasher(algorithm)
archive_handler = ArchiveHandler()
output_stream = open(output_filepath, 'w', newline='') if output_filepath else sys.stdout

try:
with (
open(output_filepath, 'w', newline='')
if output_filepath
else nullcontext(sys.stdout)
) as output_stream:
writer = csv.writer(output_stream)
writer.writerow(["filepath", "filename", f"{algorithm}"])

Expand All @@ -75,9 +91,6 @@ def get_checksums(input_path, output_filepath=None, ignore_file=None, include_hi
checksum = hasher.checksum_file(file_obj, algorithm=algorithm, length=length)
writer.writerow([virtual_path, os.path.basename(member), checksum])
pbar.update(1)
finally:
if output_filepath:
output_stream.close()

if output_filepath:
print(f"{algorithm} checksums for {input_path} written to {output_filepath}")
Expand All @@ -89,6 +102,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 +115,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 +124,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)
4 changes: 3 additions & 1 deletion src/sumbuddy/filter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pathspec
import os

import pathspec


class Filter:
def __init__(self):
self.spec = None
Expand Down
2 changes: 2 additions & 0 deletions src/sumbuddy/hasher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import hashlib

from sumbuddy.exceptions import LengthUsedForFixedLengthHashError


class Hasher:
def __init__(self, algorithm='md5'):
self.algorithm = algorithm
Expand Down
10 changes: 8 additions & 2 deletions src/sumbuddy/mapper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import os
from sumbuddy.filter import Filter
from sumbuddy.exceptions import EmptyInputDirectoryError, NoFilesAfterFilteringError, NotADirectoryError

from sumbuddy.archive import ArchiveHandler
from sumbuddy.exceptions import (
EmptyInputDirectoryError,
NoFilesAfterFilteringError,
NotADirectoryError,
)
from sumbuddy.filter import Filter


class Mapper:
def __init__(self):
Expand Down
3 changes: 1 addition & 2 deletions tests/test_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
import pytest

from sumbuddy.archive import ArchiveHandler
from sumbuddy.mapper import Mapper
from sumbuddy.hasher import Hasher

from sumbuddy.mapper import Mapper

TEST_ZIP = Path(__file__).parent / "test_archive.zip"

Expand Down
6 changes: 4 additions & 2 deletions tests/test_filter.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

import unittest
import os
import unittest
from unittest.mock import patch

from sumbuddy.archive import ArchiveHandler
from sumbuddy.filter import Filter
from sumbuddy.hasher import Hasher
from sumbuddy.archive import ArchiveHandler


class TestFilter(unittest.TestCase):
def setUp(self):
Expand Down
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")
9 changes: 5 additions & 4 deletions tests/test_getChecksums.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import unittest
from unittest.mock import patch, mock_open
import os
import tempfile
import unittest
from io import StringIO
from unittest.mock import mock_open, patch

from sumbuddy import get_checksums


class TestGetChecksums(unittest.TestCase):

def setUp(self):
Expand All @@ -20,7 +21,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 +91,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
21 changes: 13 additions & 8 deletions tests/test_hasher.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import hashlib
import os
import tempfile
import pytest
from unittest.mock import patch
from sumbuddy.hasher import Hasher

import pytest

from sumbuddy.exceptions import LengthUsedForFixedLengthHashError
from sumbuddy.hasher import Hasher


def is_algorithm_available(algorithm):
return algorithm in hashlib.algorithms_available
Expand Down Expand Up @@ -39,12 +42,14 @@ def is_algorithm_guaranteed(algorithm):

@pytest.fixture(scope="module")
def temp_file():
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.write(b'This is a test file.')
temp_file_path = temp_file.name
temp_file.close()
yield temp_file_path
os.remove(temp_file_path)
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(b'This is a test file.')
temp_file_path = temp_file.name

try:
yield temp_file_path
finally:
os.remove(temp_file_path)

@pytest.mark.parametrize("algorithm", checksums.keys())
def test_all_algorithms_with_defaults(temp_file, algorithm):
Expand Down
Loading
Loading