metadata-scrubber-tool:
added handler for excel files, add comprehensive tests
This commit is contained in:
parent
ffa2bda559
commit
3bd506e4eb
|
|
@ -10,6 +10,7 @@ dependencies = [
|
|||
"pillow>=12.0.0",
|
||||
"piexif>=1.1.3",
|
||||
"pypdf>=6.5.0",
|
||||
"openpyxl>=3.1.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
Excel metadata handler for Excel files.
|
||||
|
||||
This module provides the ExcelHandler class which implements the MetadataHandler
|
||||
interface for Excel files (.xlsx, .xlsm, .xltx, .xltm). Uses openpyxl for
|
||||
reading and writing Excel workbook properties.
|
||||
|
||||
Note:
|
||||
Does not support password-protected/encrypted workbooks.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from src.services.metadata_handler import MetadataHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
MetadataReadingError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Supported Excel formats
|
||||
FORMAT_MAP = {
|
||||
"xlsx": "xlsx",
|
||||
"xlsm": "xlsm",
|
||||
"xltx": "xltx",
|
||||
"xltm": "xltm",
|
||||
}
|
||||
|
||||
# Properties to preserve (not deleted during wipe)
|
||||
PRESERVED_PROPERTIES = {"created", "modified", "language"}
|
||||
|
||||
|
||||
class ExcelHandler(MetadataHandler):
|
||||
"""
|
||||
Excel metadata handler for Excel files.
|
||||
|
||||
Handles extraction and removal of document properties from Excel workbooks
|
||||
including author, title, subject, keywords, and other core properties.
|
||||
|
||||
Attributes:
|
||||
keys_to_delete: List of property names to be wiped.
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: str):
|
||||
"""
|
||||
Initialize the Excel handler.
|
||||
|
||||
Args:
|
||||
filepath: Path to the Excel file to process.
|
||||
"""
|
||||
super().__init__(filepath)
|
||||
self.keys_to_delete: list[str] = []
|
||||
|
||||
def _detect_format(self) -> str:
|
||||
"""
|
||||
Detect Excel format from file extension.
|
||||
|
||||
Returns:
|
||||
Normalized format string ('xlsx', 'xlsm', 'xltx', or 'xltm').
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: If file extension is not a supported Excel format.
|
||||
"""
|
||||
ext = Path(self.filepath).suffix.lower()
|
||||
normalised = FORMAT_MAP.get(ext[1:]) # Remove leading dot
|
||||
if normalised is None:
|
||||
raise UnsupportedFormatError(f"Unsupported format: {ext}")
|
||||
|
||||
return normalised
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
"""
|
||||
Extract metadata properties from the Excel workbook.
|
||||
|
||||
Reads all document properties from the workbook and identifies
|
||||
which properties should be wiped (excludes created, modified, language).
|
||||
|
||||
Returns:
|
||||
Dictionary of property names to their values.
|
||||
|
||||
Raises:
|
||||
MetadataReadingError: If the workbook is password-protected.
|
||||
MetadataNotFoundError: If no properties are found.
|
||||
"""
|
||||
wb = load_workbook(Path(self.filepath))
|
||||
try:
|
||||
if wb.security.workbookPassword is not None:
|
||||
raise MetadataReadingError("File is encrypted.")
|
||||
|
||||
if wb.properties is None:
|
||||
raise MetadataNotFoundError("No metadata found in the file.")
|
||||
|
||||
for attr, value in vars(wb.properties).items():
|
||||
self.metadata[attr] = value
|
||||
if attr not in PRESERVED_PROPERTIES:
|
||||
self.keys_to_delete.append(attr)
|
||||
|
||||
return self.metadata
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def wipe(self) -> None:
|
||||
"""
|
||||
Remove metadata properties from the Excel workbook.
|
||||
|
||||
Clears all properties identified during read() except for
|
||||
preserved properties (created, modified, language).
|
||||
|
||||
Raises:
|
||||
MetadataNotFoundError: If no properties are found.
|
||||
"""
|
||||
wb = load_workbook(Path(self.filepath))
|
||||
try:
|
||||
if wb.properties is None:
|
||||
raise MetadataNotFoundError("No metadata found in the file.")
|
||||
|
||||
# Clear each property marked for deletion
|
||||
for attr in self.keys_to_delete:
|
||||
if hasattr(wb.properties, attr):
|
||||
setattr(wb.properties, attr, None)
|
||||
|
||||
self.processed_metadata = wb.properties
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def save(self, output_path: str | None) -> None:
|
||||
"""
|
||||
Save the workbook with cleaned metadata to the output path.
|
||||
|
||||
Creates a copy of the original file and applies the wiped
|
||||
metadata properties to it.
|
||||
|
||||
Args:
|
||||
output_path: Path where the cleaned file should be saved.
|
||||
|
||||
Raises:
|
||||
ValueError: If output_path is None or empty.
|
||||
"""
|
||||
if not output_path:
|
||||
raise ValueError("output_path is required")
|
||||
|
||||
destination_file_path = Path(output_path)
|
||||
shutil.copy2(self.filepath, destination_file_path)
|
||||
|
||||
# Use keep_vba=True for macro-enabled workbooks
|
||||
detected_format = self._detect_format()
|
||||
if detected_format == "xlsm":
|
||||
wb = load_workbook(destination_file_path, keep_vba=True)
|
||||
else:
|
||||
wb = load_workbook(destination_file_path)
|
||||
|
||||
try:
|
||||
# Apply wiped properties
|
||||
for attr, value in vars(self.processed_metadata).items():
|
||||
setattr(wb.properties, attr, value)
|
||||
|
||||
wb.save(destination_file_path)
|
||||
finally:
|
||||
wb.close()
|
||||
|
|
@ -8,6 +8,7 @@ extensibility for supporting new file formats (PDF, Office docs, etc.).
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from src.services.excel_handler import ExcelHandler
|
||||
from src.services.image_handler import ImageHandler
|
||||
from src.services.pdf_handler import PDFHandler
|
||||
from src.utils.exceptions import UnsupportedFormatError
|
||||
|
|
@ -48,10 +49,10 @@ class MetadataFactory:
|
|||
return ImageHandler(filepath)
|
||||
elif ext == ".pdf":
|
||||
return PDFHandler(filepath)
|
||||
elif ext in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
|
||||
return ExcelHandler(filepath)
|
||||
|
||||
# TODO: implement other handlers
|
||||
# elif ext == ".xlsx":
|
||||
# return ExcelHandler(filepath)
|
||||
# elif ext == ".pptx":
|
||||
# return PowerPointHandler(filepath)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
"""
|
||||
PDF metadata handler for PDF files.
|
||||
Does not support encrypted files.
|
||||
|
||||
This module provides the PdfHandler class which implements the MetadataHandler
|
||||
interface for PDF files. It delegates the actual metadata operations to
|
||||
format-specific processors (PdfProcessor).
|
||||
This module provides the PDFHandler class which implements the MetadataHandler
|
||||
interface for PDF files. Uses pypdf library for reading and writing PDF
|
||||
document information dictionary.
|
||||
|
||||
Note:
|
||||
Does not support encrypted/password-protected PDF files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
|
|
@ -23,39 +26,50 @@ from src.utils.exceptions import (
|
|||
class PDFHandler(MetadataHandler):
|
||||
"""
|
||||
PDF metadata handler for PDF files.
|
||||
|
||||
Handles extraction and removal of document information from PDF files
|
||||
including author, creator, title, subject, and other standard PDF metadata.
|
||||
|
||||
Attributes:
|
||||
keys_to_delete: List of metadata keys to be wiped.
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: str):
|
||||
"""
|
||||
Initialize the pdf handler.
|
||||
Initialize the PDF handler.
|
||||
|
||||
Args:
|
||||
filepath: Path to the pdf file to process.
|
||||
filepath: Path to the PDF file to process.
|
||||
"""
|
||||
super().__init__(filepath)
|
||||
self.keys_to_delete: list[str] = []
|
||||
|
||||
def _detect_format(self) -> str:
|
||||
"""
|
||||
Detect actual pdf format using, not file extension.
|
||||
Validate that the file has a PDF extension.
|
||||
|
||||
Returns:
|
||||
Normalized format string ('pdf').
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: If format is not supported or undetectable.
|
||||
UnsupportedFormatError: If file extension is not .pdf.
|
||||
"""
|
||||
normalise = Path(self.filepath).suffix.lower()
|
||||
if normalise != ".pdf":
|
||||
raise UnsupportedFormatError(f"Unsupported format: {normalise}")
|
||||
ext = Path(self.filepath).suffix.lower()
|
||||
if ext != ".pdf":
|
||||
raise UnsupportedFormatError(f"Unsupported format: {ext}")
|
||||
|
||||
return normalise[1:]
|
||||
return ext[1:] # Return 'pdf' without the dot
|
||||
|
||||
def read(self):
|
||||
def read(self) -> dict[str, Any]:
|
||||
"""
|
||||
Extract metadata from the file.
|
||||
Extract metadata from the PDF document information dictionary.
|
||||
|
||||
Uses actual format detection to select the appropriate processor.
|
||||
Returns:
|
||||
Dictionary of metadata keys to their values.
|
||||
|
||||
Raises:
|
||||
MetadataReadingError: If the PDF is encrypted/password-protected.
|
||||
MetadataNotFoundError: If no metadata is found in the PDF.
|
||||
"""
|
||||
with PdfReader(Path(self.filepath)) as reader:
|
||||
if reader.is_encrypted:
|
||||
|
|
@ -67,11 +81,17 @@ class PDFHandler(MetadataHandler):
|
|||
for key, value in reader.metadata.items():
|
||||
self.metadata[key] = value
|
||||
self.keys_to_delete.append(key)
|
||||
|
||||
return self.metadata
|
||||
|
||||
def wipe(self) -> None:
|
||||
"""
|
||||
Remove metadata from PDF file.
|
||||
Remove metadata entries from the PDF document.
|
||||
|
||||
Clears all metadata keys identified during read().
|
||||
|
||||
Raises:
|
||||
MetadataNotFoundError: If no metadata is found in the PDF.
|
||||
"""
|
||||
with PdfReader(Path(self.filepath)) as reader:
|
||||
metadata = reader.metadata
|
||||
|
|
@ -84,15 +104,25 @@ class PDFHandler(MetadataHandler):
|
|||
|
||||
self.processed_metadata = metadata
|
||||
|
||||
def save(self, output_path: str | Path | None = None) -> None:
|
||||
def save(self, output_path: str | None) -> None:
|
||||
"""
|
||||
Save the changes to a copy of the original file.
|
||||
Save the PDF with cleaned metadata to the output path.
|
||||
|
||||
Creates a copy of the original file and rebuilds the PDF
|
||||
with the wiped metadata.
|
||||
|
||||
Args:
|
||||
output_path: Path where the cleaned PDF should be saved.
|
||||
|
||||
Raises:
|
||||
ValueError: If output_path is None or empty.
|
||||
"""
|
||||
destination_file_path = Path(output_path) if output_path else None
|
||||
if not destination_file_path:
|
||||
if not output_path:
|
||||
raise ValueError("output_path is required")
|
||||
|
||||
destination_file_path = Path(output_path)
|
||||
shutil.copy2(self.filepath, destination_file_path)
|
||||
|
||||
with PdfReader(destination_file_path) as reader, PdfWriter() as writer:
|
||||
# Copy all pages
|
||||
for page in reader.pages:
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ def print_metadata_table(metadata: dict[str, Any]):
|
|||
"DateTimeOriginal",
|
||||
"DateTimeDigitized",
|
||||
"OffsetTime",
|
||||
"created",
|
||||
"modified",
|
||||
"/CreationDate",
|
||||
"/ModDate",
|
||||
],
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -13,6 +13,7 @@ TESTS_DIR = Path(__file__).parent
|
|||
ASSETS_DIR = TESTS_DIR / "assets"
|
||||
TEST_IMAGES_DIR = ASSETS_DIR / "test_images"
|
||||
TEST_PDFS_DIR = ASSETS_DIR / "test_pdfs"
|
||||
TEST_XLSX_DIR = ASSETS_DIR / "test_xlsx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -75,3 +76,32 @@ def get_large_pdf_test_file() -> str:
|
|||
def get_test_pdfs_dir() -> str:
|
||||
"""Get test PDFs directory path as string."""
|
||||
return str(TEST_PDFS_DIR)
|
||||
|
||||
|
||||
# Excel fixtures
|
||||
@pytest.fixture
|
||||
def xlsx_test_file() -> Path:
|
||||
"""Return path to an XLSX test file with metadata."""
|
||||
return TEST_XLSX_DIR / "file_example_XLSX_1000.xlsx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_xlsx_dir() -> Path:
|
||||
"""Return path to test XLSX directory."""
|
||||
return TEST_XLSX_DIR
|
||||
|
||||
|
||||
# String versions for parametrize (Excel)
|
||||
def get_xlsx_test_file() -> str:
|
||||
"""Get XLSX test file path as string."""
|
||||
return str(TEST_XLSX_DIR / "file_example_XLSX_1000.xlsx")
|
||||
|
||||
|
||||
def get_large_xlsx_test_file() -> str:
|
||||
"""Get large XLSX test file path as string."""
|
||||
return str(TEST_XLSX_DIR / "file_example_XLSX_5000.xlsx")
|
||||
|
||||
|
||||
def get_test_xlsx_dir() -> str:
|
||||
"""Get test XLSX directory path as string."""
|
||||
return str(TEST_XLSX_DIR)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
"""
|
||||
E2E tests for the 'read' command.
|
||||
|
||||
Tests the full CLI flow for reading metadata from files.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -12,6 +18,8 @@ from tests.conftest import (
|
|||
get_png_test_file,
|
||||
get_test_images_dir,
|
||||
get_test_pdfs_dir,
|
||||
get_test_xlsx_dir,
|
||||
get_xlsx_test_file,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
|
@ -22,16 +30,16 @@ PNG_TEST_FILE = get_png_test_file()
|
|||
TEST_DIR = get_test_images_dir()
|
||||
PDF_TEST_FILE = get_pdf_test_file()
|
||||
PDF_DIR = get_test_pdfs_dir()
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
XLSX_DIR = get_test_xlsx_dir()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
# ============== Image Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_read_command_single_file_success(x):
|
||||
"""
|
||||
Test the 'read' command with a single file (JPG and PNG).
|
||||
"""
|
||||
"""Test the 'read' command with a single image file."""
|
||||
result = runner.invoke(app, ["read", str(x)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
|
@ -41,9 +49,7 @@ def test_read_command_single_file_success(x):
|
|||
|
||||
@pytest.mark.parametrize("ext", ["jpg", "png"])
|
||||
def test_read_command_recursive_directory_success(ext):
|
||||
"""
|
||||
Test the 'read' command with recursive directory processing.
|
||||
"""
|
||||
"""Test the 'read' command with recursive directory processing."""
|
||||
result = runner.invoke(app, ["read", TEST_DIR, "-r", "-ext", ext])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
|
@ -51,46 +57,34 @@ def test_read_command_recursive_directory_success(ext):
|
|||
|
||||
|
||||
def test_read_command_requires_ext_with_recursive():
|
||||
"""
|
||||
Test that --recursive requires --extension flag.
|
||||
"""
|
||||
"""Test that --recursive requires --extension flag."""
|
||||
result = runner.invoke(app, ["read", TEST_DIR, "-r"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
# Should fail with bad parameter error
|
||||
|
||||
|
||||
def test_read_command_requires_recursive_with_ext():
|
||||
"""
|
||||
Test that --extension requires --recursive flag.
|
||||
"""
|
||||
"""Test that --extension requires --recursive flag."""
|
||||
result = runner.invoke(app, ["read", JPG_TEST_FILE, "-ext", "jpg"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
# ============== Error Tests ==============
|
||||
|
||||
|
||||
def test_read_command_file_not_found():
|
||||
"""
|
||||
Test that the app handles missing files gracefully.
|
||||
"""
|
||||
"""Test that the app handles missing files gracefully."""
|
||||
result = runner.invoke(app, ["read", "ghost_file.jpg"])
|
||||
|
||||
# Typer returns exit code 2 (Usage Error) for bad arguments
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid value for 'FILE_PATH'" in result.stderr
|
||||
assert "does not exist" in result.stderr
|
||||
|
||||
|
||||
# ============== PDF E2E Read Tests ==============
|
||||
# ============== PDF Tests ==============
|
||||
|
||||
|
||||
def test_read_command_pdf_single_file_success():
|
||||
"""
|
||||
Test the 'read' command with a single PDF file.
|
||||
"""
|
||||
"""Test the 'read' command with a single PDF file."""
|
||||
result = runner.invoke(app, ["read", PDF_TEST_FILE])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
|
@ -99,10 +93,28 @@ def test_read_command_pdf_single_file_success():
|
|||
|
||||
|
||||
def test_read_command_recursive_pdf_success():
|
||||
"""
|
||||
Test the 'read' command with recursive directory processing for PDF.
|
||||
"""
|
||||
"""Test the 'read' command with recursive PDF directory processing."""
|
||||
result = runner.invoke(app, ["read", PDF_DIR, "-r", "-ext", "pdf"])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
|
||||
|
||||
# ============== Excel Tests ==============
|
||||
|
||||
|
||||
def test_read_command_xlsx_single_file_success():
|
||||
"""Test the 'read' command with a single Excel file."""
|
||||
result = runner.invoke(app, ["read", XLSX_TEST_FILE])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
assert Path(XLSX_TEST_FILE).name in result.stdout
|
||||
|
||||
|
||||
def test_read_command_recursive_xlsx_success():
|
||||
"""Test the 'read' command with recursive Excel directory processing."""
|
||||
result = runner.invoke(app, ["read", XLSX_DIR, "-r", "-ext", "xlsx"])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from tests.conftest import (
|
|||
get_png_test_file,
|
||||
get_test_images_dir,
|
||||
get_test_pdfs_dir,
|
||||
get_test_xlsx_dir,
|
||||
get_xlsx_test_file,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
|
@ -28,6 +30,8 @@ PNG_TEST_FILE = get_png_test_file()
|
|||
EXAMPLES_DIR = get_test_images_dir()
|
||||
PDF_TEST_FILE = get_pdf_test_file()
|
||||
PDF_DIR = get_test_pdfs_dir()
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
XLSX_DIR = get_test_xlsx_dir()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -38,56 +42,44 @@ def output_dir(tmp_path):
|
|||
return output
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
# ============== Image Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_scrub_command_single_file_success(x, output_dir):
|
||||
"""
|
||||
Test the 'scrub' command with a single file.
|
||||
"""
|
||||
"""Test the 'scrub' command with a single image file."""
|
||||
result = runner.invoke(app, ["scrub", x, "--output", str(output_dir)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
# Check output file was created (has processed_ prefix)
|
||||
output_file = output_dir / f"processed_{Path(x).name}"
|
||||
assert output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_recursive_jpg_success(output_dir):
|
||||
"""
|
||||
Test the 'scrub' command with recursive directory processing for JPG.
|
||||
Uses examples folder (smaller) for faster tests.
|
||||
"""
|
||||
"""Test the 'scrub' command with recursive directory processing for JPG."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", EXAMPLES_DIR, "-r", "-ext", "jpg", "--output", str(output_dir)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
# Check at least one output file was created
|
||||
output_files = list(output_dir.glob("processed_*.jpg"))
|
||||
assert len(output_files) > 0
|
||||
|
||||
|
||||
def test_scrub_command_dry_run(output_dir):
|
||||
"""
|
||||
Test that --dry-run doesn't create files.
|
||||
"""
|
||||
"""Test that --dry-run doesn't create files."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir), "--dry-run"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "DRY-RUN" in result.stdout
|
||||
# No files should be created in dry-run mode
|
||||
output_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
|
||||
assert not output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_with_workers(output_dir):
|
||||
"""
|
||||
Test the --workers option for concurrent processing.
|
||||
"""
|
||||
"""Test the --workers option for concurrent processing."""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
|
|
@ -106,13 +98,11 @@ def test_scrub_command_with_workers(output_dir):
|
|||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
# ============== Error Tests ==============
|
||||
|
||||
|
||||
def test_scrub_command_file_not_found():
|
||||
"""
|
||||
Test that the app handles missing files gracefully.
|
||||
"""
|
||||
"""Test that the app handles missing files gracefully."""
|
||||
result = runner.invoke(app, ["scrub", "ghost_file.jpg"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
|
|
@ -120,79 +110,97 @@ def test_scrub_command_file_not_found():
|
|||
|
||||
|
||||
def test_scrub_command_requires_ext_with_recursive():
|
||||
"""
|
||||
Test that --recursive requires --extension flag.
|
||||
"""
|
||||
"""Test that --recursive requires --extension flag."""
|
||||
result = runner.invoke(app, ["scrub", EXAMPLES_DIR, "-r"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_scrub_command_requires_recursive_with_ext():
|
||||
"""
|
||||
Test that --extension requires --recursive flag.
|
||||
"""
|
||||
"""Test that --extension requires --recursive flag."""
|
||||
result = runner.invoke(app, ["scrub", JPG_TEST_FILE, "-ext", "jpg"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ============== PDF E2E Tests ==============
|
||||
# ============== PDF Tests ==============
|
||||
|
||||
|
||||
def test_scrub_command_pdf_single_file_success(output_dir):
|
||||
"""
|
||||
Test the 'scrub' command with a single PDF file.
|
||||
"""
|
||||
"""Test the 'scrub' command with a single PDF file."""
|
||||
result = runner.invoke(app, ["scrub", PDF_TEST_FILE, "--output", str(output_dir)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
# Check output file was created (has processed_ prefix)
|
||||
output_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
|
||||
assert output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_recursive_pdf_success(output_dir):
|
||||
"""
|
||||
Test the 'scrub' command with recursive directory processing for PDF.
|
||||
"""
|
||||
"""Test the 'scrub' command with recursive directory processing for PDF."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", PDF_DIR, "-r", "-ext", "pdf", "--output", str(output_dir)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
# Check at least one output file was created
|
||||
output_files = list(output_dir.glob("processed_*.pdf"))
|
||||
assert len(output_files) > 0
|
||||
|
||||
|
||||
def test_scrub_command_pdf_dry_run(output_dir):
|
||||
"""
|
||||
Test that --dry-run doesn't create PDF files.
|
||||
"""
|
||||
"""Test that --dry-run doesn't create PDF files."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", PDF_TEST_FILE, "--output", str(output_dir), "--dry-run"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "DRY-RUN" in result.stdout
|
||||
# No files should be created in dry-run mode
|
||||
output_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
|
||||
assert not output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_pdf_with_workers(output_dir):
|
||||
"""
|
||||
Test the --workers option for concurrent PDF processing.
|
||||
"""
|
||||
# ============== Excel Tests ==============
|
||||
|
||||
|
||||
def test_scrub_command_xlsx_single_file_success(output_dir):
|
||||
"""Test the 'scrub' command with a single Excel file."""
|
||||
result = runner.invoke(app, ["scrub", XLSX_TEST_FILE, "--output", str(output_dir)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
|
||||
assert output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_recursive_xlsx_success(output_dir):
|
||||
"""Test the 'scrub' command with recursive directory processing for Excel."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", XLSX_DIR, "-r", "-ext", "xlsx", "--output", str(output_dir)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_files = list(output_dir.glob("processed_*.xlsx"))
|
||||
assert len(output_files) > 0
|
||||
|
||||
|
||||
def test_scrub_command_xlsx_dry_run(output_dir):
|
||||
"""Test that --dry-run doesn't create Excel files."""
|
||||
result = runner.invoke(
|
||||
app, ["scrub", XLSX_TEST_FILE, "--output", str(output_dir), "--dry-run"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "DRY-RUN" in result.stdout
|
||||
output_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
|
||||
assert not output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_xlsx_with_workers(output_dir):
|
||||
"""Test the --workers option for concurrent Excel processing."""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"scrub",
|
||||
PDF_DIR,
|
||||
XLSX_DIR,
|
||||
"-r",
|
||||
"-ext",
|
||||
"pdf",
|
||||
"xlsx",
|
||||
"--output",
|
||||
str(output_dir),
|
||||
"--workers",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
"""
|
||||
Integration tests for MetadataFactory.
|
||||
|
||||
Tests the factory pattern integration with all handler types (Image, PDF, Excel).
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -5,168 +11,91 @@ import pytest
|
|||
|
||||
from src.core.jpeg_metadata import JpegProcessor
|
||||
from src.core.png_metadata import PngProcessor
|
||||
from src.services.excel_handler import ExcelHandler
|
||||
from src.services.metadata_factory import MetadataFactory
|
||||
from src.services.pdf_handler import PDFHandler
|
||||
from src.utils.exceptions import MetadataNotFoundError, UnsupportedFormatError
|
||||
from src.utils.exceptions import UnsupportedFormatError
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_jpg_test_file, get_pdf_test_file, get_png_test_file
|
||||
from tests.conftest import (
|
||||
get_jpg_test_file,
|
||||
get_pdf_test_file,
|
||||
get_png_test_file,
|
||||
get_xlsx_test_file,
|
||||
)
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
PNG_TEST_FILE = get_png_test_file()
|
||||
PDF_TEST_FILE = get_pdf_test_file()
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
# ============== Image Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_read_metadata(x):
|
||||
"""
|
||||
test for reading image metadata
|
||||
"""
|
||||
# checks if file exists
|
||||
def test_read_image_metadata(x):
|
||||
"""Test reading image metadata through factory."""
|
||||
assert Path(x).exists(), f"Test file not found: {x}"
|
||||
handler = MetadataFactory.get_handler(str(x))
|
||||
metadata = handler.read()
|
||||
|
||||
# checks if metadata is not empty
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
if isinstance(handler, JpegProcessor | PngProcessor):
|
||||
# checks if tags_to_delete or text_keys_to_delete is not empty
|
||||
assert (
|
||||
handler.tags_to_delete is not None
|
||||
or handler.text_keys_to_delete is not None
|
||||
)
|
||||
|
||||
# checks if metadata is a dictionary
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_wipe_image_metadata(x):
|
||||
"""
|
||||
test for wiping image metadata
|
||||
"""
|
||||
# checks if file exists
|
||||
"""Test wiping image metadata through factory."""
|
||||
assert Path(x).exists(), f"Test file not found: {x}"
|
||||
handler = MetadataFactory.get_handler(str(x))
|
||||
metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# checks if processed_metadata is not equal to metadata
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_save_processed_image_metadata(x):
|
||||
"""
|
||||
test for saving image processed metadata to a copy of the file
|
||||
"""
|
||||
# creates output directory
|
||||
"""Test saving processed image metadata through factory."""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = MetadataFactory.get_handler(str(x))
|
||||
metadata = handler.read()
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# checks if processed_metadata is not equal to metadata
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
# Pass full file path
|
||||
output_file = output_dir / Path(x).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# checks if output file exists and then deletes it
|
||||
assert output_file.exists()
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
|
||||
def test_output_file_has_less_metadata(x):
|
||||
"""
|
||||
Test that the output file has metadata stripped
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process original file
|
||||
handler = MetadataFactory.get_handler(str(x))
|
||||
original_metadata = handler.read()
|
||||
original_count = len(original_metadata)
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(x).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Read output file and verify metadata is reduced or gone
|
||||
try:
|
||||
output_processor = MetadataFactory.get_handler(str(output_file))
|
||||
output_metadata = output_processor.read()
|
||||
# Output should have fewer metadata entries
|
||||
assert len(output_metadata) < original_count
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
# clean up
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_works():
|
||||
"""
|
||||
Test that format detection uses Pillow, not file extension
|
||||
"""
|
||||
def test_image_format_detection():
|
||||
"""Test format detection for images."""
|
||||
handler = MetadataFactory.get_handler(JPG_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "jpeg"
|
||||
assert handler._detect_format() == "jpeg"
|
||||
|
||||
handler_png = MetadataFactory.get_handler(PNG_TEST_FILE)
|
||||
detected_png = handler_png._detect_format()
|
||||
assert detected_png == "png"
|
||||
assert handler_png._detect_format() == "png"
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that unsupported file formats raise an error
|
||||
"""
|
||||
# Create a fake text file
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not an image")
|
||||
|
||||
# MetadataFactory.get_handler() raises UnsupportedFormatError for .txt files
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
MetadataFactory.get_handler(str(fake_file))
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None
|
||||
"""
|
||||
handler = MetadataFactory.get_handler(JPG_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== PDF Integration Tests ==============
|
||||
# ============== PDF Tests ==============
|
||||
|
||||
|
||||
def test_read_pdf_metadata_via_factory():
|
||||
"""
|
||||
Test reading PDF metadata through MetadataFactory.
|
||||
"""
|
||||
"""Test reading PDF metadata through MetadataFactory."""
|
||||
assert Path(PDF_TEST_FILE).exists(), f"Test file not found: {PDF_TEST_FILE}"
|
||||
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
|
||||
|
||||
# Verify correct handler type is returned
|
||||
assert isinstance(handler, PDFHandler)
|
||||
|
||||
metadata = handler.read()
|
||||
|
|
@ -176,10 +105,7 @@ def test_read_pdf_metadata_via_factory():
|
|||
|
||||
|
||||
def test_wipe_pdf_metadata_via_factory():
|
||||
"""
|
||||
Test wiping PDF metadata through MetadataFactory.
|
||||
"""
|
||||
assert Path(PDF_TEST_FILE).exists(), f"Test file not found: {PDF_TEST_FILE}"
|
||||
"""Test wiping PDF metadata through MetadataFactory."""
|
||||
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
|
||||
metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
|
@ -188,18 +114,14 @@ def test_wipe_pdf_metadata_via_factory():
|
|||
|
||||
|
||||
def test_save_processed_pdf_metadata_via_factory():
|
||||
"""
|
||||
Test saving processed PDF metadata through MetadataFactory.
|
||||
"""
|
||||
"""Test saving processed PDF metadata through MetadataFactory."""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
|
||||
metadata = handler.read()
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
output_file = output_dir / Path(PDF_TEST_FILE).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
|
|
@ -207,39 +129,75 @@ def test_save_processed_pdf_metadata_via_factory():
|
|||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_pdf_format_detection_via_factory():
|
||||
"""
|
||||
Test that format detection returns 'pdf' for PDF files.
|
||||
"""
|
||||
def test_pdf_format_detection():
|
||||
"""Test format detection for PDF files."""
|
||||
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "pdf"
|
||||
assert handler._detect_format() == "pdf"
|
||||
|
||||
|
||||
def test_output_pdf_file_has_less_metadata():
|
||||
"""
|
||||
Test that the output PDF file has metadata stripped.
|
||||
"""
|
||||
# ============== Excel Tests ==============
|
||||
|
||||
|
||||
def test_read_excel_metadata_via_factory():
|
||||
"""Test reading Excel metadata through MetadataFactory."""
|
||||
assert Path(XLSX_TEST_FILE).exists(), f"Test file not found: {XLSX_TEST_FILE}"
|
||||
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
|
||||
|
||||
assert isinstance(handler, ExcelHandler)
|
||||
|
||||
metadata = handler.read()
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
assert handler.keys_to_delete is not None
|
||||
|
||||
|
||||
def test_wipe_excel_metadata_via_factory():
|
||||
"""Test wiping Excel metadata through MetadataFactory."""
|
||||
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
|
||||
metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
|
||||
def test_save_processed_excel_metadata_via_factory():
|
||||
"""Test saving processed Excel metadata through MetadataFactory."""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process original file
|
||||
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
|
||||
original_metadata = handler.read()
|
||||
original_count = len(original_metadata)
|
||||
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(PDF_TEST_FILE).name
|
||||
output_file = output_dir / Path(XLSX_TEST_FILE).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Read output file and verify metadata is reduced or gone
|
||||
try:
|
||||
output_handler = MetadataFactory.get_handler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
assert len(output_metadata) < original_count
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
|
||||
assert output_file.exists()
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_excel_format_detection():
|
||||
"""Test format detection for Excel files."""
|
||||
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
|
||||
assert handler._detect_format() == "xlsx"
|
||||
|
||||
|
||||
# ============== Error Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""Test that unsupported file formats raise an error."""
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not an image")
|
||||
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
MetadataFactory.get_handler(str(fake_file))
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""Test that save() raises ValueError when output_path is empty."""
|
||||
handler = MetadataFactory.get_handler(JPG_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save("")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
"""
|
||||
Unit tests for ExcelHandler.
|
||||
|
||||
Tests the ExcelHandler class in isolation, focusing on individual methods
|
||||
and edge cases including encrypted workbooks, missing metadata, and corrupted files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
|
||||
from src.services.excel_handler import ExcelHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_large_xlsx_test_file, get_xlsx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
LARGE_XLSX_TEST_FILE = get_large_xlsx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_read_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test reading metadata from Excel files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
metadata = handler.read()
|
||||
|
||||
# Check metadata was extracted
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
# Check keys_to_delete is populated
|
||||
assert handler.keys_to_delete is not None
|
||||
assert len(handler.keys_to_delete) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_wipe_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test wiping metadata from Excel files.
|
||||
Verifies that wipe() removes metadata entries.
|
||||
"""
|
||||
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should differ from original
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_save_processed_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test saving processed Excel to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(xlsx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_xlsx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies XLSX files.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "xlsx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_output_file_has_less_metadata(xlsx_file):
|
||||
"""
|
||||
Test that the output file has metadata stripped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process original file
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
original_metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(xlsx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Read output file and verify metadata is reduced or gone
|
||||
try:
|
||||
output_handler = ExcelHandler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
# Output should have fewer metadata entries (some are None now)
|
||||
non_none_original = sum(1 for v in original_metadata.values() if v is not None)
|
||||
non_none_output = sum(1 for v in output_metadata.values() if v is not None)
|
||||
assert non_none_output <= non_none_original
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_preserved_properties_not_deleted():
|
||||
"""
|
||||
Test that created, modified, and language properties are preserved.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
|
||||
# These should NOT be in keys_to_delete
|
||||
assert "created" not in handler.keys_to_delete
|
||||
assert "modified" not in handler.keys_to_delete
|
||||
assert "language" not in handler.keys_to_delete
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that non-Excel files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not an excel file")
|
||||
|
||||
handler = ExcelHandler(str(fake_file))
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
handler._detect_format()
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None or empty.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save("")
|
||||
|
||||
|
||||
def test_save_with_none_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_excel_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted Excel files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted Excel file (invalid structure)
|
||||
corrupted_xlsx = tmp_path / "corrupted.xlsx"
|
||||
corrupted_xlsx.write_bytes(b"not a valid xlsx content at all")
|
||||
|
||||
handler = ExcelHandler(str(corrupted_xlsx))
|
||||
# Should raise InvalidFileException or similar from openpyxl
|
||||
with pytest.raises((InvalidFileException, Exception)):
|
||||
handler.read()
|
||||
|
||||
|
||||
def test_format_detection_all_excel_types(tmp_path):
|
||||
"""
|
||||
Test format detection for all supported Excel extensions.
|
||||
"""
|
||||
for ext in ["xlsx", "xlsm", "xltx", "xltm"]:
|
||||
fake_file = tmp_path / f"test.{ext}"
|
||||
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
|
||||
|
||||
handler = ExcelHandler(str(fake_file))
|
||||
detected = handler._detect_format()
|
||||
assert detected == ext
|
||||
23
uv.lock
23
uv.lock
|
|
@ -127,6 +127,15 @@ toml = [
|
|||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "et-xmlfile"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
|
|
@ -247,6 +256,7 @@ name = "metadata-scrubber"
|
|||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "piexif" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pypdf" },
|
||||
|
|
@ -265,6 +275,7 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" },
|
||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||
{ name = "piexif", specifier = ">=1.1.3" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "pypdf", specifier = ">=6.5.0" },
|
||||
|
|
@ -331,6 +342,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "et-xmlfile" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue