Merge branch 'main' of https://github.com/Heritage-XioN/metadata-scrubber-tool
This commit is contained in:
commit
a10d6b566b
|
|
@ -10,6 +10,9 @@ dependencies = [
|
|||
"pillow>=12.0.0",
|
||||
"piexif>=1.1.3",
|
||||
"pypdf>=6.5.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"python-pptx>=1.0.2",
|
||||
"python-docx>=1.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
@ -70,6 +73,26 @@ show_missing = true
|
|||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
warn_unused_ignores = false
|
||||
ignore_missing_imports = true
|
||||
|
||||
# pptx library doesn't have type stubs
|
||||
[[tool.mypy.overrides]]
|
||||
module = "pptx.*"
|
||||
ignore_missing_imports = true
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
self.metadata.clear()
|
||||
self.keys_to_delete.clear()
|
||||
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.
|
||||
"""
|
||||
self.processed_metadata.clear()
|
||||
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()
|
||||
|
|
@ -90,6 +90,10 @@ class ImageHandler(MetadataHandler):
|
|||
|
||||
Uses actual format detection to select the appropriate processor.
|
||||
"""
|
||||
self.metadata.clear()
|
||||
self.text_keys_to_delete.clear()
|
||||
self.tags_to_delete.clear()
|
||||
|
||||
self.detected_format = self._detect_format()
|
||||
processor = self.processors.get(self.detected_format)
|
||||
|
||||
|
|
@ -111,6 +115,8 @@ class ImageHandler(MetadataHandler):
|
|||
|
||||
Uses actual format detection to select the appropriate processor.
|
||||
"""
|
||||
self.processed_metadata.clear()
|
||||
self.clean_pnginfo = None
|
||||
# Use cached format if available, otherwise detect
|
||||
if not self.detected_format:
|
||||
self.detected_format = self._detect_format()
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ 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.services.powerpoint_handler import PowerpointHandler
|
||||
from src.services.worddoc_handler import WorddocHandler
|
||||
from src.utils.exceptions import UnsupportedFormatError
|
||||
|
||||
|
||||
|
|
@ -41,19 +44,19 @@ 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, .pdf, .docx, .xlsx, .xlsm, .xltx, .xltm, .pptx, .pptm, .potx, .potm"
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if Path(filepath).is_file():
|
||||
if ext in [".jpg", ".jpeg", ".png"]:
|
||||
return ImageHandler(filepath)
|
||||
elif ext == ".pdf":
|
||||
return PDFHandler(filepath)
|
||||
|
||||
# TODO: implement other handlers
|
||||
# elif ext == ".xlsx":
|
||||
# return ExcelHandler(filepath)
|
||||
# elif ext == ".pptx":
|
||||
# return PowerPointHandler(filepath)
|
||||
elif ext in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
|
||||
return ExcelHandler(filepath)
|
||||
elif ext in [".pptx", ".pptm", ".potx", ".potm"]:
|
||||
return PowerpointHandler(filepath)
|
||||
elif ext == ".docx":
|
||||
return WorddocHandler(filepath)
|
||||
else:
|
||||
raise UnsupportedFormatError(
|
||||
f"No handler defined for {ext} files. we curently only support {supported_extensions} files."
|
||||
|
|
|
|||
|
|
@ -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,40 +26,53 @@ 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.
|
||||
"""
|
||||
self.metadata.clear()
|
||||
self.keys_to_delete.clear()
|
||||
with PdfReader(Path(self.filepath)) as reader:
|
||||
if reader.is_encrypted:
|
||||
raise MetadataReadingError("File is encrypted.")
|
||||
|
|
@ -67,12 +83,19 @@ 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.
|
||||
"""
|
||||
self.processed_metadata.clear()
|
||||
with PdfReader(Path(self.filepath)) as reader:
|
||||
metadata = reader.metadata
|
||||
if metadata is None:
|
||||
|
|
@ -84,15 +107,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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
PowerPoint metadata handler for presentation files.
|
||||
|
||||
This module provides the PowerpointHandler class which implements the MetadataHandler
|
||||
interface for PowerPoint presentation files (.pptx, .pptm, .potx, .potm). Uses python-pptx
|
||||
for reading and writing presentation properties.
|
||||
|
||||
Note:
|
||||
Does not support password-protected/encrypted presentations.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
from src.services.metadata_handler import MetadataHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
MetadataProcessingError,
|
||||
MetadataReadingError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Supported PowerPoint formats
|
||||
FORMAT_MAP = {
|
||||
"pptx": "pptx",
|
||||
"pptm": "pptm",
|
||||
"potx": "potx",
|
||||
"potm": "potm",
|
||||
}
|
||||
|
||||
# Properties to preserve (not deleted during wipe)
|
||||
PRESERVED_PROPERTIES = {"created", "modified", "language", "last_printed", "revision"}
|
||||
|
||||
# Core properties available in PowerPoint presentations
|
||||
CORE_PROPERTIES = [
|
||||
"author",
|
||||
"category",
|
||||
"comments",
|
||||
"content_status",
|
||||
"created",
|
||||
"identifier",
|
||||
"keywords",
|
||||
"language",
|
||||
"last_modified_by",
|
||||
"last_printed",
|
||||
"modified",
|
||||
"revision",
|
||||
"subject",
|
||||
"title",
|
||||
"version",
|
||||
]
|
||||
|
||||
|
||||
class PowerpointHandler(MetadataHandler):
|
||||
"""
|
||||
PowerPoint metadata handler for presentation files.
|
||||
|
||||
Handles extraction and removal of document properties from PowerPoint presentations
|
||||
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 PowerPoint handler.
|
||||
|
||||
Args:
|
||||
filepath: Path to the PowerPoint file to process.
|
||||
"""
|
||||
super().__init__(filepath)
|
||||
self.keys_to_delete: list[str] = []
|
||||
|
||||
def _detect_format(self) -> str:
|
||||
"""
|
||||
Detect PowerPoint format from file extension.
|
||||
|
||||
Returns:
|
||||
Normalized format string ('pptx', 'pptm', 'potx', or 'potm').
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: If file extension is not a supported PowerPoint 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 PowerPoint presentation.
|
||||
|
||||
Reads all document properties from the presentation 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.
|
||||
"""
|
||||
self.metadata.clear()
|
||||
self.keys_to_delete.clear()
|
||||
prs = Presentation(str(Path(self.filepath)))
|
||||
try:
|
||||
if prs.core_properties is None:
|
||||
raise MetadataNotFoundError("No metadata found in the file.")
|
||||
|
||||
for attr in CORE_PROPERTIES:
|
||||
if hasattr(prs.core_properties, attr):
|
||||
self.metadata[attr] = getattr(prs.core_properties, attr)
|
||||
if attr not in PRESERVED_PROPERTIES:
|
||||
self.keys_to_delete.append(attr)
|
||||
|
||||
return self.metadata
|
||||
except Exception as e:
|
||||
raise MetadataReadingError(f"error reading metadata. {e}")
|
||||
finally:
|
||||
del prs
|
||||
|
||||
def wipe(self) -> None:
|
||||
"""
|
||||
Remove metadata properties from the PowerPoint presentation.
|
||||
|
||||
Clears all properties identified during read() except for
|
||||
preserved properties (created, modified, language, etc.).
|
||||
|
||||
Raises:
|
||||
MetadataNotFoundError: If no properties are found.
|
||||
"""
|
||||
self.processed_metadata.clear()
|
||||
prs = Presentation(str(Path(self.filepath)))
|
||||
try:
|
||||
if prs.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
|
||||
except Exception as e:
|
||||
raise MetadataProcessingError(f"error processing metadata. {e}")
|
||||
finally:
|
||||
del prs
|
||||
|
||||
def save(self, output_path: str | None = None) -> None:
|
||||
"""
|
||||
Save the presentation 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)
|
||||
|
||||
prs = Presentation(str(Path(destination_file_path)))
|
||||
try:
|
||||
# Apply wiped properties
|
||||
for attr in self.processed_metadata:
|
||||
if hasattr(prs.core_properties, attr):
|
||||
setattr(prs.core_properties, attr, self.processed_metadata[attr])
|
||||
|
||||
prs.save(str(destination_file_path))
|
||||
except Exception as e:
|
||||
raise MetadataProcessingError(f"error processing metadata. {e}")
|
||||
finally:
|
||||
del prs
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
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,
|
||||
MetadataProcessingError,
|
||||
MetadataReadingError,
|
||||
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.
|
||||
"""
|
||||
self.metadata.clear()
|
||||
self.keys_to_delete.clear()
|
||||
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
|
||||
except Exception as e:
|
||||
raise MetadataReadingError(f"error reading metadata. {e}")
|
||||
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.
|
||||
"""
|
||||
self.processed_metadata.clear()
|
||||
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
|
||||
except Exception as e:
|
||||
raise MetadataProcessingError(f"error processing metadata. {e}")
|
||||
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))
|
||||
except Exception as e:
|
||||
raise MetadataProcessingError(f"error processing metadata. {e}")
|
||||
finally:
|
||||
del doc
|
||||
|
|
@ -33,6 +33,7 @@ def print_metadata_table(metadata: dict[str, Any]):
|
|||
groups = {
|
||||
"📄 Document Info": [
|
||||
"Author",
|
||||
"author",
|
||||
"/Author",
|
||||
"/Creator",
|
||||
],
|
||||
|
|
@ -59,6 +60,8 @@ def print_metadata_table(metadata: dict[str, Any]):
|
|||
"DateTimeOriginal",
|
||||
"DateTimeDigitized",
|
||||
"OffsetTime",
|
||||
"created",
|
||||
"modified",
|
||||
"/CreationDate",
|
||||
"/ModDate",
|
||||
],
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,96 @@ 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)
|
||||
|
||||
|
||||
# PowerPoint fixtures
|
||||
TEST_PPTX_DIR = ASSETS_DIR / "test_pptx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pptx_test_file() -> Path:
|
||||
"""Return path to a PPTX test file with metadata."""
|
||||
return TEST_PPTX_DIR / "Extlst-test.pptx"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_pptx_dir() -> Path:
|
||||
"""Return path to test PPTX directory."""
|
||||
return TEST_PPTX_DIR
|
||||
|
||||
|
||||
# String versions for parametrize (PowerPoint)
|
||||
def get_pptx_test_file() -> str:
|
||||
"""Get PPTX test file path as string."""
|
||||
return str(TEST_PPTX_DIR / "Extlst-test.pptx")
|
||||
|
||||
|
||||
def get_large_pptx_test_file() -> str:
|
||||
"""Get second PPTX test file path as string."""
|
||||
return str(TEST_PPTX_DIR / "sample3.pptx")
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,80 @@ 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
|
||||
|
||||
|
||||
# ============== PowerPoint Tests ==============
|
||||
|
||||
|
||||
def test_read_command_pptx_single_file_success():
|
||||
"""Test the 'read' command with a single PowerPoint file."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
result = runner.invoke(app, ["read", PPTX_TEST_FILE])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Reading" in result.stdout
|
||||
assert Path(PPTX_TEST_FILE).name in result.stdout
|
||||
|
||||
|
||||
def test_read_command_recursive_pptx_success():
|
||||
"""Test the 'read' command with recursive PowerPoint directory processing."""
|
||||
from tests.conftest import get_test_pptx_dir
|
||||
|
||||
PPTX_DIR = get_test_pptx_dir()
|
||||
result = runner.invoke(app, ["read", PPTX_DIR, "-r", "-ext", "pptx"])
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,231 @@ 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",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
|
||||
|
||||
# ============== PowerPoint Tests ==============
|
||||
|
||||
|
||||
def test_scrub_command_pptx_single_file_success(output_dir):
|
||||
"""Test the 'scrub' command with a single PowerPoint file."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
result = runner.invoke(app, ["scrub", PPTX_TEST_FILE, "--output", str(output_dir)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_file = output_dir / f"processed_{Path(PPTX_TEST_FILE).name}"
|
||||
assert output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_recursive_pptx_success(output_dir):
|
||||
"""Test the 'scrub' command with recursive directory processing for PowerPoint."""
|
||||
from tests.conftest import get_test_pptx_dir
|
||||
|
||||
PPTX_DIR = get_test_pptx_dir()
|
||||
result = runner.invoke(
|
||||
app, ["scrub", PPTX_DIR, "-r", "-ext", "pptx", "--output", str(output_dir)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
output_files = list(output_dir.glob("processed_*.pptx"))
|
||||
assert len(output_files) > 0
|
||||
|
||||
|
||||
def test_scrub_command_pptx_dry_run(output_dir):
|
||||
"""Test that --dry-run doesn't create PowerPoint files."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
result = runner.invoke(
|
||||
app, ["scrub", PPTX_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(PPTX_TEST_FILE).name}"
|
||||
assert not output_file.exists()
|
||||
|
||||
|
||||
def test_scrub_command_pptx_with_workers(output_dir):
|
||||
"""Test the --workers option for concurrent PowerPoint processing."""
|
||||
from tests.conftest import get_test_pptx_dir
|
||||
|
||||
PPTX_DIR = get_test_pptx_dir()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"scrub",
|
||||
PPTX_DIR,
|
||||
"-r",
|
||||
"-ext",
|
||||
"pptx",
|
||||
"--output",
|
||||
str(output_dir),
|
||||
"--workers",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
if isinstance(handler, (JpegProcessor, PngProcessor)):
|
||||
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,194 @@ 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"
|
||||
|
||||
|
||||
# ============== PowerPoint Tests ==============
|
||||
|
||||
|
||||
def test_read_pptx_metadata_via_factory():
|
||||
"""Test reading PowerPoint metadata through MetadataFactory."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
assert Path(PPTX_TEST_FILE).exists(), f"Test file not found: {PPTX_TEST_FILE}"
|
||||
|
||||
from src.services.powerpoint_handler import PowerpointHandler
|
||||
|
||||
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
|
||||
assert isinstance(handler, PowerpointHandler)
|
||||
|
||||
metadata = handler.read()
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
|
||||
def test_wipe_pptx_metadata_via_factory():
|
||||
"""Test wiping PowerPoint metadata through MetadataFactory."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
def test_save_processed_pptx_metadata_via_factory():
|
||||
"""Test saving processed PowerPoint metadata through MetadataFactory."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(PPTX_TEST_FILE).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
assert output_file.exists()
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_pptx_format_detection():
|
||||
"""Test format detection for PowerPoint files."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
|
||||
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 ==============
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
Unit tests for PowerpointHandler.
|
||||
|
||||
Tests the PowerpointHandler 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.powerpoint_handler import PowerpointHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_large_pptx_test_file, get_pptx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
LARGE_PPTX_TEST_FILE = get_large_pptx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_read_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test reading metadata from PowerPoint files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
|
||||
handler = PowerpointHandler(pptx_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("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_wipe_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test wiping metadata from PowerPoint files.
|
||||
Verifies that wipe() prepares metadata for removal.
|
||||
"""
|
||||
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should have entries set to None
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_save_processed_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test saving processed PowerPoint to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(pptx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_pptx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies PPTX files.
|
||||
"""
|
||||
handler = PowerpointHandler(PPTX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "pptx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_output_file_has_wiped_metadata(pptx_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 = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(pptx_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 = PowerpointHandler(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 = PowerpointHandler(PPTX_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-PowerPoint files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not a powerpoint file")
|
||||
|
||||
handler = PowerpointHandler(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 = PowerpointHandler(PPTX_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 = PowerpointHandler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_pptx_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted PowerPoint files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted PowerPoint file (invalid structure)
|
||||
corrupted_pptx = tmp_path / "corrupted.pptx"
|
||||
corrupted_pptx.write_bytes(b"not a valid pptx content at all")
|
||||
|
||||
handler = PowerpointHandler(str(corrupted_pptx))
|
||||
# Should raise an exception from python-pptx
|
||||
with pytest.raises(Exception):
|
||||
handler.read()
|
||||
|
||||
|
||||
def test_format_detection_all_pptx_types(tmp_path):
|
||||
"""
|
||||
Test format detection for all supported PowerPoint extensions.
|
||||
"""
|
||||
for ext in ["pptx", "pptm", "potx", "potm"]:
|
||||
fake_file = tmp_path / f"test.{ext}"
|
||||
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
|
||||
|
||||
handler = PowerpointHandler(str(fake_file))
|
||||
detected = handler._detect_format()
|
||||
assert detected == ext
|
||||
|
|
@ -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()
|
||||
188
uv.lock
188
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"
|
||||
|
|
@ -221,6 +230,130 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
|
|
@ -247,9 +380,12 @@ name = "metadata-scrubber"
|
|||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "piexif" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-pptx" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
|
@ -265,11 +401,14 @@ 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" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" },
|
||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||
{ name = "python-pptx", specifier = ">=1.0.2" },
|
||||
{ name = "rich", specifier = ">=14.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.0" },
|
||||
{ name = "typer", specifier = ">=0.21.0" },
|
||||
|
|
@ -331,6 +470,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"
|
||||
|
|
@ -518,6 +669,34 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-pptx"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "pillow" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "xlsxwriter" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
|
|
@ -638,3 +817,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
|||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xlsxwriter"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue