From 35c7988e67ad1748ee1f2e832c31655a7c23ecbc Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sat, 10 Jan 2026 19:23:43 +0100 Subject: [PATCH 1/7] metadata-scrubber-tool: added comprehensive report coomand("veriiy") and tests, added docs --- README.md | 145 +++++++++++++-- docs/best-practices.md | 139 ++++++++++++++ docs/metadata-risks.md | 103 +++++++++++ pyproject.toml | 18 +- src/commands/verify.py | 81 ++++++++ src/main.py | 7 +- src/services/report_generator.py | 304 +++++++++++++++++++++++++++++++ tests/e2e/test_verify_command.py | 258 ++++++++++++++++++++++++++ 8 files changed, 1030 insertions(+), 25 deletions(-) create mode 100644 docs/best-practices.md create mode 100644 docs/metadata-risks.md create mode 100644 src/commands/verify.py create mode 100644 src/services/report_generator.py create mode 100644 tests/e2e/test_verify_command.py diff --git a/README.md b/README.md index 3fdb130e..af357d82 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 00000000..c3c46cc0 --- /dev/null +++ b/docs/best-practices.md @@ -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 +``` diff --git a/docs/metadata-risks.md b/docs/metadata-risks.md new file mode 100644 index 00000000..b0406ec7 --- /dev/null +++ b/docs/metadata-risks.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml index 7b8360c3..0451e2ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", +] + diff --git a/src/commands/verify.py b/src/commands/verify.py new file mode 100644 index 00000000..f4fac7c2 --- /dev/null +++ b/src/commands/verify.py @@ -0,0 +1,81 @@ +""" +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) + + # # Exit with appropriate code + # if report.status == VerificationStatus.WARNING: + # raise typer.Exit(code=1) + + except Exception as e: + console.print(f"[red]Error during verification:[/red] {e}") + if log.isEnabledFor(logging.DEBUG): + console.print_exception() diff --git a/src/main.py b/src/main.py index 89b5d22a..51646c76 100644 --- a/src/main.py +++ b/src/main.py @@ -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,6 +16,7 @@ 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 @@ -22,7 +24,7 @@ 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 +61,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) @@ -71,6 +73,7 @@ def main( # register commands app.command(name="read")(read) app.command(name="scrub")(scrub) +app.command(name="verify")(verify) # run app if __name__ == "__main__": diff --git a/src/services/report_generator.py b/src/services/report_generator.py new file mode 100644 index 00000000..f2ae04cb --- /dev/null +++ b/src/services/report_generator.py @@ -0,0 +1,304 @@ +""" +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 diff --git a/tests/e2e/test_verify_command.py b/tests/e2e/test_verify_command.py new file mode 100644 index 00000000..a37bc2c7 --- /dev/null +++ b/tests/e2e/test_verify_command.py @@ -0,0 +1,258 @@ +""" +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 From d0ab014b9d3a635738816f0ede719bae00fcc269 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sat, 10 Jan 2026 19:25:07 +0100 Subject: [PATCH 2/7] added yapf and style file --- .style.yapf | 47 +++++++++++++++++++++++++++++ uv.lock | 86 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 .style.yapf diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 00000000..8abdade4 --- /dev/null +++ b/.style.yapf @@ -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 diff --git a/uv.lock b/uv.lock index 339984bd..8f055311 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +] From 79d8b538f8af1b36623cc8ecec2cee90b25e2677 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sat, 10 Jan 2026 19:58:35 +0100 Subject: [PATCH 3/7] applied formatting from yapf style file --- .gitignore | 5 +- .yapfignore | 4 ++ scripts/generate_png_tests.py | 5 +- src/commands/read.py | 1 + src/commands/scrub.py | 39 ++++++---- src/commands/verify.py | 33 ++++----- src/core/jpeg_metadata.py | 11 ++- src/core/png_metadata.py | 6 +- src/main.py | 9 ++- src/services/batch_processor.py | 53 +++++++------- src/services/excel_handler.py | 5 +- src/services/image_handler.py | 28 +++++--- src/services/metadata_factory.py | 1 - src/services/metadata_handler.py | 1 - src/services/pdf_handler.py | 3 +- src/services/powerpoint_handler.py | 3 +- src/services/report_generator.py | 47 ++++++------ src/services/worddoc_handler.py | 3 +- src/utils/display.py | 38 ++++++---- src/utils/logger.py | 14 ++-- tests/e2e/test_read_command.py | 1 + tests/e2e/test_scrub_command.py | 83 +++++++++++++++++++--- tests/e2e/test_verify_command.py | 36 ++++++++-- tests/integration/test_metadata_factory.py | 13 ++-- tests/unit/test_excel_handler.py | 4 +- tests/unit/test_image_handler.py | 7 +- tests/unit/test_pdf_handler.py | 4 +- tests/unit/test_powerpoint_handler.py | 4 +- tests/unit/test_worddoc_handler.py | 4 +- 29 files changed, 294 insertions(+), 171 deletions(-) create mode 100644 .yapfignore diff --git a/.gitignore b/.gitignore index bef1aeef..8b24a52f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ Thumbs.db # ============== Project Specific ============== # Output directory for processed images -tests/assets/output/ \ No newline at end of file +tests/assets/output/ + + +.yapfignore/ \ No newline at end of file diff --git a/.yapfignore b/.yapfignore new file mode 100644 index 00000000..b39b8d26 --- /dev/null +++ b/.yapfignore @@ -0,0 +1,4 @@ +.venv/ +venv/ +env/ +typings/ \ No newline at end of file diff --git a/scripts/generate_png_tests.py b/scripts/generate_png_tests.py index 8d40e3ef..7cab3219 100644 --- a/scripts/generate_png_tests.py +++ b/scripts/generate_png_tests.py @@ -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')))}") diff --git a/src/commands/read.py b/src/commands/read.py index 01ba37ff..c827b0da 100644 --- a/src/commands/read.py +++ b/src/commands/read.py @@ -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") diff --git a/src/commands/scrub.py b/src/commands/scrub.py index 17d59714..ef6f3b1c 100644 --- a/src/commands/scrub.py +++ b/src/commands/scrub.py @@ -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()) diff --git a/src/commands/verify.py b/src/commands/verify.py index f4fac7c2..86ee32da 100644 --- a/src/commands/verify.py +++ b/src/commands/verify.py @@ -14,26 +14,27 @@ 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)", + 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)", + exists = True, + file_okay = True, + dir_okay = False, + readable = True, + resolve_path = True, + help = "Path to the processed file (after scrubbing)", ), ) -> None: """ @@ -58,10 +59,10 @@ def verify( # 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, + original_file = str(original_path), + processed_file = str(processed_path), + before_metadata = before_metadata, + after_metadata = after_metadata, ) # Render the report diff --git a/src/core/jpeg_metadata.py b/src/core/jpeg_metadata.py index 29962176..3fe680b0 100644 --- a/src/core/jpeg_metadata.py +++ b/src/core/jpeg_metadata.py @@ -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 diff --git a/src/core/png_metadata.py b/src/core/png_metadata.py index 2362d7b5..f970a28a 100644 --- a/src/core/png_metadata.py +++ b/src/core/png_metadata.py @@ -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. diff --git a/src/main.py b/src/main.py index 51646c76..7780b582 100644 --- a/src/main.py +++ b/src/main.py @@ -20,10 +20,9 @@ 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.3.0" @@ -71,9 +70,9 @@ def main( # fmt: on # register commands -app.command(name="read")(read) -app.command(name="scrub")(scrub) -app.command(name="verify")(verify) +app.command(name = "read")(read) +app.command(name = "scrub")(scrub) +app.command(name = "verify")(verify) # run app if __name__ == "__main__": diff --git a/src/services/batch_processor.py b/src/services/batch_processor.py index 0f4543f2..b7bee94a 100644 --- a/src/services/batch_processor.py +++ b/src/services/batch_processor.py @@ -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 diff --git a/src/services/excel_handler.py b/src/services/excel_handler.py index 68445bde..59501f47 100644 --- a/src/services/excel_handler.py +++ b/src/services/excel_handler.py @@ -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) diff --git a/src/services/image_handler.py b/src/services/image_handler.py index 18e96db0..13ee901d 100644 --- a/src/services/image_handler.py +++ b/src/services/image_handler.py @@ -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), ) diff --git a/src/services/metadata_factory.py b/src/services/metadata_factory.py index 1dd54e41..91c45f7d 100644 --- a/src/services/metadata_factory.py +++ b/src/services/metadata_factory.py @@ -28,7 +28,6 @@ class MetadataFactory: - Images: .jpg, .jpeg, .png - Future: .pdf, .docx, .xlsx, .pptx """ - @staticmethod def get_handler(filepath: str): """ diff --git a/src/services/metadata_handler.py b/src/services/metadata_handler.py index 5a189f23..1b45400d 100644 --- a/src/services/metadata_handler.py +++ b/src/services/metadata_handler.py @@ -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. diff --git a/src/services/pdf_handler.py b/src/services/pdf_handler.py index 246066c4..f08dc2b8 100644 --- a/src/services/pdf_handler.py +++ b/src/services/pdf_handler.py @@ -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]: """ diff --git a/src/services/powerpoint_handler.py b/src/services/powerpoint_handler.py index 99867b79..7277431a 100644 --- a/src/services/powerpoint_handler.py +++ b/src/services/powerpoint_handler.py @@ -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}") diff --git a/src/services/report_generator.py b/src/services/report_generator.py index f2ae04cb..6525f225 100644 --- a/src/services/report_generator.py +++ b/src/services/report_generator.py @@ -48,7 +48,7 @@ class ComparisonReport: original_file: str processed_file: str - comparisons: list[PropertyComparison] = field(default_factory=list) + comparisons: list[PropertyComparison] = field(default_factory = list) status: VerificationStatus = VerificationStatus.CLEAN removed_count: int = 0 preserved_count: int = 0 @@ -80,8 +80,10 @@ class ReportGenerator: def compare( self, - before: dict[str, Any], - after: dict[str, Any], + before: dict[str, + Any], + after: dict[str, + Any], preserved_keys: set[str] | None = None, ) -> ComparisonReport: """ @@ -99,8 +101,8 @@ class ReportGenerator: preserved_keys = self.PRESERVED_PROPERTIES report = ComparisonReport( - original_file="", - processed_file="", + original_file = "", + processed_file = "", ) # Get all unique keys from both dictionaries @@ -112,17 +114,20 @@ class ReportGenerator: # Determine property status status = self._determine_status( - key, before_value, after_value, preserved_keys + 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, + name = key, + before_value = before_value, + after_value = after_value, + status = status, + is_sensitive = is_sensitive, ) report.comparisons.append(comparison) @@ -214,14 +219,14 @@ class ReportGenerator: } table = Table( - title="[bold]Verification Report[/bold]", - show_header=True, - header_style="bold", + 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") + 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) @@ -248,7 +253,7 @@ class ReportGenerator: return "[dim]None[/dim]" if isinstance(value, str): if len(value) > 30: - return value[:27] + "..." + return value[: 27] + "..." return value if value.strip() else "[dim]-[/dim]" return str(value) @@ -283,8 +288,10 @@ class ReportGenerator: self, original_file: str, processed_file: str, - before_metadata: dict[str, Any], - after_metadata: dict[str, Any], + before_metadata: dict[str, + Any], + after_metadata: dict[str, + Any], ) -> ComparisonReport: """ Generate a full comparison report between two files. diff --git a/src/services/worddoc_handler.py b/src/services/worddoc_handler.py index e08a31cb..5a60c5a5 100644 --- a/src/services/worddoc_handler.py +++ b/src/services/worddoc_handler.py @@ -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}") diff --git a/src/utils/display.py b/src/utils/display.py index c815ed3d..04ea030b 100644 --- a/src/utils/display.py +++ b/src/utils/display.py @@ -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) + ) diff --git a/src/utils/logger.py b/src/utils/logger.py index 6df8280e..46487e94 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -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) ) ], ) diff --git a/tests/e2e/test_read_command.py b/tests/e2e/test_read_command.py index 4640b6ec..bc4cb48a 100644 --- a/tests/e2e/test_read_command.py +++ b/tests/e2e/test_read_command.py @@ -22,6 +22,7 @@ from tests.conftest import ( get_xlsx_test_file, ) + runner = CliRunner() # Test file paths (cross-platform) diff --git a/tests/e2e/test_scrub_command.py b/tests/e2e/test_scrub_command.py index 042d3abf..d9e77734 100644 --- a/tests/e2e/test_scrub_command.py +++ b/tests/e2e/test_scrub_command.py @@ -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}" diff --git a/tests/e2e/test_verify_command.py b/tests/e2e/test_verify_command.py index a37bc2c7..0d9592ac 100644 --- a/tests/e2e/test_verify_command.py +++ b/tests/e2e/test_verify_command.py @@ -13,6 +13,7 @@ from typer.testing import CliRunner from src.main import app + runner = CliRunner() @@ -37,7 +38,11 @@ def test_verify_command_jpeg_clean_status(output_dir): # First scrub the file scrub_result = runner.invoke( - app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)] + app, + ["scrub", + JPG_TEST_FILE, + "--output", + str(output_dir)] ) assert scrub_result.exit_code == 0 @@ -90,7 +95,11 @@ def test_verify_command_pdf_clean_status(output_dir): # First scrub the file scrub_result = runner.invoke( - app, ["scrub", PDF_TEST_FILE, "--output", str(output_dir)] + app, + ["scrub", + PDF_TEST_FILE, + "--output", + str(output_dir)] ) assert scrub_result.exit_code == 0 @@ -116,7 +125,11 @@ def test_verify_command_xlsx_clean_status(output_dir): # First scrub the file scrub_result = runner.invoke( - app, ["scrub", XLSX_TEST_FILE, "--output", str(output_dir)] + app, + ["scrub", + XLSX_TEST_FILE, + "--output", + str(output_dir)] ) assert scrub_result.exit_code == 0 @@ -142,7 +155,11 @@ def test_verify_command_pptx_clean_status(output_dir): # First scrub the file scrub_result = runner.invoke( - app, ["scrub", PPTX_TEST_FILE, "--output", str(output_dir)] + app, + ["scrub", + PPTX_TEST_FILE, + "--output", + str(output_dir)] ) assert scrub_result.exit_code == 0 @@ -168,7 +185,11 @@ def test_verify_command_docx_clean_status(output_dir): # First scrub the file scrub_result = runner.invoke( - app, ["scrub", DOCX_TEST_FILE, "--output", str(output_dir)] + app, + ["scrub", + DOCX_TEST_FILE, + "--output", + str(output_dir)] ) assert scrub_result.exit_code == 0 @@ -189,7 +210,10 @@ def test_verify_command_docx_clean_status(output_dir): 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"] + app, + ["verify", + "nonexistent_file.jpg", + "also_nonexistent.jpg"] ) assert result.exit_code != 0 diff --git a/tests/integration/test_metadata_factory.py b/tests/integration/test_metadata_factory.py index e2dc2dc2..78a62220 100644 --- a/tests/integration/test_metadata_factory.py +++ b/tests/integration/test_metadata_factory.py @@ -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() diff --git a/tests/unit/test_excel_handler.py b/tests/unit/test_excel_handler.py index 03c3e80a..fcb70829 100644 --- a/tests/unit/test_excel_handler.py +++ b/tests/unit/test_excel_handler.py @@ -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) diff --git a/tests/unit/test_image_handler.py b/tests/unit/test_image_handler.py index 742c02a3..229f5e47 100644 --- a/tests/unit/test_image_handler.py +++ b/tests/unit/test_image_handler.py @@ -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) diff --git a/tests/unit/test_pdf_handler.py b/tests/unit/test_pdf_handler.py index 55001c07..801b3e2e 100644 --- a/tests/unit/test_pdf_handler.py +++ b/tests/unit/test_pdf_handler.py @@ -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) diff --git a/tests/unit/test_powerpoint_handler.py b/tests/unit/test_powerpoint_handler.py index c85358e9..9fa5716c 100644 --- a/tests/unit/test_powerpoint_handler.py +++ b/tests/unit/test_powerpoint_handler.py @@ -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) diff --git a/tests/unit/test_worddoc_handler.py b/tests/unit/test_worddoc_handler.py index 766a5191..19c2eaaf 100644 --- a/tests/unit/test_worddoc_handler.py +++ b/tests/unit/test_worddoc_handler.py @@ -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) From 814e282d9eaf5ff084420442438ca727c114c734 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION <143282101+Heritage-XioN@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:12:34 +0100 Subject: [PATCH 4/7] Update src/commands/verify.py uncomment Exit non-zero on warning status Co-authored-by: qodo-code-review[bot] <151058649+qodo-code-review[bot]@users.noreply.github.com> --- src/commands/verify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/verify.py b/src/commands/verify.py index 86ee32da..1c7a07c1 100644 --- a/src/commands/verify.py +++ b/src/commands/verify.py @@ -72,9 +72,9 @@ def verify( console.print() generator.render_table(report) - # # Exit with appropriate code - # if report.status == VerificationStatus.WARNING: - # raise typer.Exit(code=1) + # Exit with appropriate code on warnings + if report.status == VerificationStatus.WARNING: + raise typer.Exit(code=1) except Exception as e: console.print(f"[red]Error during verification:[/red] {e}") From 4c94a42a6c0c6732d4ab165c4bc8cda9735c98b5 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION <143282101+Heritage-XioN@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:14:00 +0100 Subject: [PATCH 5/7] Update src/commands/verify.py Exit with error on verification exception Co-authored-by: qodo-code-review[bot] <151058649+qodo-code-review[bot]@users.noreply.github.com> --- src/commands/verify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/verify.py b/src/commands/verify.py index 1c7a07c1..a9d3b8b2 100644 --- a/src/commands/verify.py +++ b/src/commands/verify.py @@ -80,3 +80,4 @@ def verify( console.print(f"[red]Error during verification:[/red] {e}") if log.isEnabledFor(logging.DEBUG): console.print_exception() + raise typer.Exit(code=1) From fcf234f88fd2316617cce8440368c419cdae1fd9 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sat, 10 Jan 2026 20:32:30 +0100 Subject: [PATCH 6/7] check some potentially buggy sit --- src/commands/verify.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/commands/verify.py b/src/commands/verify.py index a9d3b8b2..d101d7ea 100644 --- a/src/commands/verify.py +++ b/src/commands/verify.py @@ -14,27 +14,26 @@ 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)", + 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)", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to the processed file (after scrubbing)", ), ) -> None: """ @@ -59,10 +58,10 @@ def verify( # 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, + original_file=str(original_path), + processed_file=str(processed_path), + before_metadata=before_metadata, + after_metadata=after_metadata, ) # Render the report @@ -72,10 +71,6 @@ def verify( console.print() generator.render_table(report) - # Exit with appropriate code on warnings - if report.status == VerificationStatus.WARNING: - raise typer.Exit(code=1) - except Exception as e: console.print(f"[red]Error during verification:[/red] {e}") if log.isEnabledFor(logging.DEBUG): From b3074df0305d915dc1f483578dfc3b10eaeb752e Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sat, 10 Jan 2026 20:33:05 +0100 Subject: [PATCH 7/7] applied format --- src/commands/verify.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/commands/verify.py b/src/commands/verify.py index d101d7ea..2b40810f 100644 --- a/src/commands/verify.py +++ b/src/commands/verify.py @@ -14,26 +14,27 @@ 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)", + 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)", + exists = True, + file_okay = True, + dir_okay = False, + readable = True, + resolve_path = True, + help = "Path to the processed file (after scrubbing)", ), ) -> None: """ @@ -58,10 +59,10 @@ def verify( # 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, + original_file = str(original_path), + processed_file = str(processed_path), + before_metadata = before_metadata, + after_metadata = after_metadata, ) # Render the report @@ -75,4 +76,4 @@ def verify( console.print(f"[red]Error during verification:[/red] {e}") if log.isEnabledFor(logging.DEBUG): console.print_exception() - raise typer.Exit(code=1) + raise typer.Exit(code = 1)