Merge pull request #6 from Heritage-XioN/add-comprehensive-reporting-interface

Add comprehensive reporting interface
This commit is contained in:
HERITAGE-XION 2026-01-10 20:35:27 +01:00 committed by GitHub
commit 250a648855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1382 additions and 182 deletions

5
.gitignore vendored
View File

@ -46,4 +46,7 @@ Thumbs.db
# ============== Project Specific ==============
# Output directory for processed images
tests/assets/output/
tests/assets/output/
.yapfignore/

47
.style.yapf Normal file
View File

@ -0,0 +1,47 @@
# ⒸAngelaMos | 2025 | CarterPerez-dev
[style]
based_on_style = pep8
column_limit = 89
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

4
.yapfignore Normal file
View File

@ -0,0 +1,4 @@
.venv/
venv/
env/
typings/

145
README.md
View File

@ -11,6 +11,7 @@ A privacy-focused CLI tool that removes sensitive metadata from files. Supports
- **Multi-format support** - Images (JPEG, PNG), PDFs, and Office docs (Word, Excel, PowerPoint)
- **Concurrent processing** - Process 1000+ files efficiently with ThreadPoolExecutor
- **Dry-run mode** - Preview what would be scrubbed without making changes
- **Verification reports** - Before/after comparison to confirm removal
- **Smart format detection** - Uses library-level format detection, not just file extensions
- **Beautiful CLI** - Rich progress bars and formatted output
- **Privacy-first** - Removes GPS coordinates, author info, timestamps, camera data
@ -50,39 +51,133 @@ mst scrub photo.jpg --output ./cleaned
# Batch process entire folder
mst scrub ./documents -r -ext docx --output ./cleaned
# Verify removal
mst verify original.jpg ./cleaned/processed_original.jpg
```
## 📖 Commands
### `mst read` - View Metadata
Extract and display all embedded metadata from a file.
```bash
mst read photo.jpg # Single file
mst read report.pdf # PDF file
mst read ./docs -r -ext docx # All Word docs recursively
```
**Example output:**
```
╭────────────────── Metadata Report ──────────────────╮
│ ╭────────────────────┬────────────────────────────╮ │
│ │ Property │ Value │ │
│ ├────────────────────┼────────────────────────────┤ │
│ │ 📷 Camera │ │ │
│ │ Make │ Canon │ │
│ │ Model │ Canon EOS 80D │ │
│ │ Software │ Adobe Photoshop │ │
│ ├────────────────────┼────────────────────────────┤ │
│ │ 📍 GPS │ │ │
│ │ GPSLatitude │ 40.7128 │ │
│ │ GPSLongitude │ -74.0060 │ │
│ ├────────────────────┼────────────────────────────┤ │
│ │ 📅 Dates │ │ │
│ │ DateTimeOriginal │ 2024:01:15 14:30:00 │ │
│ │ created │ 2024-01-15 14:30:00 │ │
│ ╰────────────────────┴────────────────────────────╯ │
╰─────────────────────────────────────────────────────╯
```
---
### `mst scrub` - Remove Metadata
Remove sensitive metadata from files and save cleaned copies.
```bash
mst scrub photo.jpg --output ./out # Single file
mst scrub ./photos -r -ext jpg -o ./out # All JPEGs in directory
mst scrub ./docs -r -ext pdf --dry-run # Preview PDF scrubbing
mst scrub ./docs -r -ext pdf --dry-run # Preview without changes
mst scrub ./files -r -ext xlsx -w 8 # 8 concurrent workers
```
### CLI Options
**Example output:**
```
Processing 42 files with 4 workers...
⠸ Scrubbing metadata... ━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 42/42 0:00:12
╭───────────────────── Summary ─────────────────────╮
│ ✅ Processed: 42 │
│ ❌ Failed: 0 │
│ 📁 Output: C:\Users\...\cleaned │
╰───────────────────────────────────────────────────╯
```
**Dry-run example:**
```bash
mst scrub ./photos -r -ext jpg --dry-run
```
```
🔍 DRY-RUN MODE - No files will be modified
Would process 15 files:
• photo1.jpg → processed_photo1.jpg
• photo2.jpg → processed_photo2.jpg
• vacation/beach.jpg → processed_beach.jpg
...
```
---
### `mst verify` - Verify Metadata Removal
Compare original and processed files to confirm sensitive data was removed.
```bash
mst verify original.jpg ./out/processed_original.jpg
```
**Example output:**
```
Comparing: test_canon.jpg → processed_test_canon.jpg
Verification Report
┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
┃ Property ┃ Before ┃ After ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
│ Make │ Canon │ ✅ Removed │
│ Model │ Canon EOS 80D │ ✅ Removed │
│ Software │ Adobe Photoshop │ ✅ Removed │
│ GPSLatitude │ 40.7128 │ ✅ Removed │
│ GPSLongitude │ -74.0060 │ ✅ Removed │
│ Artist │ John Smith │ ✅ Removed │
│ Copyright │ © 2024 John Smith │ ✅ Removed │
│ DateTimeOriginal │ 2024:01:15 14:30:00 │ ⚪ Preserved │
└─────────────────────────┴──────────────────────────┴────────────────┘
✅ Status: CLEAN - All sensitive metadata removed
Removed: 38 | Preserved: 2
```
---
## ⚙️ CLI Options
| Option | Description |
|--------|-------------|
| `-r`, `--recursive` | Process directories recursively |
| `-ext`, `--extension` | Filter by file extension |
| `-ext`, `--extension` | Filter by file extension (jpg, png, pdf, docx, xlsx, pptx) |
| `-o`, `--output` | Output directory for cleaned files |
| `-d`, `--dry-run` | Preview without making changes |
| `-w`, `--workers` | Number of concurrent workers |
| `-w`, `--workers` | Number of concurrent workers (default: 4, max: 16) |
| `-V`, `--verbose` | Show detailed debug logs |
| `-v`, `--version` | Show version |
---
## 🛠️ Development
### Setup
@ -108,32 +203,50 @@ mypy src
```
src/
├── main.py # CLI entry point (Typer app)
├── main.py # CLI entry point (Typer app)
├── commands/
│ ├── read.py # Read metadata command
│ └── scrub.py # Scrub metadata command
│ ├── read.py # Read metadata command
│ ├── scrub.py # Scrub metadata command
│ └── verify.py # Verify removal command
├── services/
│ ├── metadata_factory.py # Factory for creating handlers
│ ├── metadata_handler.py # Abstract base class
│ ├── image_handler.py # JPEG/PNG handler
│ ├── pdf_handler.py # PDF handler
│ ├── excel_handler.py # Excel handler
│ ├── metadata_factory.py # Factory for creating handlers
│ ├── metadata_handler.py # Abstract base class
│ ├── image_handler.py # JPEG/PNG handler
│ ├── pdf_handler.py # PDF handler
│ ├── excel_handler.py # Excel handler
│ ├── powerpoint_handler.py # PowerPoint handler
│ ├── worddoc_handler.py # Word document handler
│ └── batch_processor.py # Concurrent batch processing
│ ├── worddoc_handler.py # Word document handler
│ ├── report_generator.py # Verification reports
│ └── batch_processor.py # Concurrent batch processing
└── core/
├── jpeg_metadata.py # JPEG EXIF processor
└── png_metadata.py # PNG metadata processor
├── jpeg_metadata.py # JPEG EXIF processor
└── png_metadata.py # PNG metadata processor
docs/
├── metadata-risks.md # Privacy risks documentation
└── best-practices.md # Secure file sharing guide
```
---
## 📚 Documentation
- **[Metadata Risks](docs/metadata-risks.md)** - Why metadata matters for privacy
- **[Best Practices](docs/best-practices.md)** - Guidelines for secure file sharing
---
## ⚠️ Security Considerations
- **Original files are never modified** - processed copies are created
- **Use `--dry-run`** to preview changes before committing
- **Use `mst verify`** to confirm sensitive data was removed
- **GPS coordinates** are completely stripped for privacy
- **Author information** is removed from all supported formats
- **Always backup files** before scrubbing in production
---
## 📄 License
MIT License - See [LICENSE](LICENSE) for details.

139
docs/best-practices.md Normal file
View File

@ -0,0 +1,139 @@
# Best Practices for Secure File Sharing
Guidelines for protecting your privacy when sharing files.
## Before Sharing Files
### 1. Always Check Metadata First
```bash
mst read yourfile.pdf
```
Review the output for any sensitive information.
### 2. Scrub Before Sharing
```bash
mst scrub yourfile.pdf --output ./clean
```
Always use the cleaned version for sharing.
### 3. Verify Removal
```bash
mst verify yourfile.pdf ./clean/processed_yourfile.pdf
```
Confirm sensitive data was actually removed.
---
## File Type Specific Guidance
### 📸 Images
**High Risk:** Photos from smartphones contain GPS coordinates by default.
```bash
# Batch process all photos before uploading
mst scrub ./photos -r -ext jpg --output ./clean
```
**Tip:** Disable location services for your camera app to prevent GPS embedding.
### 📄 PDF Documents
**High Risk:** PDFs often contain author name, creator software, and sometimes revision history.
```bash
mst scrub report.pdf --output ./clean
```
**Note:** This tool removes document properties. For redacting visible content, use a dedicated PDF editor.
### 📊 Office Documents (Word, Excel, PowerPoint)
**High Risk:** Office files store author, company, and modification history.
```bash
# Process all Word documents
mst scrub ./documents -r -ext docx --output ./clean
```
---
## Workflow Integration
### For Regular File Sharing
1. Create a "clean" folder for processed files
2. Before sharing, always scrub to that folder
3. Share only from the clean folder
### For Automated Pipelines
```bash
# In CI/CD or scripts
mst scrub ./output -r -ext pdf --output ./publish
```
### For Verification
```bash
# Verify before publishing
mst verify original.pdf ./publish/processed_original.pdf
```
---
## What's Preserved (And Why)
| Property | Reason |
|----------|--------|
| Created Date | File organization, audit trails |
| Modified Date | Version tracking |
| Language | Accessibility, screen readers |
These properties are generally not privacy-sensitive and are often needed for file management.
---
## Common Mistakes to Avoid
1. **Sharing the original instead of the processed file**
- Always verify you're sharing from the output folder
2. **Not checking the output**
- Use `mst verify` to confirm removal
3. **Forgetting about PDFs**
- PDF author metadata is often overlooked
4. **Batch processing without verification**
- Spot-check a few files after batch processing
5. **Assuming social media strips metadata**
- Many platforms compress but don't fully remove metadata
---
## Quick Reference
```bash
# Read metadata
mst read file.jpg
# Scrub single file
mst scrub file.jpg --output ./clean
# Scrub directory
mst scrub ./folder -r -ext jpg --output ./clean
# Preview without changes
mst scrub ./folder -r -ext jpg --dry-run
# Verify removal
mst verify original.jpg ./clean/processed_original.jpg
```

103
docs/metadata-risks.md Normal file
View File

@ -0,0 +1,103 @@
# Metadata Privacy Risks
Understanding why metadata removal matters for privacy and security.
## What is Metadata?
Metadata is "data about data" - hidden information embedded in files that describes when, where, how, and by whom a file was created or modified.
## Common Metadata Types
### 📸 Images (JPEG, PNG)
| Metadata Type | Privacy Risk |
|---------------|--------------|
| **GPS Coordinates** | Reveals exact location where photo was taken |
| **Camera Model** | Can identify specific device, linked to owner |
| **Date/Time** | Reveals when photo was taken |
| **Software** | Shows editing tools used |
| **Thumbnail** | May contain original uncropped image |
### 📄 Documents (Word, PDF)
| Metadata Type | Privacy Risk |
|---------------|--------------|
| **Author** | Reveals creator's name/username |
| **Company** | Exposes employer information |
| **Last Modified By** | Shows who edited the document |
| **Comments** | May contain internal notes |
| **Revision History** | Can expose previous versions |
### 📊 Spreadsheets (Excel)
| Metadata Type | Privacy Risk |
|---------------|--------------|
| **Author** | Reveals creator identity |
| **Company** | Exposes organization |
| **Keywords** | May reveal document purpose |
| **Custom Properties** | Internal tracking data |
### 📽️ Presentations (PowerPoint)
| Metadata Type | Privacy Risk |
|---------------|--------------|
| **Author** | Creator identity |
| **Title/Subject** | May reveal confidential topics |
| **Comments** | Presenter notes, internal feedback |
---
## Real-World Examples
### Case 1: Location Leak via Photo
A journalist shared a photo online. The embedded GPS coordinates revealed their safe house location, putting sources at risk.
### Case 2: Internal Document Exposed
A company released a redacted PDF. The author metadata still showed an employee's full name and the legal department.
### Case 3: Anonymous Submission Failed
A whistleblower submitted documents "anonymously" but the Word metadata contained their username and computer name.
---
## High-Risk Scenarios
1. **Sharing photos on social media** - GPS can reveal home address
2. **Sending documents to clients** - Author reveals internal usernames
3. **Publishing reports externally** - Revision history may expose drafts
4. **Submitting anonymous tips** - Metadata can identify the source
5. **Legal discovery** - Metadata is often requested in litigation
---
## What This Tool Removes
| Category | Removed Properties |
|----------|-------------------|
| **Identity** | Author, Last Modified By, Creator |
| **Location** | GPS Latitude, Longitude, Altitude |
| **Device** | Camera Model, Software, Device ID |
| **Tracking** | Comments, Keywords, Custom Properties |
### Preserved (Intentionally)
- **Created Date** - Often needed for file organization
- **Modified Date** - Required for version tracking
- **Language** - Accessibility purposes
---
## Best Practice
**Always scrub metadata before sharing files externally.**
```bash
# Check what metadata exists
mst read document.pdf
# Remove metadata
mst scrub document.pdf --output ./clean
# Verify removal
mst verify document.pdf ./clean/processed_document.pdf
```

View File

@ -1,6 +1,6 @@
[project]
name = "metadata-scrubber"
version = "0.2.0"
version = "0.3.0"
description = "A privacy-focused CLI tool that removes sensitive metadata from image files"
readme = "README.md"
requires-python = ">=3.10"
@ -16,12 +16,7 @@ dependencies = [
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.0",
"pytest-cov>=6.0.0",
"mypy>=1.19.0",
"ruff>=0.14.0",
]
dev = []
[project.scripts]
mst = "src.main:app"
@ -96,3 +91,12 @@ ignore_errors = true
module = "src.services.worddoc_handler"
ignore_errors = true
[dependency-groups]
dev = [
"mypy>=1.19.1",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.11",
"yapf>=0.43.0",
]

View File

@ -5,13 +5,14 @@ from pathlib import Path
from PIL import Image, PngImagePlugin
dest_dir = Path("tests/assets/test_images")
# Generate 45 PNG images with various metadata
for i in range(1, 46):
# Create a simple colored image
r, g, b = random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)
img = Image.new("RGB", (100, 100), color=(r, g, b))
img = Image.new("RGB", (100, 100), color = (r, g, b))
# Add PNG text metadata
pnginfo = PngImagePlugin.PngInfo()
@ -23,7 +24,7 @@ for i in range(1, 46):
# Save with metadata
filename = dest_dir / f"generated_test_{i:02d}.png"
img.save(filename, pnginfo=pnginfo)
img.save(filename, pnginfo = pnginfo)
print("Generated 45 PNG images with metadata")
print(f"Total PNG count: {len(list(dest_dir.glob('*.png')))}")

View File

@ -15,6 +15,7 @@ from src.services.metadata_factory import MetadataFactory
from src.utils.display import print_metadata_table
from src.utils.get_target_files import get_target_files
console = Console()
log = logging.getLogger("metadata-scrubber")

View File

@ -27,6 +27,7 @@ from src.services.batch_processor import BatchProcessor
from src.utils.display import print_batch_summary
from src.utils.get_target_files import get_target_files
console = Console()
log = logging.getLogger("metadata-scrubber")
@ -93,7 +94,9 @@ def scrub(
# Show dry-run banner
if dry_run:
console.print("\n[bold yellow]🔍 DRY-RUN MODE[/bold yellow] - No files will be modified.\n")
console.print(
"\n[bold yellow]🔍 DRY-RUN MODE[/bold yellow] - No files will be modified.\n"
)
# Collect files to process
files = list(get_target_files(file_path, ext)) if recursive else [file_path]
@ -104,25 +107,31 @@ def scrub(
# Show worker count for batch operations
if len(files) > 1:
console.print(f"[dim]Processing {len(files)} files with {workers} workers...[/dim]\n")
console.print(
f"[dim]Processing {len(files)} files with {workers} workers...[/dim]\n"
)
# Initialize processor with worker count
processor = BatchProcessor(output_dir=output_dir, dry_run=dry_run, max_workers=workers)
processor = BatchProcessor(
output_dir = output_dir,
dry_run = dry_run,
max_workers = workers
)
# Process with thread-safe progress bar
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
console=console,
refresh_per_second=10, # Smooth updates for concurrent processing
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
console = console,
refresh_per_second = 10, # Smooth updates for concurrent processing
) as progress:
task_id = progress.add_task(
"[cyan]Scrubbing metadata...",
total=len(files),
total = len(files),
)
def on_file_complete(result):
@ -130,12 +139,12 @@ def scrub(
status = "" if result.success else ""
progress.update(
task_id,
description=f"[cyan]{status} {result.filepath.name}",
advance=1,
description = f"[cyan]{status} {result.filepath.name}",
advance = 1,
)
# Use concurrent batch processing
processor.process_batch(files, progress_callback=on_file_complete)
processor.process_batch(files, progress_callback = on_file_complete)
# Display summary
print_batch_summary(processor.get_summary())

79
src/commands/verify.py Normal file
View File

@ -0,0 +1,79 @@
"""
Verify command for comparing metadata between original and processed files.
This command reads metadata from both files and generates a comparison report
showing what was removed, preserved, or unchanged.
"""
import logging
from pathlib import Path
import typer
from rich.console import Console
from src.services.metadata_factory import MetadataFactory
from src.services.report_generator import ReportGenerator
console = Console()
log = logging.getLogger("metadata-scrubber")
def verify(
original_path: Path = typer.Argument(
exists = True,
file_okay = True,
dir_okay = False,
readable = True,
resolve_path = True,
help = "Path to the original file (before scrubbing)",
),
processed_path: Path = typer.Argument(
exists = True,
file_okay = True,
dir_okay = False,
readable = True,
resolve_path = True,
help = "Path to the processed file (after scrubbing)",
),
) -> None:
"""
Verify that metadata was properly removed from a scrubbed file.
Compares the original file with the processed version and shows
a detailed report of what was removed, preserved, or still present.
"""
if log.isEnabledFor(logging.DEBUG):
log.info(f"Original: {original_path}")
log.info(f"Processed: {processed_path}")
try:
# Read metadata from original file
original_handler = MetadataFactory.get_handler(str(original_path))
before_metadata = original_handler.read()
# Read metadata from processed file
processed_handler = MetadataFactory.get_handler(str(processed_path))
after_metadata = processed_handler.read()
# Generate comparison report
generator = ReportGenerator()
report = generator.generate_report(
original_file = str(original_path),
processed_file = str(processed_path),
before_metadata = before_metadata,
after_metadata = after_metadata,
)
# Render the report
console.print(
f"[bold]Comparing:[/bold] {original_path.name}{processed_path.name}"
)
console.print()
generator.render_table(report)
except Exception as e:
console.print(f"[red]Error during verification:[/red] {e}")
if log.isEnabledFor(logging.DEBUG):
console.print_exception()
raise typer.Exit(code = 1)

View File

@ -34,7 +34,6 @@ class JpegProcessor:
tags_to_delete: List of EXIF tag IDs to remove.
data: Dict of extracted metadata with human-readable keys.
"""
def __init__(self):
"""Initialize the JPEG processor with empty data structures."""
self.tags_to_delete: list[int] = []
@ -69,15 +68,13 @@ class JpegProcessor:
# Get the human-readable name for the tag
tag_name = str(
tag_info.get("name", "Unknown Tag") if tag_info else "Unknown Tag"
tag_info.get("name",
"Unknown Tag") if tag_info else "Unknown Tag"
)
# Exclude tags necessary for image display integrity
if (
tag_name == "Orientation"
or tag_name == "ColorSpace"
or tag_name == "ExifTag"
):
if (tag_name == "Orientation" or tag_name == "ColorSpace"
or tag_name == "ExifTag"):
continue
# Save to list and dict

View File

@ -95,7 +95,7 @@ class PngProcessor:
found_metadata = True
if isinstance(value, bytes):
try:
value = value.decode("utf-8", errors="replace")
value = value.decode("utf-8", errors = "replace")
except Exception:
value = str(value)
@ -143,7 +143,9 @@ class PngProcessor:
raise MetadataProcessingError(f"Error processing PNG EXIF: {str(e)}")
def get_clean_pnginfo(
self, img: Image, keys_to_remove: list[str] | None = None
self,
img: Image,
keys_to_remove: list[str] | None = None
) -> PngImagePlugin.PngInfo | None:
"""
Create a new PngInfo with sensitive keys removed.

View File

@ -7,6 +7,7 @@ It initializes the Typer app, registers commands, and configures logging.
Commands:
read: Display metadata from files.
scrub: Remove metadata from files.
verify: Compare original and processed files to verify metadata removal.
"""
import logging
@ -15,14 +16,14 @@ import typer
from src.commands.read import read
from src.commands.scrub import scrub
from src.commands.verify import verify
from src.utils.logger import setup_logging
# Initialize the Typer app with helpful defaults
app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
app = typer.Typer(no_args_is_help = True, pretty_exceptions_show_locals = False)
log = logging.getLogger("metadata-scrubber")
__version__ = "0.2.0"
__version__ = "0.3.0"
# ---------------------------------------------------------
@ -59,7 +60,7 @@ def main(
),
):
"""
Metadata Scrubber Tool - Clean your images personal identifying data. eg: author name, camera model, GPS coordinates, etc.
Metadata Scrubber Tool - Clean your files' personal identifying data. eg: author name, camera model, GPS coordinates, etc.
"""
# Initialize the logger based on the user's flag
setup_logging(verbose)
@ -69,8 +70,9 @@ def main(
# fmt: on
# register commands
app.command(name="read")(read)
app.command(name="scrub")(scrub)
app.command(name = "read")(read)
app.command(name = "scrub")(scrub)
app.command(name = "verify")(verify)
# run app
if __name__ == "__main__":

View File

@ -19,6 +19,7 @@ from rich.console import Console
from src.services.metadata_factory import MetadataFactory
log = logging.getLogger("metadata-scrubber")
console = Console()
@ -44,7 +45,7 @@ class BatchSummary:
failed: int = 0
dry_run: bool = False
output_dir: Path | None = None
results: list[FileResult] = field(default_factory=list)
results: list[FileResult] = field(default_factory = list)
class BatchProcessor:
@ -55,7 +56,6 @@ class BatchProcessor:
Supports dry-run mode, automatic duplicate suffix handling,
and concurrent processing via ThreadPoolExecutor.
"""
def __init__(
self,
output_dir: str | None = None,
@ -99,12 +99,12 @@ class BatchProcessor:
if self.dry_run:
# Verify the file can be handled (will raise if not)
MetadataFactory.get_handler(str(file))
output_path = self._get_unique_output_path(file, reserve=False)
output_path = self._get_unique_output_path(file, reserve = False)
result = FileResult(
filepath=file,
success=True,
action="dry-run",
output_path=output_path,
filepath = file,
success = True,
action = "dry-run",
output_path = output_path,
)
self._append_result(result)
log.debug(f"[DRY-RUN] Would process: {file}")
@ -121,10 +121,10 @@ class BatchProcessor:
handler.save(str(output_path))
result = FileResult(
filepath=file,
success=True,
action="scrubbed",
output_path=output_path,
filepath = file,
success = True,
action = "scrubbed",
output_path = output_path,
)
self._append_result(result)
if log.isEnabledFor(logging.DEBUG):
@ -137,10 +137,10 @@ class BatchProcessor:
self._cleanup_reserved_path(output_path)
result = FileResult(
filepath=file,
success=False,
action="skipped",
error=str(e),
filepath = file,
success = False,
action = "skipped",
error = str(e),
)
self._append_result(result)
if log.isEnabledFor(logging.DEBUG):
@ -151,7 +151,8 @@ class BatchProcessor:
def process_batch(
self,
files: Iterable[Path],
progress_callback: Callable[[FileResult], None] | None = None,
progress_callback: Callable[[FileResult],
None] | None = None,
) -> list[FileResult]:
"""
Process multiple files concurrently using ThreadPoolExecutor.
@ -170,10 +171,12 @@ class BatchProcessor:
return self.results
# Used ThreadPoolExecutor for I/O-bound concurrent processing
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
with ThreadPoolExecutor(max_workers = self.max_workers) as executor:
# Submit all files for processing
future_to_file = {
executor.submit(self.process_file, file): file for file in file_list
executor.submit(self.process_file,
file): file
for file in file_list
}
# Collect results as they complete
@ -209,14 +212,14 @@ class BatchProcessor:
results_copy = list(self.results)
summary = BatchSummary(
total=len(results_copy),
success=sum(
total = len(results_copy),
success = sum(
1 for r in results_copy if r.success and r.action == "scrubbed"
),
skipped=sum(1 for r in results_copy if not r.success),
dry_run=self.dry_run,
output_dir=self.output_dir,
results=results_copy,
skipped = sum(1 for r in results_copy if not r.success),
dry_run = self.dry_run,
output_dir = self.output_dir,
results = results_copy,
)
# Count dry-run as separate from success for clarity
if self.dry_run:
@ -266,7 +269,7 @@ class BatchProcessor:
"""
with self._path_lock:
# creates the destination directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents = True, exist_ok = True)
base_name = file.stem
extension = file.suffix

View File

@ -44,7 +44,6 @@ class ExcelHandler(MetadataHandler):
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the Excel handler.
@ -66,7 +65,7 @@ class ExcelHandler(MetadataHandler):
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
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")
@ -152,7 +151,7 @@ class ExcelHandler(MetadataHandler):
# 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)
wb = load_workbook(destination_file_path, keep_vba = True)
else:
wb = load_workbook(destination_file_path)

View File

@ -39,7 +39,6 @@ class ImageHandler(MetadataHandler):
tags_to_delete: List of EXIF tags to remove during wipe operation.
detected_format: Actual image format detected by Pillow.
"""
def __init__(self, filepath: str):
"""
Initialize the image handler.
@ -48,10 +47,11 @@ class ImageHandler(MetadataHandler):
filepath: Path to the image file to process.
"""
super().__init__(filepath)
self.processors: dict[str, JpegProcessor | PngProcessor] = {
"jpeg": JpegProcessor(),
"png": PngProcessor(),
}
self.processors: dict[str,
JpegProcessor | PngProcessor] = {
"jpeg": JpegProcessor(),
"png": PngProcessor(),
}
self.tags_to_delete: list[int] = []
self.detected_format: str | None = None
self.text_keys_to_delete: list[str] = []
@ -128,12 +128,16 @@ class ImageHandler(MetadataHandler):
with Image.open(Path(self.filepath)) as img:
self.processed_metadata = cast(
dict[str, Any], processor.delete_metadata(img, self.tags_to_delete)
dict[str,
Any],
processor.delete_metadata(img,
self.tags_to_delete)
)
# For PNG, also get clean PngInfo
if isinstance(processor, PngProcessor):
self.clean_pnginfo = processor.get_clean_pnginfo(
img, self.text_keys_to_delete
img,
self.text_keys_to_delete
)
def save(self, output_path: str | Path | None = None) -> None:
@ -160,7 +164,7 @@ class ImageHandler(MetadataHandler):
shutil.copy2(self.filepath, destination_file_path)
with Image.open(destination_file_path) as img:
exif_bytes = piexif.dump(self.processed_metadata)
img.save(destination_file_path, exif=exif_bytes)
img.save(destination_file_path, exif = exif_bytes)
elif actual_format == "png":
# PNG: Open original, save fresh copy without metadata
@ -169,7 +173,9 @@ class ImageHandler(MetadataHandler):
# Preserve image mode and data integrity
img.save(
destination_file_path,
format="PNG",
exif=None,
pnginfo=getattr(self, "clean_pnginfo", None),
format = "PNG",
exif = None,
pnginfo = getattr(self,
"clean_pnginfo",
None),
)

View File

@ -28,7 +28,6 @@ class MetadataFactory:
- Images: .jpg, .jpeg, .png
- Future: .pdf, .docx, .xlsx, .pptx
"""
@staticmethod
def get_handler(filepath: str):
"""

View File

@ -23,7 +23,6 @@ class MetadataHandler(ABC):
metadata: Dictionary containing the extracted metadata.
processed_metadata: Dictionary containing metadata after processing.
"""
def __init__(self, filepath: str):
"""
Initialize the metadata handler.

View File

@ -33,7 +33,6 @@ class PDFHandler(MetadataHandler):
Attributes:
keys_to_delete: List of metadata keys to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the PDF handler.
@ -58,7 +57,7 @@ class PDFHandler(MetadataHandler):
if ext != ".pdf":
raise UnsupportedFormatError(f"Unsupported format: {ext}")
return ext[1:] # Return 'pdf' without the dot
return ext[1 :] # Return 'pdf' without the dot
def read(self) -> dict[str, Any]:
"""

View File

@ -64,7 +64,6 @@ class PowerpointHandler(MetadataHandler):
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the PowerPoint handler.
@ -86,7 +85,7 @@ class PowerpointHandler(MetadataHandler):
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
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")

View File

@ -0,0 +1,311 @@
"""
Report generator for metadata comparison and verification.
This module provides the ReportGenerator class which compares before/after
metadata states and generates rich table output showing what was removed,
preserved, or unchanged.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from rich.console import Console
from rich.table import Table
class PropertyStatus(Enum):
"""Status of a metadata property after scrubbing."""
REMOVED = "removed" # Was present, now None/empty
PRESERVED = "preserved" # Intentionally kept (created, modified, etc.)
UNCHANGED = "unchanged" # Had no value before and after
WARNING = "warning" # Should have been removed but wasn't
class VerificationStatus(Enum):
"""Overall verification status of a scrubbed file."""
CLEAN = "clean" # All sensitive metadata removed
WARNING = "warning" # Some metadata may still be present
ERROR = "error" # Verification failed
@dataclass
class PropertyComparison:
"""Comparison result for a single metadata property."""
name: str
before_value: Any
after_value: Any
status: PropertyStatus
is_sensitive: bool = True # Whether this property is considered sensitive
@dataclass
class ComparisonReport:
"""Complete comparison report between original and processed files."""
original_file: str
processed_file: str
comparisons: list[PropertyComparison] = field(default_factory = list)
status: VerificationStatus = VerificationStatus.CLEAN
removed_count: int = 0
preserved_count: int = 0
warning_count: int = 0
class ReportGenerator:
"""
Generates before/after metadata comparison reports.
Compares metadata from original and processed files to verify
that sensitive information has been properly removed.
"""
# Properties that are intentionally preserved
PRESERVED_PROPERTIES = {
"created",
"modified",
"language",
"last_printed",
"revision",
"JPEGInterchangeFormat",
"JPEGInterchangeFormatLength",
}
def __init__(self):
"""Initialize the report generator."""
self.console = Console()
def compare(
self,
before: dict[str,
Any],
after: dict[str,
Any],
preserved_keys: set[str] | None = None,
) -> ComparisonReport:
"""
Compare before and after metadata states.
Args:
before: Metadata dictionary from original file.
after: Metadata dictionary from processed file.
preserved_keys: Set of property names that should be preserved.
Returns:
ComparisonReport with detailed comparison results.
"""
if preserved_keys is None:
preserved_keys = self.PRESERVED_PROPERTIES
report = ComparisonReport(
original_file = "",
processed_file = "",
)
# Get all unique keys from both dictionaries
all_keys = set(before.keys()) | set(after.keys())
for key in sorted(all_keys):
before_value = before.get(key)
after_value = after.get(key)
# Determine property status
status = self._determine_status(
key,
before_value,
after_value,
preserved_keys
)
is_sensitive = key not in preserved_keys
comparison = PropertyComparison(
name = key,
before_value = before_value,
after_value = after_value,
status = status,
is_sensitive = is_sensitive,
)
report.comparisons.append(comparison)
# Update counts
if status == PropertyStatus.REMOVED:
report.removed_count += 1
elif status == PropertyStatus.PRESERVED:
report.preserved_count += 1
elif status == PropertyStatus.WARNING:
report.warning_count += 1
# Determine overall status
if report.warning_count > 0:
report.status = VerificationStatus.WARNING
else:
report.status = VerificationStatus.CLEAN
return report
def _determine_status(
self,
key: str,
before_value: Any,
after_value: Any,
preserved_keys: set[str],
) -> PropertyStatus:
"""
Determine the status of a property based on before/after values.
Args:
key: Property name.
before_value: Value before processing.
after_value: Value after processing.
preserved_keys: Set of keys that should be preserved.
Returns:
PropertyStatus indicating what happened to this property.
"""
# Check if this is a preserved property
if key in preserved_keys:
return PropertyStatus.PRESERVED
# Check if value was removed
before_has_value = self._has_value(before_value)
after_has_value = self._has_value(after_value)
if before_has_value and not after_has_value:
return PropertyStatus.REMOVED
elif not before_has_value and not after_has_value:
return PropertyStatus.UNCHANGED
elif before_has_value and after_has_value:
# Sensitive property still has value - warning
return PropertyStatus.WARNING
else:
return PropertyStatus.UNCHANGED
def _has_value(self, value: Any) -> bool:
"""
Check if a value is considered "present" (not None/empty).
Args:
value: Value to check.
Returns:
True if value is present and meaningful.
"""
if value is None:
return False
if isinstance(value, str) and value.strip() in ("", "-"):
return False
if value == 0 and not isinstance(value, bool):
# 0 is typically not meaningful for metadata
return False
return True
def render_table(self, report: ComparisonReport) -> None:
"""
Render a comparison report as a rich table.
Args:
report: ComparisonReport to render.
"""
# Create status emoji mapping
status_icons = {
PropertyStatus.REMOVED: "[green]✅ Removed[/green]",
PropertyStatus.PRESERVED: "[dim]⚪ Preserved[/dim]",
PropertyStatus.UNCHANGED: "[dim]— No change[/dim]",
PropertyStatus.WARNING: "[yellow]⚠️ Still present[/yellow]",
}
table = Table(
title = "[bold]Verification Report[/bold]",
show_header = True,
header_style = "bold",
)
table.add_column("Property", style = "cyan")
table.add_column("Before", style = "dim")
table.add_column("After", justify = "left")
for comp in report.comparisons:
before_str = self._format_value(comp.before_value)
after_str = status_icons.get(comp.status, str(comp.after_value))
table.add_row(comp.name, before_str, after_str)
self.console.print(table)
# Print summary
self._print_summary(report)
def _format_value(self, value: Any) -> str:
"""
Format a metadata value for display.
Args:
value: Value to format.
Returns:
Formatted string representation.
"""
if value is None:
return "[dim]None[/dim]"
if isinstance(value, str):
if len(value) > 30:
return value[: 27] + "..."
return value if value.strip() else "[dim]-[/dim]"
return str(value)
def _print_summary(self, report: ComparisonReport) -> None:
"""
Print summary statistics for the report.
Args:
report: ComparisonReport to summarize.
"""
self.console.print()
if report.status == VerificationStatus.CLEAN:
self.console.print(
"[bold green]✅ Status: CLEAN[/bold green] - "
"All sensitive metadata removed"
)
elif report.status == VerificationStatus.WARNING:
self.console.print(
f"[bold yellow]⚠️ Status: WARNING[/bold yellow] - "
f"{report.warning_count} properties may still contain data"
)
else:
self.console.print("[bold red]❌ Status: ERROR[/bold red]")
self.console.print(
f"[dim]Removed: {report.removed_count} | "
f"Preserved: {report.preserved_count}[/dim]"
)
def generate_report(
self,
original_file: str,
processed_file: str,
before_metadata: dict[str,
Any],
after_metadata: dict[str,
Any],
) -> ComparisonReport:
"""
Generate a full comparison report between two files.
Args:
original_file: Path to original file.
processed_file: Path to processed file.
before_metadata: Metadata from original file.
after_metadata: Metadata from processed file.
Returns:
ComparisonReport with file paths and comparison data.
"""
report = self.compare(before_metadata, after_metadata)
report.original_file = original_file
report.processed_file = processed_file
return report

View File

@ -61,7 +61,6 @@ class WorddocHandler(MetadataHandler):
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the Word document handler.
@ -83,7 +82,7 @@ class WorddocHandler(MetadataHandler):
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
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")

View File

@ -15,6 +15,7 @@ from rich.text import Text
from src.utils.formatter import clean_value
console = Console()
@ -37,7 +38,10 @@ def print_metadata_table(metadata: dict[str, Any]):
"/Author",
"/Creator",
],
"📸 Device Info": ["Make", "Model", "Software", "ExifVersion"],
"📸 Device Info": ["Make",
"Model",
"Software",
"ExifVersion"],
"⚙️ Exposure Settings": [
"ExposureTime",
"FNumber",
@ -68,9 +72,9 @@ def print_metadata_table(metadata: dict[str, Any]):
}
# Create the main table
table = Table(box=box.ROUNDED, show_header=True, header_style="bold magenta")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
table = Table(box = box.ROUNDED, show_header = True, header_style = "bold magenta")
table.add_column("Property", style = "cyan")
table.add_column("Value", style = "green")
# Track which keys we have displayed to handle the "leftovers"
displayed_keys = set()
@ -82,7 +86,7 @@ def print_metadata_table(metadata: dict[str, Any]):
if section_data:
# Add a section row (acts as a sub-header)
table.add_row(Text(section_name, style="bold yellow"), "")
table.add_row(Text(section_name, style = "bold yellow"), "")
for key, val in section_data.items():
table.add_row(f" {key}", clean_value(val))
@ -98,13 +102,16 @@ def print_metadata_table(metadata: dict[str, Any]):
if k not in displayed_keys and k != "JPEGInterchangeFormat"
} # skip binary blobs
if leftovers:
table.add_row(Text("📝 Other", style="bold yellow"), "")
table.add_row(Text("📝 Other", style = "bold yellow"), "")
for key, val in leftovers.items():
table.add_row(f" {key}", clean_value(val))
# Print nicely inside a panel
console.print(
Panel(table, title="Metadata Report", border_style="blue", expand=False)
Panel(table,
title = "Metadata Report",
border_style = "blue",
expand = False)
)
@ -116,9 +123,9 @@ def print_batch_summary(summary) -> None:
summary: BatchSummary object with processing statistics.
"""
# Build the summary table
table = Table(box=box.ROUNDED, show_header=False, expand=False)
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green")
table = Table(box = box.ROUNDED, show_header = False, expand = False)
table.add_column("Metric", style = "cyan")
table.add_column("Value", style = "green")
if summary.dry_run:
table.add_row("Mode", "[yellow]DRY-RUN (no changes made)[/yellow]")
@ -135,8 +142,8 @@ def print_batch_summary(summary) -> None:
failed = [r for r in summary.results if not r.success]
if failed:
table.add_section()
table.add_row(Text("Failed files:", style="bold red"), "")
for result in failed[:5]: # Show max 5 failures
table.add_row(Text("Failed files:", style = "bold red"), "")
for result in failed[: 5]: # Show max 5 failures
table.add_row(f" {result.filepath.name}", f"[dim]{result.error}[/dim]")
if len(failed) > 5:
table.add_row("", f"[dim]... and {len(failed) - 5} more[/dim]")
@ -152,4 +159,9 @@ def print_batch_summary(summary) -> None:
title = "⚠️ Scrub Complete (with warnings)"
border_style = "yellow"
console.print(Panel(table, title=title, border_style=border_style, expand=False))
console.print(
Panel(table,
title = title,
border_style = border_style,
expand = False)
)

View File

@ -32,14 +32,14 @@ def setup_logging(verbose: bool = False):
logging.getLogger().handlers.clear()
logging.basicConfig(
level=level,
format="%(message)s",
datefmt="[%X]",
handlers=[
level = level,
format = "%(message)s",
datefmt = "[%X]",
handlers = [
RichHandler(
rich_tracebacks=True, # Beautiful colorful stack traces
markup=True, # Allow [bold red] styles in logs
show_path=False, # Hide line number (cleaner for CLI tools)
rich_tracebacks = True, # Beautiful colorful stack traces
markup = True, # Allow [bold red] styles in logs
show_path = False, # Hide line number (cleaner for CLI tools)
)
],
)

View File

@ -22,6 +22,7 @@ from tests.conftest import (
get_xlsx_test_file,
)
runner = CliRunner()
# Test file paths (cross-platform)

View File

@ -22,6 +22,7 @@ from tests.conftest import (
get_xlsx_test_file,
)
runner = CliRunner()
# Test file paths (cross-platform)
@ -38,7 +39,7 @@ XLSX_DIR = get_test_xlsx_dir()
def output_dir(tmp_path):
"""Create isolated output directory for each test using tmp_path."""
output = tmp_path / "output"
output.mkdir(parents=True, exist_ok=True)
output.mkdir(parents = True, exist_ok = True)
return output
@ -58,7 +59,14 @@ def test_scrub_command_single_file_success(x, output_dir):
def test_scrub_command_recursive_jpg_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for JPG."""
result = runner.invoke(
app, ["scrub", EXAMPLES_DIR, "-r", "-ext", "jpg", "--output", str(output_dir)]
app,
["scrub",
EXAMPLES_DIR,
"-r",
"-ext",
"jpg",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -69,7 +77,12 @@ def test_scrub_command_recursive_jpg_success(output_dir):
def test_scrub_command_dry_run(output_dir):
"""Test that --dry-run doesn't create files."""
result = runner.invoke(
app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir), "--dry-run"]
app,
["scrub",
JPG_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -136,7 +149,14 @@ def test_scrub_command_pdf_single_file_success(output_dir):
def test_scrub_command_recursive_pdf_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for PDF."""
result = runner.invoke(
app, ["scrub", PDF_DIR, "-r", "-ext", "pdf", "--output", str(output_dir)]
app,
["scrub",
PDF_DIR,
"-r",
"-ext",
"pdf",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -147,7 +167,12 @@ def test_scrub_command_recursive_pdf_success(output_dir):
def test_scrub_command_pdf_dry_run(output_dir):
"""Test that --dry-run doesn't create PDF files."""
result = runner.invoke(
app, ["scrub", PDF_TEST_FILE, "--output", str(output_dir), "--dry-run"]
app,
["scrub",
PDF_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -171,7 +196,14 @@ def test_scrub_command_xlsx_single_file_success(output_dir):
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)]
app,
["scrub",
XLSX_DIR,
"-r",
"-ext",
"xlsx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -182,7 +214,12 @@ def test_scrub_command_recursive_xlsx_success(output_dir):
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"]
app,
["scrub",
XLSX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -232,7 +269,14 @@ def test_scrub_command_recursive_pptx_success(output_dir):
PPTX_DIR = get_test_pptx_dir()
result = runner.invoke(
app, ["scrub", PPTX_DIR, "-r", "-ext", "pptx", "--output", str(output_dir)]
app,
["scrub",
PPTX_DIR,
"-r",
"-ext",
"pptx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -246,7 +290,12 @@ def test_scrub_command_pptx_dry_run(output_dir):
PPTX_TEST_FILE = get_pptx_test_file()
result = runner.invoke(
app, ["scrub", PPTX_TEST_FILE, "--output", str(output_dir), "--dry-run"]
app,
["scrub",
PPTX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -299,7 +348,14 @@ def test_scrub_command_recursive_docx_success(output_dir):
DOCX_DIR = get_test_docx_dir()
result = runner.invoke(
app, ["scrub", DOCX_DIR, "-r", "-ext", "docx", "--output", str(output_dir)]
app,
["scrub",
DOCX_DIR,
"-r",
"-ext",
"docx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
@ -313,7 +369,12 @@ def test_scrub_command_docx_dry_run(output_dir):
DOCX_TEST_FILE = get_docx_test_file()
result = runner.invoke(
app, ["scrub", DOCX_TEST_FILE, "--output", str(output_dir), "--dry-run"]
app,
["scrub",
DOCX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"

View File

@ -0,0 +1,282 @@
"""
End-to-end tests for the verify command.
Tests the complete CLI workflow for the verify command, including
comparing original and processed files and verifying output format.
"""
import shutil
from pathlib import Path
import pytest
from typer.testing import CliRunner
from src.main import app
runner = CliRunner()
@pytest.fixture
def output_dir(tmp_path):
"""Create a temporary output directory for processed files."""
output = tmp_path / "output"
output.mkdir()
yield output
if output.exists():
shutil.rmtree(output)
# ============== Image Tests ==============
def test_verify_command_jpeg_clean_status(output_dir):
"""Test verify command shows CLEAN status for properly scrubbed JPEG."""
from tests.conftest import get_jpg_test_file
JPG_TEST_FILE = get_jpg_test_file()
# First scrub the file
scrub_result = runner.invoke(
app,
["scrub",
JPG_TEST_FILE,
"--output",
str(output_dir)]
)
assert scrub_result.exit_code == 0
# Get the processed file path
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
assert processed_file.exists()
# Now verify
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Comparing" in result.stdout
assert "CLEAN" in result.stdout or "Removed" in result.stdout
# this test can be ran if a png with metadata is found and added to the assests folder.
# def test_verify_command_png_clean_status(output_dir):
# """Test verify command shows CLEAN status for properly scrubbed PNG."""
# from tests.conftest import get_png_test_file
# # PNG_TEST_FILE = get_png_test_file()
# # First scrub the file
# scrub_result = runner.invoke(
# app, ["scrub", PNG_TEST_FILE, "--output", str(output_dir)]
# )
# # Get the processed file path
# processed_file = output_dir / f"processed_{Path(PNG_TEST_FILE).name}"
# # Skip if scrub failed (PNG might have no metadata)
# if scrub_result.exit_code != 0 or not processed_file.exists():
# pytest.skip("PNG file had no metadata to scrub")
# # Now verify
# result = runner.invoke(app, ["verify", PNG_TEST_FILE, str(processed_file)])
# assert result.exit_code == 0, f"Failed with: {result.stdout}"
# assert "Comparing" in result.stdout
# ============== PDF Tests ==============
def test_verify_command_pdf_clean_status(output_dir):
"""Test verify command shows CLEAN status for properly scrubbed PDF."""
from tests.conftest import get_pdf_test_file
PDF_TEST_FILE = get_pdf_test_file()
# First scrub the file
scrub_result = runner.invoke(
app,
["scrub",
PDF_TEST_FILE,
"--output",
str(output_dir)]
)
assert scrub_result.exit_code == 0
# Get the processed file path
processed_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
assert processed_file.exists()
# Now verify
result = runner.invoke(app, ["verify", PDF_TEST_FILE, str(processed_file)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Comparing" in result.stdout
# ============== Excel Tests ==============
def test_verify_command_xlsx_clean_status(output_dir):
"""Test verify command shows CLEAN status for properly scrubbed Excel."""
from tests.conftest import get_xlsx_test_file
XLSX_TEST_FILE = get_xlsx_test_file()
# First scrub the file
scrub_result = runner.invoke(
app,
["scrub",
XLSX_TEST_FILE,
"--output",
str(output_dir)]
)
assert scrub_result.exit_code == 0
# Get the processed file path
processed_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
assert processed_file.exists()
# Now verify
result = runner.invoke(app, ["verify", XLSX_TEST_FILE, str(processed_file)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Comparing" in result.stdout
# ============== PowerPoint Tests ==============
def test_verify_command_pptx_clean_status(output_dir):
"""Test verify command shows CLEAN status for properly scrubbed PowerPoint."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
# First scrub the file
scrub_result = runner.invoke(
app,
["scrub",
PPTX_TEST_FILE,
"--output",
str(output_dir)]
)
assert scrub_result.exit_code == 0
# Get the processed file path
processed_file = output_dir / f"processed_{Path(PPTX_TEST_FILE).name}"
assert processed_file.exists()
# Now verify
result = runner.invoke(app, ["verify", PPTX_TEST_FILE, str(processed_file)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Comparing" in result.stdout
# ============== Word Document Tests ==============
def test_verify_command_docx_clean_status(output_dir):
"""Test verify command shows CLEAN status for properly scrubbed Word doc."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
# First scrub the file
scrub_result = runner.invoke(
app,
["scrub",
DOCX_TEST_FILE,
"--output",
str(output_dir)]
)
assert scrub_result.exit_code == 0
# Get the processed file path
processed_file = output_dir / f"processed_{Path(DOCX_TEST_FILE).name}"
assert processed_file.exists()
# Now verify
result = runner.invoke(app, ["verify", DOCX_TEST_FILE, str(processed_file)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Comparing" in result.stdout
# ============== Error Tests ==============
def test_verify_command_original_file_not_found():
"""Test verify command handles missing original file."""
result = runner.invoke(
app,
["verify",
"nonexistent_file.jpg",
"also_nonexistent.jpg"]
)
assert result.exit_code != 0
def test_verify_command_processed_file_not_found():
"""Test verify command handles missing processed file."""
from tests.conftest import get_jpg_test_file
JPG_TEST_FILE = get_jpg_test_file()
result = runner.invoke(app, ["verify", JPG_TEST_FILE, "nonexistent_processed.jpg"])
assert result.exit_code != 0
# ============== Output Format Tests ==============
def test_verify_command_shows_removed_count(output_dir):
"""Test that verify command shows removed count in output."""
from tests.conftest import get_jpg_test_file
JPG_TEST_FILE = get_jpg_test_file()
# Scrub the file
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
# Verify
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
assert "Removed:" in result.stdout
def test_verify_command_shows_preserved_count(output_dir):
"""Test that verify command shows preserved count in output."""
from tests.conftest import get_jpg_test_file
JPG_TEST_FILE = get_jpg_test_file()
# Scrub the file
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
# Verify
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
assert "Preserved:" in result.stdout
def test_verify_command_shows_comparison_header(output_dir):
"""Test that verify command shows comparison header."""
from tests.conftest import get_jpg_test_file
JPG_TEST_FILE = get_jpg_test_file()
# Scrub the file
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
# Verify
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
assert "Comparing:" in result.stdout
assert Path(JPG_TEST_FILE).name in result.stdout

View File

@ -46,8 +46,7 @@ def test_read_image_metadata(x):
if isinstance(handler, (JpegProcessor, PngProcessor)):
assert (
handler.tags_to_delete is not None
or handler.text_keys_to_delete is not None
handler.tags_to_delete is not None or handler.text_keys_to_delete is not None
)
@ -66,7 +65,7 @@ def test_wipe_image_metadata(x):
def test_save_processed_image_metadata(x):
"""Test saving processed image metadata through factory."""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(str(x))
handler.read()
@ -116,7 +115,7 @@ def test_wipe_pdf_metadata_via_factory():
def test_save_processed_pdf_metadata_via_factory():
"""Test saving processed PDF metadata through MetadataFactory."""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
handler.read()
@ -163,7 +162,7 @@ def test_wipe_excel_metadata_via_factory():
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)
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
handler.read()
@ -220,7 +219,7 @@ def test_save_processed_pptx_metadata_via_factory():
PPTX_TEST_FILE = get_pptx_test_file()
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
handler.read()
@ -279,7 +278,7 @@ def test_save_processed_docx_metadata_via_factory():
DOCX_TEST_FILE = get_docx_test_file()
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
handler.read()

View File

@ -68,7 +68,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
handler = ExcelHandler(xlsx_file)
handler.read()
@ -99,7 +99,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = ExcelHandler(xlsx_file)

View File

@ -32,8 +32,7 @@ def test_read_image_metadata(x):
# checks if tags_to_delete or text_keys_to_delete is not empty
assert (
processor.tags_to_delete is not None
or processor.text_keys_to_delete is not None
processor.tags_to_delete is not None or processor.text_keys_to_delete is not None
)
# checks if metadata is a dictionary
@ -62,7 +61,7 @@ def test_save_processed_image_metadata(x):
"""
# creates output directory
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
processor = ImageHandler(x)
metadata = processor.read()
@ -116,7 +115,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
processor = ImageHandler(x)

View File

@ -69,7 +69,7 @@ def test_save_processed_pdf_metadata(pdf_file):
Test saving processed PDF to output path.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
handler = PDFHandler(pdf_file)
handler.read()
@ -100,7 +100,7 @@ def test_output_file_has_less_metadata(pdf_file):
Test that the output file has metadata stripped.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = PDFHandler(pdf_file)

View File

@ -66,7 +66,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
handler = PowerpointHandler(pptx_file)
handler.read()
@ -97,7 +97,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = PowerpointHandler(pptx_file)

View File

@ -66,7 +66,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
handler = WorddocHandler(docx_file)
handler.read()
@ -97,7 +97,7 @@ 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)
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = WorddocHandler(docx_file)

86
uv.lock
View File

@ -377,7 +377,7 @@ wheels = [
[[package]]
name = "metadata-scrubber"
version = "0.1.1"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "openpyxl" },
@ -390,31 +390,37 @@ dependencies = [
{ name = "typer" },
]
[package.optional-dependencies]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "yapf" },
]
[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" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.14.11" },
{ name = "yapf", specifier = ">=0.43.0" },
]
[[package]]
name = "mypy"
version = "1.19.1"
@ -493,11 +499,11 @@ wheels = [
[[package]]
name = "pathspec"
version = "1.0.1"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/28/2e/83722ece0f6ee24387d6cb830dd562ddbcd6ce0b9d76072c6849670c31b4/pathspec-1.0.1.tar.gz", hash = "sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c", size = 129791, upload-time = "2026-01-06T13:02:55.15Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fe/2257c71721aeab6a6e8aa1f00d01f2a20f58547d249a6c8fef5791f559fc/pathspec-1.0.1-py3-none-any.whl", hash = "sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721", size = 54584, upload-time = "2026-01-06T13:02:53.601Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
]
[[package]]
@ -607,6 +613,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -712,28 +727,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.14.10"
version = "0.14.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" },
{ url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" },
{ url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" },
{ url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" },
{ url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" },
{ url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" },
{ url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
{ url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
{ url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
]
[[package]]
@ -826,3 +841,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e
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" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]