metadata-scrubber-tool:
added handler for docx files, add comprehensive tests
This commit is contained in:
parent
bbbeff0e32
commit
0bbfe84fd1
|
|
@ -85,3 +85,14 @@ ignore_errors = true
|
|||
[[tool.mypy.overrides]]
|
||||
module = "src.services.powerpoint_handler"
|
||||
ignore_errors = true
|
||||
|
||||
# docx library doesn't have type stubs
|
||||
[[tool.mypy.overrides]]
|
||||
module = "docx.*"
|
||||
ignore_missing_imports = true
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "src.services.worddoc_handler"
|
||||
ignore_errors = true
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from src.services.excel_handler import ExcelHandler
|
|||
from src.services.image_handler import ImageHandler
|
||||
from src.services.pdf_handler import PDFHandler
|
||||
from src.services.powerpoint_handler import PowerpointHandler
|
||||
from src.services.worddoc_handler import WorddocHandler
|
||||
from src.utils.exceptions import UnsupportedFormatError
|
||||
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ class MetadataFactory:
|
|||
UnsupportedFormatError: If no handler is defined for the file type.
|
||||
ValueError: If the path is not a valid file.
|
||||
"""
|
||||
supported_extensions = ".jpg, .jpeg, .png"
|
||||
supported_extensions = ".jpg, .jpeg, .png .docx .xlsx .pptx"
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if Path(filepath).is_file():
|
||||
if ext in [".jpg", ".jpeg", ".png"]:
|
||||
|
|
@ -54,8 +55,8 @@ class MetadataFactory:
|
|||
return ExcelHandler(filepath)
|
||||
elif ext in [".pptx", ".pptm", ".potx", ".potm"]:
|
||||
return PowerpointHandler(filepath)
|
||||
|
||||
# TODO: implement other handlers
|
||||
elif ext == ".docx":
|
||||
return WorddocHandler(filepath)
|
||||
else:
|
||||
raise UnsupportedFormatError(
|
||||
f"No handler defined for {ext} files. we curently only support {supported_extensions} files."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
Word document metadata handler for .docx files.
|
||||
|
||||
This module provides the WorddocHandler class which implements the MetadataHandler
|
||||
interface for Word document files (.docx). Uses python-docx for reading and writing
|
||||
document properties.
|
||||
|
||||
Note:
|
||||
Does not support password-protected/encrypted documents.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from docx import Document # type: ignore[import-untyped]
|
||||
|
||||
from src.services.metadata_handler import MetadataHandler
|
||||
from src.utils.exceptions import MetadataNotFoundError, UnsupportedFormatError
|
||||
|
||||
# Supported Word document formats
|
||||
FORMAT_MAP = {
|
||||
"docx": "docx",
|
||||
}
|
||||
|
||||
# Properties to preserve (not deleted during wipe)
|
||||
PRESERVED_PROPERTIES = {"created", "modified", "language", "last_printed", "revision"}
|
||||
|
||||
# Core properties available in Word documents
|
||||
CORE_PROPERTIES = [
|
||||
"author",
|
||||
"category",
|
||||
"comments",
|
||||
"content_status",
|
||||
"created",
|
||||
"identifier",
|
||||
"keywords",
|
||||
"language",
|
||||
"last_modified_by",
|
||||
"last_printed",
|
||||
"modified",
|
||||
"revision",
|
||||
"subject",
|
||||
"title",
|
||||
"version",
|
||||
]
|
||||
|
||||
|
||||
class WorddocHandler(MetadataHandler):
|
||||
"""
|
||||
Word document metadata handler for .docx files.
|
||||
|
||||
Handles extraction and removal of document properties from Word documents
|
||||
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 Word document handler.
|
||||
|
||||
Args:
|
||||
filepath: Path to the Word document file to process.
|
||||
"""
|
||||
super().__init__(filepath)
|
||||
self.keys_to_delete: list[str] = []
|
||||
|
||||
def _detect_format(self) -> str:
|
||||
"""
|
||||
Detect Word document format from file extension.
|
||||
|
||||
Returns:
|
||||
Normalized format string ('docx').
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: If file extension is not a supported Word 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 Word document.
|
||||
|
||||
Reads all document properties and identifies which properties
|
||||
should be wiped (excludes created, modified, language, etc.).
|
||||
|
||||
Returns:
|
||||
Dictionary of property names to their values.
|
||||
|
||||
Raises:
|
||||
MetadataNotFoundError: If no properties are found.
|
||||
"""
|
||||
doc = Document(str(Path(self.filepath)))
|
||||
try:
|
||||
if doc.core_properties is None:
|
||||
raise MetadataNotFoundError("No metadata found in the file.")
|
||||
|
||||
for attr in CORE_PROPERTIES:
|
||||
if hasattr(doc.core_properties, attr):
|
||||
self.metadata[attr] = getattr(doc.core_properties, attr)
|
||||
if attr not in PRESERVED_PROPERTIES:
|
||||
self.keys_to_delete.append(attr)
|
||||
|
||||
return self.metadata
|
||||
finally:
|
||||
del doc
|
||||
|
||||
def wipe(self) -> None:
|
||||
"""
|
||||
Remove metadata properties from the Word document.
|
||||
|
||||
Clears all properties identified during read() except for
|
||||
preserved properties (created, modified, language, etc.).
|
||||
|
||||
Raises:
|
||||
MetadataNotFoundError: If no properties are found.
|
||||
"""
|
||||
doc = Document(str(Path(self.filepath)))
|
||||
try:
|
||||
if doc.core_properties is None:
|
||||
raise MetadataNotFoundError("No metadata found in the file.")
|
||||
|
||||
# Clear each property marked for deletion
|
||||
for attr in self.keys_to_delete:
|
||||
self.processed_metadata[attr] = None
|
||||
|
||||
finally:
|
||||
del doc
|
||||
|
||||
def save(self, output_path: str | None = None) -> None:
|
||||
"""
|
||||
Save the document 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)
|
||||
|
||||
doc = Document(str(Path(destination_file_path)))
|
||||
try:
|
||||
# Apply wiped properties
|
||||
for attr in self.processed_metadata:
|
||||
if hasattr(doc.core_properties, attr):
|
||||
setattr(doc.core_properties, attr, self.processed_metadata[attr])
|
||||
|
||||
doc.save(str(destination_file_path))
|
||||
finally:
|
||||
del doc
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -137,3 +137,35 @@ def get_large_pptx_test_file() -> str:
|
|||
def get_test_pptx_dir() -> str:
|
||||
"""Get test PPTX directory path as string."""
|
||||
return str(TEST_PPTX_DIR)
|
||||
|
||||
|
||||
# Word document fixtures
|
||||
TEST_DOCX_DIR = ASSETS_DIR / "test_docx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docx_test_file() -> Path:
|
||||
"""Return path to a DOCX test file with metadata."""
|
||||
return TEST_DOCX_DIR / "file-sample_500kB.docx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_docx_dir() -> Path:
|
||||
"""Return path to test DOCX directory."""
|
||||
return TEST_DOCX_DIR
|
||||
|
||||
|
||||
# String versions for parametrize (Word document)
|
||||
def get_docx_test_file() -> str:
|
||||
"""Get DOCX test file path as string."""
|
||||
return str(TEST_DOCX_DIR / "file-sample_500kB.docx")
|
||||
|
||||
|
||||
def get_large_docx_test_file() -> str:
|
||||
"""Get large DOCX test file path as string."""
|
||||
return str(TEST_DOCX_DIR / "file-sample_1MB.docx")
|
||||
|
||||
|
||||
def get_test_docx_dir() -> str:
|
||||
"""Get test DOCX directory path as string."""
|
||||
return str(TEST_DOCX_DIR)
|
||||
|
|
|
|||
|
|
@ -144,3 +144,29 @@ def test_read_command_recursive_pptx_success():
|
|||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
|
||||
|
||||
# ============== Word Document Tests ==============
|
||||
|
||||
|
||||
def test_read_command_docx_single_file_success():
|
||||
"""Test the 'read' command with a single Word document file."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
result = runner.invoke(app, ["read", DOCX_TEST_FILE])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
assert Path(DOCX_TEST_FILE).name in result.stdout
|
||||
|
||||
|
||||
def test_read_command_recursive_docx_success():
|
||||
"""Test the 'read' command with recursive Word document directory processing."""
|
||||
from tests.conftest import get_test_docx_dir
|
||||
|
||||
DOCX_DIR = get_test_docx_dir()
|
||||
result = runner.invoke(app, ["read", DOCX_DIR, "-r", "-ext", "docx"])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
|
|
|
|||
|
|
@ -276,3 +276,70 @@ def test_scrub_command_pptx_with_workers(output_dir):
|
|||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
||||
|
||||
# ============== Word Document Tests ==============
|
||||
|
||||
|
||||
def test_scrub_command_docx_single_file_success(output_dir):
|
||||
"""Test the 'scrub' command with a single Word document file."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
result = runner.invoke(app, ["scrub", DOCX_TEST_FILE, "--output", str(output_dir)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_file = output_dir / f"processed_{Path(DOCX_TEST_FILE).name}"
|
||||
assert output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_recursive_docx_success(output_dir):
|
||||
"""Test the 'scrub' command with recursive directory processing for Word documents."""
|
||||
from tests.conftest import get_test_docx_dir
|
||||
|
||||
DOCX_DIR = get_test_docx_dir()
|
||||
result = runner.invoke(
|
||||
app, ["scrub", DOCX_DIR, "-r", "-ext", "docx", "--output", str(output_dir)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_files = list(output_dir.glob("processed_*.docx"))
|
||||
assert len(output_files) > 0
|
||||
|
||||
|
||||
def test_scrub_command_docx_dry_run(output_dir):
|
||||
"""Test that --dry-run doesn't create Word document files."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
result = runner.invoke(
|
||||
app, ["scrub", DOCX_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(DOCX_TEST_FILE).name}"
|
||||
assert not output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_docx_with_workers(output_dir):
|
||||
"""Test the --workers option for concurrent Word document processing."""
|
||||
from tests.conftest import get_test_docx_dir
|
||||
|
||||
DOCX_DIR = get_test_docx_dir()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"scrub",
|
||||
DOCX_DIR,
|
||||
"-r",
|
||||
"-ext",
|
||||
"docx",
|
||||
"--output",
|
||||
str(output_dir),
|
||||
"--workers",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
|
|
|||
|
|
@ -242,6 +242,65 @@ def test_pptx_format_detection():
|
|||
assert handler._detect_format() == "pptx"
|
||||
|
||||
|
||||
# ============== Word Document Tests ==============
|
||||
|
||||
|
||||
def test_read_docx_metadata_via_factory():
|
||||
"""Test reading Word document metadata through MetadataFactory."""
|
||||
from src.services.worddoc_handler import WorddocHandler
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
assert Path(DOCX_TEST_FILE).exists(), f"Test file not found: {DOCX_TEST_FILE}"
|
||||
|
||||
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
|
||||
assert isinstance(handler, WorddocHandler)
|
||||
|
||||
metadata = handler.read()
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
|
||||
def test_wipe_docx_metadata_via_factory():
|
||||
"""Test wiping Word document metadata through MetadataFactory."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
def test_save_processed_docx_metadata_via_factory():
|
||||
"""Test saving processed Word document metadata through MetadataFactory."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(DOCX_TEST_FILE).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
assert output_file.exists()
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_docx_format_detection():
|
||||
"""Test format detection for Word document files."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
|
||||
assert handler._detect_format() == "docx"
|
||||
|
||||
|
||||
# ============== Error Tests ==============
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Unit tests for WorddocHandler.
|
||||
|
||||
Tests the WorddocHandler class in isolation, focusing on individual methods
|
||||
and edge cases including missing metadata and corrupted files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.worddoc_handler import WorddocHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_docx_test_file, get_large_docx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
LARGE_DOCX_TEST_FILE = get_large_docx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_read_docx_metadata(docx_file):
|
||||
"""
|
||||
Test reading metadata from Word document files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
|
||||
handler = WorddocHandler(docx_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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_wipe_docx_metadata(docx_file):
|
||||
"""
|
||||
Test wiping metadata from Word document files.
|
||||
Verifies that wipe() prepares metadata for removal.
|
||||
"""
|
||||
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should have entries set to None
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_save_processed_docx_metadata(docx_file):
|
||||
"""
|
||||
Test saving processed Word document to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(docx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_docx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies DOCX files.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "docx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_output_file_has_wiped_metadata(docx_file):
|
||||
"""
|
||||
Test that the output file has metadata wiped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process original file
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(docx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists and can be read
|
||||
assert output_file.exists()
|
||||
|
||||
# Read output file and verify it's valid
|
||||
try:
|
||||
output_handler = WorddocHandler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
# Just verify we can read it - the wipe worked if we're here
|
||||
assert isinstance(output_metadata, dict)
|
||||
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, language, last_printed, revision are preserved.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_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
|
||||
assert "last_printed" not in handler.keys_to_delete
|
||||
assert "revision" not in handler.keys_to_delete
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that non-Word document files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not a word document")
|
||||
|
||||
handler = WorddocHandler(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 empty.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_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 = WorddocHandler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_docx_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted Word document files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted DOCX file (invalid structure)
|
||||
corrupted_docx = tmp_path / "corrupted.docx"
|
||||
corrupted_docx.write_bytes(b"not a valid docx content at all")
|
||||
|
||||
handler = WorddocHandler(str(corrupted_docx))
|
||||
# Should raise an exception from python-docx
|
||||
with pytest.raises(Exception):
|
||||
handler.read()
|
||||
Loading…
Reference in New Issue