Repository: Veinar/envcloak

Issue:
Add Support for Recursive Directory Encryption\Decryption
Currently, the `encrypt`/`decrypt` command supports encrypting files in a single directory but does not handle files in subdirectories. This limitation can be restrictive for users who organize their environment files in nested directory structures.

The goal of this issue is to enhance the encrypt and decrypt command to recursively process all files within a directory and its subdirectories. The output directory should mirror the input directory structure while appending `.enc` to encrypted files.

**Hints:**

1. Validation:
* Update the directory validation logic to check if a directory (and its subdirectories) contains any files. Use Path.rglob('*') or os.walk() for recursive checks.
* Ensure the --dry-run option considers subdirectory files.

2. Output Directory Structure:
* When encrypting files, replicate the input directory structure under the output directory.
* Use Path.relative_to() to preserve subdirectory paths relative to the input directory.
* Ensure subdirectories are created in the output directory before writing encrypted files.

3. File Traversal:
* Leverage Path.rglob('*') for recursive traversal or use os.walk().
* Process only regular files (is_file()) and skip directories.

4. Existing Functionality:
* The new implementation should integrate seamlessly with existing flags (--dry-run, --force, etc.).
* Maintain the current behavior for single files and top-level directory encryption.

Discussion:

Solution patch:
diff --git a/envcloak/commands/decrypt.py b/envcloak/commands/decrypt.py
index edb2e20..8adb0dc 100644
--- a/envcloak/commands/decrypt.py
+++ b/envcloak/commands/decrypt.py
@@ -9,6 +9,7 @@
     dry_run_option,
     force_option,
     no_sha_validation_option,
+    recursion,
 )
 from envcloak.validation import (
     check_file_exists,
@@ -18,7 +19,7 @@
     check_permissions,
     check_disk_space,
 )
-from envcloak.encryptor import decrypt_file
+from envcloak.encryptor import decrypt_file, traverse_and_process_files
 from envcloak.exceptions import (
     OutputFileExistsException,
     DiskSpaceException,
@@ -31,6 +32,7 @@
 @dry_run_option
 @force_option
 @no_sha_validation_option
+@recursion
 @click.option(
     "--input",
     "-i",
@@ -53,7 +55,15 @@
     "--key-file", "-k", required=True, help="Path to the decryption key file."
 )
 def decrypt(
-    input, directory, output, key_file, dry_run, force, debug, skip_sha_validation
+    input,
+    directory,
+    output,
+    key_file,
+    dry_run,
+    force,
+    debug,
+    skip_sha_validation,
+    recursion,
 ):
     """
     Decrypt environment variables from a file or all files in a directory.
@@ -129,31 +139,21 @@ def decrypt(
             decrypt_file(input, output, key, validate_integrity=not skip_sha_validation)
             click.echo(f"File {input} decrypted -> {output} using key {key_file}")
         elif directory:
-            input_dir = Path(directory)
-            output_dir = Path(output)
-            if not output_dir.exists():
-                debug_log(
-                    f"Debug: Output directory {output_dir} does not exist. Creating it.",
-                    debug,
-                )
-                output_dir.mkdir(parents=True)
-
-            for file in input_dir.iterdir():
-                if file.is_file() and file.suffix == ".enc":  # Only decrypt .enc files
-                    output_file = output_dir / file.stem  # Remove .enc from filename
-                    debug_log(
-                        f"Debug: Decrypting file {file} -> {output_file} using key {key_file}.",
-                        debug,
-                    )
-                    decrypt_file(
-                        str(file),
-                        str(output_file),
-                        key,
-                        validate_integrity=not skip_sha_validation,
-                    )
-                    click.echo(
-                        f"File {file} decrypted -> {output_file} using key {key_file}"
-                    )
+            traverse_and_process_files(
+                directory,
+                output,
+                key,
+                dry_run,
+                debug,
+                process_file=lambda src, dest, key, dbg: decrypt_file(
+                    str(src),
+                    str(dest).replace(".enc", ""),
+                    key,
+                    validate_integrity=not skip_sha_validation,
+                ),
+                recursion=recursion,
+            )
+            click.echo(f"All files in directory {directory} decrypted -> {output}")
     except (
         OutputFileExistsException,
         DiskSpaceException,
diff --git a/envcloak/commands/encrypt.py b/envcloak/commands/encrypt.py
index 1b31699..14792a6 100644
--- a/envcloak/commands/encrypt.py
+++ b/envcloak/commands/encrypt.py
@@ -8,6 +8,7 @@
     debug_option,
     force_option,
     dry_run_option,
+    recursion,
 )
 from envcloak.validation import (
     check_file_exists,
@@ -17,7 +18,7 @@
     check_permissions,
     check_disk_space,
 )
-from envcloak.encryptor import encrypt_file
+from envcloak.encryptor import encrypt_file, traverse_and_process_files
 from envcloak.exceptions import (
     OutputFileExistsException,
     DiskSpaceException,
@@ -29,6 +30,7 @@
 @debug_option
 @dry_run_option
 @force_option
+@recursion
 @click.option(
     "--input", "-i", required=False, help="Path to the input file (e.g., .env)."
 )
@@ -47,7 +49,7 @@
 @click.option(
     "--key-file", "-k", required=True, help="Path to the encryption key file."
 )
-def encrypt(input, directory, output, key_file, dry_run, force, debug):
+def encrypt(input, directory, output, key_file, dry_run, force, debug, recursion):
     """
     Encrypt environment variables from a file or all files in a directory.
     """
@@ -126,26 +128,18 @@ def encrypt(input, directory, output, key_file, dry_run, force, debug):
             encrypt_file(input, output, key)
             click.echo(f"File {input} encrypted -> {output} using key {key_file}")
         elif directory:
-            input_dir = Path(directory)
-            output_dir = Path(output)
-            if not output_dir.exists():
-                debug_log(
-                    f"Debug: Output directory {output_dir} does not exist. Creating it.",
-                    debug,
-                )
-                output_dir.mkdir(parents=True)
-
-            for file in input_dir.iterdir():
-                if file.is_file():  # Skip directories
-                    output_file = output_dir / (file.name + ".enc")
-                    debug_log(
-                        f"Debug: Encrypting file {file} -> {output_file} using key {key_file}.",
-                        debug,
-                    )
-                    encrypt_file(str(file), str(output_file), key)
-                    click.echo(
-                        f"File {file} encrypted -> {output_file} using key {key_file}"
-                    )
+            traverse_and_process_files(
+                directory,
+                output,
+                key,
+                dry_run,
+                debug,
+                process_file=lambda src, dest, key, dbg: encrypt_file(
+                    str(src), str(dest) + ".enc", key
+                ),
+                recursion=recursion,
+            )
+            click.echo(f"All files in directory {directory} encrypted -> {output}")
     except (
         OutputFileExistsException,
         DiskSpaceException,
diff --git a/envcloak/decorators/common_decorators.py b/envcloak/decorators/common_decorators.py
index 4db3411..6177df8 100644
--- a/envcloak/decorators/common_decorators.py
+++ b/envcloak/decorators/common_decorators.py
@@ -40,3 +40,15 @@ def no_sha_validation_option(func):
         default=False,
         help="Skip SHA3 integrity validation checks during decryption.",
     )(func)
+
+
+def recursion(func):
+    """
+    Add `--recursion` and `-r` flags to a Click command.
+    """
+    return click.option(
+        "--recursion",
+        "-r",
+        is_flag=True,
+        help="Enable recursion to process files in subdirectories.",
+    )(func)
diff --git a/envcloak/encryptor.py b/envcloak/encryptor.py
index 92f883a..bd3cebd 100644
--- a/envcloak/encryptor.py
+++ b/envcloak/encryptor.py
@@ -1,6 +1,7 @@
 import os
 import base64
 import json
+from pathlib import Path
 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
 from cryptography.hazmat.primitives import hashes
@@ -17,7 +18,7 @@
     IntegrityCheckFailedException,
 )
 from envcloak.constants import NONCE_SIZE, KEY_SIZE, SALT_SIZE
-from envcloak.utils import compute_sha256
+from envcloak.utils import compute_sha256, debug_log
 
 
 def derive_key(password: str, salt: bytes) -> bytes:
@@ -213,3 +214,35 @@ def decrypt_file(
             outfile.write(decrypted_data)
     except Exception as e:
         raise FileDecryptionException(details=str(e)) from e
+
+
+def traverse_and_process_files(
+    input_dir, output_dir, key, dry_run, debug, process_file, recursion=False
+):
+    """
+    Traverse a directory recursively if `recursion` is enabled, process files,
+    and replicate the directory structure in output_dir.
+
+    :param input_dir: Source directory to traverse.
+    :param output_dir: Target directory to replicate structure and save processed files.
+    :param key: Encryption/decryption key.
+    :param dry_run: Flag indicating if the operation should be simulated without making changes.
+    :param debug: Debug flag for verbose logging.
+    :param process_file: Callable to process individual files.
+    :param recursion: Flag to enable or disable recursive traversal.
+    """
+    input_dir = Path(input_dir)
+    output_dir = Path(output_dir)
+
+    files_to_process = input_dir.rglob("*") if recursion else input_dir.iterdir()
+
+    for file_path in files_to_process:
+        if file_path.is_file():
+            relative_path = file_path.relative_to(input_dir)
+            target_path = output_dir / relative_path
+            target_path.parent.mkdir(parents=True, exist_ok=True)
+
+            if not dry_run:
+                process_file(file_path, target_path, key, debug)
+            else:
+                debug_log(f"Dry-run: Would process {file_path} -> {target_path}", debug)


Test patch:
diff --git a/tests/test_cli_decrypt.py b/tests/test_cli_decrypt.py
index c8d2c7a..89dc32e 100644
--- a/tests/test_cli_decrypt.py
+++ b/tests/test_cli_decrypt.py
@@ -121,13 +121,16 @@ def test_decrypt_with_force_directory(mock_decrypt_file, runner, isolated_mock_f
     output_directory.mkdir()
     (output_directory / "file1.env").write_text("existing decrypted content")
 
+    # Write a mock key file
+    key_file.write_bytes(b"mock_key")
+
     def mock_decrypt(input_path, output_path, key, validate_integrity=True):
         with open(output_path, "w") as f:
             f.write("decrypted content")
 
     mock_decrypt_file.side_effect = mock_decrypt
 
-    # Invoke with --force
+    # Invoke with --force and --recursion
     result = runner.invoke(
         main,
         [
@@ -139,24 +142,32 @@ def mock_decrypt(input_path, output_path, key, validate_integrity=True):
             "--key-file",
             str(key_file),
             "--force",
+            "--recursion",  # Enable recursion
             "--skip-sha-validation",
         ],
     )
 
+    # Check that output mentions overwriting existing files
     assert "Overwriting existing file" in result.output
+
+    # Verify that decrypt_file was called for each input file
     mock_decrypt_file.assert_any_call(
         str(directory / "file1.env.enc"),
         str(output_directory / "file1.env"),
-        key_file.read_bytes(),
+        b"mock_key",
         validate_integrity=False,  # Ensure the validate_integrity flag matches
     )
     mock_decrypt_file.assert_any_call(
         str(directory / "file2.env.enc"),
         str(output_directory / "file2.env"),
-        key_file.read_bytes(),
+        b"mock_key",
         validate_integrity=False,  # Ensure the validate_integrity flag matches
     )
 
+    # Ensure the output is clean and correct
+    assert (output_directory / "file1.env").read_text() == "decrypted content"
+    assert (output_directory / "file2.env").read_text() == "decrypted content"
+
 
 def test_decrypt_without_force_conflict(runner, mock_files):
     """
diff --git a/tests/test_cli_generate_key.py b/tests/test_cli_generate_key.py
index c61db9b..76bd888 100644
--- a/tests/test_cli_generate_key.py
+++ b/tests/test_cli_generate_key.py
@@ -170,6 +170,7 @@ def mock_create_key_from_password(password, output_path, salt):
     if temp_key_file.exists():
         temp_key_file.unlink()
 
+
 @patch("envcloak.generator.os.urandom")
 @patch("envcloak.generator.derive_key")
 def test_generate_key_from_password_random_salt(
@@ -205,7 +206,9 @@ def test_generate_key_from_password_random_salt(
 
     # Verify behavior
     assert temp_key_file.exists(), "The key file should be created."
-    assert temp_key_file.read_bytes() == expected_key, "The key file should contain the derived key."
+    assert (
+        temp_key_file.read_bytes() == expected_key
+    ), "The key file should contain the derived key."
     mock_urandom.assert_called_once_with(16)
     mock_derive_key.assert_called_once_with(password, mock_salt)
 
@@ -271,7 +274,9 @@ def test_generate_key_from_password_valid_salt(
 
     # Verify behavior
     assert temp_key_file.exists(), "The key file should be created."
-    assert temp_key_file.read_bytes() == expected_key, "The key file should contain the derived key."
+    assert (
+        temp_key_file.read_bytes() == expected_key
+    ), "The key file should contain the derived key."
     mock_derive_key.assert_called_once_with(password, valid_salt)
 
 
@@ -309,5 +314,7 @@ def test_generate_key_from_password_file_creation(
 
     # Verify behavior
     assert temp_key_file.exists(), "The key file should be created."
-    assert temp_key_file.read_bytes() == expected_key, "The key file should contain the derived key."
-    mock_derive_key.assert_called_once_with(password, bytes.fromhex(salt))
\ No newline at end of file
+    assert (
+        temp_key_file.read_bytes() == expected_key
+    ), "The key file should contain the derived key."
+    mock_derive_key.assert_called_once_with(password, bytes.fromhex(salt))
diff --git a/tests/test_cli_recursive_decrypt.py b/tests/test_cli_recursive_decrypt.py
new file mode 100644
index 0000000..e7a704d
--- /dev/null
+++ b/tests/test_cli_recursive_decrypt.py
@@ -0,0 +1,118 @@
+import os
+from unittest.mock import patch
+from pathlib import Path
+from click.testing import CliRunner
+import pytest
+from unittest.mock import call
+from envcloak.cli import main
+
+
+@patch("envcloak.commands.decrypt.decrypt_file")
+def test_recursive_decrypt(mock_decrypt_file, runner, isolated_mock_files):
+    """
+    Test recursive decryption using the `--recursion` flag.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create nested mock encrypted files in the directory
+    (directory / "subdir").mkdir(parents=True)
+    (directory / "file1.env.enc").write_text("encrypted content1")
+    (directory / "subdir" / "file2.env.enc").write_text("encrypted content2")
+
+    def mock_decrypt(input_path, output_path, key, validate_integrity=True):
+        with open(output_path, "w") as f:
+            f.write("decrypted content")
+
+    mock_decrypt_file.side_effect = mock_decrypt
+
+    # Invoke with --recursion
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+            "--recursion",
+        ],
+    )
+
+    # Assert decryption succeeded and files were processed recursively
+    assert result.exit_code == 0
+    assert "All files in directory" in result.output
+    mock_decrypt_file.assert_any_call(
+        str(directory / "file1.env.enc"),
+        str(output_directory / "file1.env"),
+        key_file.read_bytes(),
+        validate_integrity=True,
+    )
+    mock_decrypt_file.assert_any_call(
+        str(directory / "subdir" / "file2.env.enc"),
+        str(output_directory / "subdir" / "file2.env"),
+        key_file.read_bytes(),
+        validate_integrity=True,
+    )
+
+
+@patch("envcloak.commands.decrypt.decrypt_file")
+def test_recursive_decrypt_without_recursion(
+    mock_decrypt_file, runner, isolated_mock_files
+):
+    """
+    Test that `--recursion` is required for nested files during decryption.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create nested mock encrypted files in the directory
+    (directory / "subdir").mkdir(parents=True)
+    (directory / "file1.env.enc").write_text("encrypted content1")
+    (directory / "subdir" / "file2.env.enc").write_text("encrypted content2")
+
+    def mock_decrypt(input_path, output_path, key, validate_integrity=True):
+        with open(output_path, "w") as f:
+            f.write("decrypted content")
+
+    mock_decrypt_file.side_effect = mock_decrypt
+
+    # Invoke without --recursion
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+        ],
+    )
+
+    # Assert only top-level files are processed
+    assert "All files in directory" in result.output
+
+    # Ensure the top-level file is processed
+    mock_decrypt_file.assert_any_call(
+        str(directory / "file1.env.enc"),
+        str(output_directory / "file1.env"),
+        key_file.read_bytes(),
+        validate_integrity=True,
+    )
+
+    # Ensure the nested file is not processed
+    assert (
+        call(
+            str(directory / "subdir" / "file2.env.enc"),
+            str(output_directory / "subdir" / "file2.env"),
+            key_file.read_bytes(),
+            validate_integrity=True,
+        )
+        not in mock_decrypt_file.call_args_list
+    )
diff --git a/tests/test_cli_recursive_encrypt.py b/tests/test_cli_recursive_encrypt.py
new file mode 100644
index 0000000..5d82a0a
--- /dev/null
+++ b/tests/test_cli_recursive_encrypt.py
@@ -0,0 +1,115 @@
+import os
+from unittest.mock import patch
+import json
+from pathlib import Path
+from click.testing import CliRunner
+import pytest
+from unittest.mock import call
+from envcloak.cli import main
+
+
+@patch("envcloak.commands.encrypt.encrypt_file")
+def test_recursive_encrypt(mock_encrypt_file, runner, isolated_mock_files):
+    """
+    Test recursive encryption using the `--recursion` flag.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create nested mock files in the directory
+    (directory / "subdir").mkdir(parents=True)
+    (directory / "file1.env").write_text("content1")
+    (directory / "subdir" / "file2.env").write_text("content2")
+
+    def mock_encrypt(input_path, output_path, key):
+        with open(output_path, "w") as f:
+            f.write(json.dumps({"ciphertext": "encrypted_data"}))
+
+    mock_encrypt_file.side_effect = mock_encrypt
+
+    # Invoke with --recursion
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+            "--recursion",
+        ],
+    )
+
+    # Assert encryption succeeded and files were processed recursively
+    assert "All files in directory" in result.output
+    mock_encrypt_file.assert_any_call(
+        str(directory / "file1.env"),
+        str(output_directory / "file1.env.enc"),
+        key_file.read_bytes(),
+    )
+    mock_encrypt_file.assert_any_call(
+        str(directory / "subdir" / "file2.env"),
+        str(output_directory / "subdir" / "file2.env.enc"),
+        key_file.read_bytes(),
+    )
+
+
+@patch("envcloak.commands.encrypt.encrypt_file")
+def test_recursive_encrypt_without_recursion(
+    mock_encrypt_file, runner, isolated_mock_files
+):
+    """
+    Test that `--recursion` is required for nested files during encryption.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create nested mock files in the directory
+    (directory / "subdir").mkdir(parents=True)
+    (directory / "file1.env").write_text("content1")
+    (directory / "subdir" / "file2.env").write_text("content2")
+
+    def mock_encrypt(input_path, output_path, key):
+        with open(output_path, "w") as f:
+            f.write(json.dumps({"ciphertext": "encrypted_data"}))
+
+    mock_encrypt_file.side_effect = mock_encrypt
+
+    # Invoke without --recursion
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+        ],
+    )
+
+    # Assert only top-level files are processed
+    assert result.exit_code == 0
+    assert "All files in directory" in result.output
+
+    # Ensure the top-level file is processed
+    mock_encrypt_file.assert_any_call(
+        str(directory / "file1.env"),
+        str(output_directory / "file1.env.enc"),
+        key_file.read_bytes(),
+    )
+
+    # Ensure the nested file is not processed
+    assert (
+        call(
+            str(directory / "subdir" / "file2.env"),
+            str(output_directory / "subdir" / "file2.env.enc"),
+            key_file.read_bytes(),
+        )
+        not in mock_encrypt_file.call_args_list
+    )
diff --git a/tests/test_other_gitignore.py b/tests/test_other_gitignore.py
index e5dc939..bfb4734 100644
--- a/tests/test_other_gitignore.py
+++ b/tests/test_other_gitignore.py
@@ -23,11 +23,13 @@ def test_add_to_gitignore_existing_file(tmp_path, capsys):
     updated_content = gitignore_path.read_text(encoding="utf-8")
     assert filename in updated_content, f"{filename} should be in .gitignore"
     # Normalize the content to ignore extra newlines
-    normalized_content = "\n".join(
-        line for line in updated_content.splitlines() if line.strip()
-    ) + "\n"
+    normalized_content = (
+        "\n".join(line for line in updated_content.splitlines() if line.strip()) + "\n"
+    )
     expected_content = f"{existing_content.strip()}\n{filename}\n"
-    assert normalized_content == expected_content, f"Expected content:\n{expected_content}\nGot:\n{normalized_content}"
+    assert (
+        normalized_content == expected_content
+    ), f"Expected content:\n{expected_content}\nGot:\n{normalized_content}"
 
     # Verify the printed message
     captured = capsys.readouterr()
@@ -51,8 +53,10 @@ def test_add_to_gitignore_already_listed(tmp_path, capsys):
 
     # Verify that the content remains unchanged
     updated_content = gitignore_path.read_text(encoding="utf-8")
-    assert updated_content == existing_content, ".gitignore content should remain unchanged"
+    assert (
+        updated_content == existing_content
+    ), ".gitignore content should remain unchanged"
 
     # Verify no additional output
     captured = capsys.readouterr()
-    assert f"Added '{filename}'" not in captured.out
\ No newline at end of file
+    assert f"Added '{filename}'" not in captured.out
diff --git a/tests/test_other_overwriting.py b/tests/test_other_overwriting.py
index d9898db..2e13fa8 100644
--- a/tests/test_other_overwriting.py
+++ b/tests/test_other_overwriting.py
@@ -12,7 +12,9 @@ def test_check_directory_overwrite_existing_with_files(tmp_path):
     dir_with_files.mkdir()
     (dir_with_files / "file1.txt").touch()
 
-    with pytest.raises(OutputFileExistsException, match="Directory already exists and contains files"):
+    with pytest.raises(
+        OutputFileExistsException, match="Directory already exists and contains files"
+    ):
         check_directory_overwrite(str(dir_with_files))
 
 
@@ -26,7 +28,9 @@ def test_check_directory_overwrite_existing_empty(tmp_path):
     try:
         check_directory_overwrite(str(empty_dir))
     except OutputFileExistsException:
-        pytest.fail("check_directory_overwrite raised an exception for an empty directory.")
+        pytest.fail(
+            "check_directory_overwrite raised an exception for an empty directory."
+        )
 
 
 def test_check_directory_overwrite_nonexistent(tmp_path):
@@ -38,4 +42,6 @@ def test_check_directory_overwrite_nonexistent(tmp_path):
     try:
         check_directory_overwrite(str(nonexistent_dir))
     except OutputFileExistsException:
-        pytest.fail("check_directory_overwrite raised an exception for a nonexistent directory.")
+        pytest.fail(
+            "check_directory_overwrite raised an exception for a nonexistent directory."
+        )
diff --git a/tests/test_other_path_conflict.py b/tests/test_other_path_conflict.py
index 03829dd..1279c35 100644
--- a/tests/test_other_path_conflict.py
+++ b/tests/test_other_path_conflict.py
@@ -28,7 +28,9 @@ def test_check_path_conflict_non_overlapping_paths(tmp_path):
     try:
         check_path_conflict(str(input_path), str(output_path))
     except ValueError:
-        pytest.fail("check_path_conflict raised a ValueError for non-overlapping paths.")
+        pytest.fail(
+            "check_path_conflict raised a ValueError for non-overlapping paths."
+        )
 
 
 def test_check_path_conflict_same_path(tmp_path):