Merge pull request #6 from Heritage-XioN/add-comprehensive-reporting-interface
Add comprehensive reporting interface
This commit is contained in:
commit
250a648855
|
|
@ -46,4 +46,7 @@ Thumbs.db
|
|||
|
||||
# ============== Project Specific ==============
|
||||
# Output directory for processed images
|
||||
tests/assets/output/
|
||||
tests/assets/output/
|
||||
|
||||
|
||||
.yapfignore/
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.venv/
|
||||
venv/
|
||||
env/
|
||||
typings/
|
||||
145
README.md
145
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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')))}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Verify command for comparing metadata between original and processed files.
|
||||
|
||||
This command reads metadata from both files and generates a comparison report
|
||||
showing what was removed, preserved, or unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from src.services.metadata_factory import MetadataFactory
|
||||
from src.services.report_generator import ReportGenerator
|
||||
|
||||
|
||||
console = Console()
|
||||
log = logging.getLogger("metadata-scrubber")
|
||||
|
||||
|
||||
def verify(
|
||||
original_path: Path = typer.Argument(
|
||||
exists = True,
|
||||
file_okay = True,
|
||||
dir_okay = False,
|
||||
readable = True,
|
||||
resolve_path = True,
|
||||
help = "Path to the original file (before scrubbing)",
|
||||
),
|
||||
processed_path: Path = typer.Argument(
|
||||
exists = True,
|
||||
file_okay = True,
|
||||
dir_okay = False,
|
||||
readable = True,
|
||||
resolve_path = True,
|
||||
help = "Path to the processed file (after scrubbing)",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Verify that metadata was properly removed from a scrubbed file.
|
||||
|
||||
Compares the original file with the processed version and shows
|
||||
a detailed report of what was removed, preserved, or still present.
|
||||
"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.info(f"Original: {original_path}")
|
||||
log.info(f"Processed: {processed_path}")
|
||||
|
||||
try:
|
||||
# Read metadata from original file
|
||||
original_handler = MetadataFactory.get_handler(str(original_path))
|
||||
before_metadata = original_handler.read()
|
||||
|
||||
# Read metadata from processed file
|
||||
processed_handler = MetadataFactory.get_handler(str(processed_path))
|
||||
after_metadata = processed_handler.read()
|
||||
|
||||
# Generate comparison report
|
||||
generator = ReportGenerator()
|
||||
report = generator.generate_report(
|
||||
original_file = str(original_path),
|
||||
processed_file = str(processed_path),
|
||||
before_metadata = before_metadata,
|
||||
after_metadata = after_metadata,
|
||||
)
|
||||
|
||||
# Render the report
|
||||
console.print(
|
||||
f"[bold]Comparing:[/bold] {original_path.name} → {processed_path.name}"
|
||||
)
|
||||
console.print()
|
||||
generator.render_table(report)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error during verification:[/red] {e}")
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
console.print_exception()
|
||||
raise typer.Exit(code = 1)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
14
src/main.py
14
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,14 +16,14 @@ import typer
|
|||
|
||||
from src.commands.read import read
|
||||
from src.commands.scrub import scrub
|
||||
from src.commands.verify import verify
|
||||
from src.utils.logger import setup_logging
|
||||
|
||||
# Initialize the Typer app with helpful defaults
|
||||
app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
|
||||
app = typer.Typer(no_args_is_help = True, pretty_exceptions_show_locals = False)
|
||||
log = logging.getLogger("metadata-scrubber")
|
||||
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
|
|
@ -59,7 +60,7 @@ def main(
|
|||
),
|
||||
):
|
||||
"""
|
||||
Metadata Scrubber Tool - Clean your images personal identifying data. eg: author name, camera model, GPS coordinates, etc.
|
||||
Metadata Scrubber Tool - Clean your files' personal identifying data. eg: author name, camera model, GPS coordinates, etc.
|
||||
"""
|
||||
# Initialize the logger based on the user's flag
|
||||
setup_logging(verbose)
|
||||
|
|
@ -69,8 +70,9 @@ def main(
|
|||
# fmt: on
|
||||
|
||||
# register commands
|
||||
app.command(name="read")(read)
|
||||
app.command(name="scrub")(scrub)
|
||||
app.command(name = "read")(read)
|
||||
app.command(name = "scrub")(scrub)
|
||||
app.command(name = "verify")(verify)
|
||||
|
||||
# run app
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ class MetadataFactory:
|
|||
- Images: .jpg, .jpeg, .png
|
||||
- Future: .pdf, .docx, .xlsx, .pptx
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_handler(filepath: str):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
"""
|
||||
Report generator for metadata comparison and verification.
|
||||
|
||||
This module provides the ReportGenerator class which compares before/after
|
||||
metadata states and generates rich table output showing what was removed,
|
||||
preserved, or unchanged.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
class PropertyStatus(Enum):
|
||||
"""Status of a metadata property after scrubbing."""
|
||||
|
||||
REMOVED = "removed" # Was present, now None/empty
|
||||
PRESERVED = "preserved" # Intentionally kept (created, modified, etc.)
|
||||
UNCHANGED = "unchanged" # Had no value before and after
|
||||
WARNING = "warning" # Should have been removed but wasn't
|
||||
|
||||
|
||||
class VerificationStatus(Enum):
|
||||
"""Overall verification status of a scrubbed file."""
|
||||
|
||||
CLEAN = "clean" # All sensitive metadata removed
|
||||
WARNING = "warning" # Some metadata may still be present
|
||||
ERROR = "error" # Verification failed
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertyComparison:
|
||||
"""Comparison result for a single metadata property."""
|
||||
|
||||
name: str
|
||||
before_value: Any
|
||||
after_value: Any
|
||||
status: PropertyStatus
|
||||
is_sensitive: bool = True # Whether this property is considered sensitive
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonReport:
|
||||
"""Complete comparison report between original and processed files."""
|
||||
|
||||
original_file: str
|
||||
processed_file: str
|
||||
comparisons: list[PropertyComparison] = field(default_factory = list)
|
||||
status: VerificationStatus = VerificationStatus.CLEAN
|
||||
removed_count: int = 0
|
||||
preserved_count: int = 0
|
||||
warning_count: int = 0
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""
|
||||
Generates before/after metadata comparison reports.
|
||||
|
||||
Compares metadata from original and processed files to verify
|
||||
that sensitive information has been properly removed.
|
||||
"""
|
||||
|
||||
# Properties that are intentionally preserved
|
||||
PRESERVED_PROPERTIES = {
|
||||
"created",
|
||||
"modified",
|
||||
"language",
|
||||
"last_printed",
|
||||
"revision",
|
||||
"JPEGInterchangeFormat",
|
||||
"JPEGInterchangeFormatLength",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the report generator."""
|
||||
self.console = Console()
|
||||
|
||||
def compare(
|
||||
self,
|
||||
before: dict[str,
|
||||
Any],
|
||||
after: dict[str,
|
||||
Any],
|
||||
preserved_keys: set[str] | None = None,
|
||||
) -> ComparisonReport:
|
||||
"""
|
||||
Compare before and after metadata states.
|
||||
|
||||
Args:
|
||||
before: Metadata dictionary from original file.
|
||||
after: Metadata dictionary from processed file.
|
||||
preserved_keys: Set of property names that should be preserved.
|
||||
|
||||
Returns:
|
||||
ComparisonReport with detailed comparison results.
|
||||
"""
|
||||
if preserved_keys is None:
|
||||
preserved_keys = self.PRESERVED_PROPERTIES
|
||||
|
||||
report = ComparisonReport(
|
||||
original_file = "",
|
||||
processed_file = "",
|
||||
)
|
||||
|
||||
# Get all unique keys from both dictionaries
|
||||
all_keys = set(before.keys()) | set(after.keys())
|
||||
|
||||
for key in sorted(all_keys):
|
||||
before_value = before.get(key)
|
||||
after_value = after.get(key)
|
||||
|
||||
# Determine property status
|
||||
status = self._determine_status(
|
||||
key,
|
||||
before_value,
|
||||
after_value,
|
||||
preserved_keys
|
||||
)
|
||||
|
||||
is_sensitive = key not in preserved_keys
|
||||
|
||||
comparison = PropertyComparison(
|
||||
name = key,
|
||||
before_value = before_value,
|
||||
after_value = after_value,
|
||||
status = status,
|
||||
is_sensitive = is_sensitive,
|
||||
)
|
||||
report.comparisons.append(comparison)
|
||||
|
||||
# Update counts
|
||||
if status == PropertyStatus.REMOVED:
|
||||
report.removed_count += 1
|
||||
elif status == PropertyStatus.PRESERVED:
|
||||
report.preserved_count += 1
|
||||
elif status == PropertyStatus.WARNING:
|
||||
report.warning_count += 1
|
||||
|
||||
# Determine overall status
|
||||
if report.warning_count > 0:
|
||||
report.status = VerificationStatus.WARNING
|
||||
else:
|
||||
report.status = VerificationStatus.CLEAN
|
||||
|
||||
return report
|
||||
|
||||
def _determine_status(
|
||||
self,
|
||||
key: str,
|
||||
before_value: Any,
|
||||
after_value: Any,
|
||||
preserved_keys: set[str],
|
||||
) -> PropertyStatus:
|
||||
"""
|
||||
Determine the status of a property based on before/after values.
|
||||
|
||||
Args:
|
||||
key: Property name.
|
||||
before_value: Value before processing.
|
||||
after_value: Value after processing.
|
||||
preserved_keys: Set of keys that should be preserved.
|
||||
|
||||
Returns:
|
||||
PropertyStatus indicating what happened to this property.
|
||||
"""
|
||||
# Check if this is a preserved property
|
||||
if key in preserved_keys:
|
||||
return PropertyStatus.PRESERVED
|
||||
|
||||
# Check if value was removed
|
||||
before_has_value = self._has_value(before_value)
|
||||
after_has_value = self._has_value(after_value)
|
||||
|
||||
if before_has_value and not after_has_value:
|
||||
return PropertyStatus.REMOVED
|
||||
elif not before_has_value and not after_has_value:
|
||||
return PropertyStatus.UNCHANGED
|
||||
elif before_has_value and after_has_value:
|
||||
# Sensitive property still has value - warning
|
||||
return PropertyStatus.WARNING
|
||||
else:
|
||||
return PropertyStatus.UNCHANGED
|
||||
|
||||
def _has_value(self, value: Any) -> bool:
|
||||
"""
|
||||
Check if a value is considered "present" (not None/empty).
|
||||
|
||||
Args:
|
||||
value: Value to check.
|
||||
|
||||
Returns:
|
||||
True if value is present and meaningful.
|
||||
"""
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str) and value.strip() in ("", "-"):
|
||||
return False
|
||||
if value == 0 and not isinstance(value, bool):
|
||||
# 0 is typically not meaningful for metadata
|
||||
return False
|
||||
return True
|
||||
|
||||
def render_table(self, report: ComparisonReport) -> None:
|
||||
"""
|
||||
Render a comparison report as a rich table.
|
||||
|
||||
Args:
|
||||
report: ComparisonReport to render.
|
||||
"""
|
||||
# Create status emoji mapping
|
||||
status_icons = {
|
||||
PropertyStatus.REMOVED: "[green]✅ Removed[/green]",
|
||||
PropertyStatus.PRESERVED: "[dim]⚪ Preserved[/dim]",
|
||||
PropertyStatus.UNCHANGED: "[dim]— No change[/dim]",
|
||||
PropertyStatus.WARNING: "[yellow]⚠️ Still present[/yellow]",
|
||||
}
|
||||
|
||||
table = Table(
|
||||
title = "[bold]Verification Report[/bold]",
|
||||
show_header = True,
|
||||
header_style = "bold",
|
||||
)
|
||||
|
||||
table.add_column("Property", style = "cyan")
|
||||
table.add_column("Before", style = "dim")
|
||||
table.add_column("After", justify = "left")
|
||||
|
||||
for comp in report.comparisons:
|
||||
before_str = self._format_value(comp.before_value)
|
||||
after_str = status_icons.get(comp.status, str(comp.after_value))
|
||||
|
||||
table.add_row(comp.name, before_str, after_str)
|
||||
|
||||
self.console.print(table)
|
||||
|
||||
# Print summary
|
||||
self._print_summary(report)
|
||||
|
||||
def _format_value(self, value: Any) -> str:
|
||||
"""
|
||||
Format a metadata value for display.
|
||||
|
||||
Args:
|
||||
value: Value to format.
|
||||
|
||||
Returns:
|
||||
Formatted string representation.
|
||||
"""
|
||||
if value is None:
|
||||
return "[dim]None[/dim]"
|
||||
if isinstance(value, str):
|
||||
if len(value) > 30:
|
||||
return value[: 27] + "..."
|
||||
return value if value.strip() else "[dim]-[/dim]"
|
||||
return str(value)
|
||||
|
||||
def _print_summary(self, report: ComparisonReport) -> None:
|
||||
"""
|
||||
Print summary statistics for the report.
|
||||
|
||||
Args:
|
||||
report: ComparisonReport to summarize.
|
||||
"""
|
||||
self.console.print()
|
||||
|
||||
if report.status == VerificationStatus.CLEAN:
|
||||
self.console.print(
|
||||
"[bold green]✅ Status: CLEAN[/bold green] - "
|
||||
"All sensitive metadata removed"
|
||||
)
|
||||
elif report.status == VerificationStatus.WARNING:
|
||||
self.console.print(
|
||||
f"[bold yellow]⚠️ Status: WARNING[/bold yellow] - "
|
||||
f"{report.warning_count} properties may still contain data"
|
||||
)
|
||||
else:
|
||||
self.console.print("[bold red]❌ Status: ERROR[/bold red]")
|
||||
|
||||
self.console.print(
|
||||
f"[dim]Removed: {report.removed_count} | "
|
||||
f"Preserved: {report.preserved_count}[/dim]"
|
||||
)
|
||||
|
||||
def generate_report(
|
||||
self,
|
||||
original_file: str,
|
||||
processed_file: str,
|
||||
before_metadata: dict[str,
|
||||
Any],
|
||||
after_metadata: dict[str,
|
||||
Any],
|
||||
) -> ComparisonReport:
|
||||
"""
|
||||
Generate a full comparison report between two files.
|
||||
|
||||
Args:
|
||||
original_file: Path to original file.
|
||||
processed_file: Path to processed file.
|
||||
before_metadata: Metadata from original file.
|
||||
after_metadata: Metadata from processed file.
|
||||
|
||||
Returns:
|
||||
ComparisonReport with file paths and comparison data.
|
||||
"""
|
||||
report = self.compare(before_metadata, after_metadata)
|
||||
report.original_file = original_file
|
||||
report.processed_file = processed_file
|
||||
return report
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from tests.conftest import (
|
|||
get_xlsx_test_file,
|
||||
)
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
"""
|
||||
End-to-end tests for the verify command.
|
||||
|
||||
Tests the complete CLI workflow for the verify command, including
|
||||
comparing original and processed files and verifying output format.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def output_dir(tmp_path):
|
||||
"""Create a temporary output directory for processed files."""
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
yield output
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
|
||||
|
||||
# ============== Image Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_jpeg_clean_status(output_dir):
|
||||
"""Test verify command shows CLEAN status for properly scrubbed JPEG."""
|
||||
from tests.conftest import get_jpg_test_file
|
||||
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
|
||||
# First scrub the file
|
||||
scrub_result = runner.invoke(
|
||||
app,
|
||||
["scrub",
|
||||
JPG_TEST_FILE,
|
||||
"--output",
|
||||
str(output_dir)]
|
||||
)
|
||||
assert scrub_result.exit_code == 0
|
||||
|
||||
# Get the processed file path
|
||||
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
|
||||
assert processed_file.exists()
|
||||
|
||||
# Now verify
|
||||
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Comparing" in result.stdout
|
||||
assert "CLEAN" in result.stdout or "Removed" in result.stdout
|
||||
|
||||
|
||||
# this test can be ran if a png with metadata is found and added to the assests folder.
|
||||
# def test_verify_command_png_clean_status(output_dir):
|
||||
# """Test verify command shows CLEAN status for properly scrubbed PNG."""
|
||||
# from tests.conftest import get_png_test_file
|
||||
|
||||
# # PNG_TEST_FILE = get_png_test_file()
|
||||
|
||||
# # First scrub the file
|
||||
# scrub_result = runner.invoke(
|
||||
# app, ["scrub", PNG_TEST_FILE, "--output", str(output_dir)]
|
||||
# )
|
||||
|
||||
# # Get the processed file path
|
||||
# processed_file = output_dir / f"processed_{Path(PNG_TEST_FILE).name}"
|
||||
|
||||
# # Skip if scrub failed (PNG might have no metadata)
|
||||
# if scrub_result.exit_code != 0 or not processed_file.exists():
|
||||
# pytest.skip("PNG file had no metadata to scrub")
|
||||
|
||||
# # Now verify
|
||||
# result = runner.invoke(app, ["verify", PNG_TEST_FILE, str(processed_file)])
|
||||
|
||||
# assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
# assert "Comparing" in result.stdout
|
||||
|
||||
|
||||
# ============== PDF Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_pdf_clean_status(output_dir):
|
||||
"""Test verify command shows CLEAN status for properly scrubbed PDF."""
|
||||
from tests.conftest import get_pdf_test_file
|
||||
|
||||
PDF_TEST_FILE = get_pdf_test_file()
|
||||
|
||||
# First scrub the file
|
||||
scrub_result = runner.invoke(
|
||||
app,
|
||||
["scrub",
|
||||
PDF_TEST_FILE,
|
||||
"--output",
|
||||
str(output_dir)]
|
||||
)
|
||||
assert scrub_result.exit_code == 0
|
||||
|
||||
# Get the processed file path
|
||||
processed_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
|
||||
assert processed_file.exists()
|
||||
|
||||
# Now verify
|
||||
result = runner.invoke(app, ["verify", PDF_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Comparing" in result.stdout
|
||||
|
||||
|
||||
# ============== Excel Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_xlsx_clean_status(output_dir):
|
||||
"""Test verify command shows CLEAN status for properly scrubbed Excel."""
|
||||
from tests.conftest import get_xlsx_test_file
|
||||
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
|
||||
# First scrub the file
|
||||
scrub_result = runner.invoke(
|
||||
app,
|
||||
["scrub",
|
||||
XLSX_TEST_FILE,
|
||||
"--output",
|
||||
str(output_dir)]
|
||||
)
|
||||
assert scrub_result.exit_code == 0
|
||||
|
||||
# Get the processed file path
|
||||
processed_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
|
||||
assert processed_file.exists()
|
||||
|
||||
# Now verify
|
||||
result = runner.invoke(app, ["verify", XLSX_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Comparing" in result.stdout
|
||||
|
||||
|
||||
# ============== PowerPoint Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_pptx_clean_status(output_dir):
|
||||
"""Test verify command shows CLEAN status for properly scrubbed PowerPoint."""
|
||||
from tests.conftest import get_pptx_test_file
|
||||
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
|
||||
# First scrub the file
|
||||
scrub_result = runner.invoke(
|
||||
app,
|
||||
["scrub",
|
||||
PPTX_TEST_FILE,
|
||||
"--output",
|
||||
str(output_dir)]
|
||||
)
|
||||
assert scrub_result.exit_code == 0
|
||||
|
||||
# Get the processed file path
|
||||
processed_file = output_dir / f"processed_{Path(PPTX_TEST_FILE).name}"
|
||||
assert processed_file.exists()
|
||||
|
||||
# Now verify
|
||||
result = runner.invoke(app, ["verify", PPTX_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Comparing" in result.stdout
|
||||
|
||||
|
||||
# ============== Word Document Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_docx_clean_status(output_dir):
|
||||
"""Test verify command shows CLEAN status for properly scrubbed Word doc."""
|
||||
from tests.conftest import get_docx_test_file
|
||||
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
|
||||
# First scrub the file
|
||||
scrub_result = runner.invoke(
|
||||
app,
|
||||
["scrub",
|
||||
DOCX_TEST_FILE,
|
||||
"--output",
|
||||
str(output_dir)]
|
||||
)
|
||||
assert scrub_result.exit_code == 0
|
||||
|
||||
# Get the processed file path
|
||||
processed_file = output_dir / f"processed_{Path(DOCX_TEST_FILE).name}"
|
||||
assert processed_file.exists()
|
||||
|
||||
# Now verify
|
||||
result = runner.invoke(app, ["verify", DOCX_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert result.exit_code == 0, f"Failed with: {result.stdout}"
|
||||
assert "Comparing" in result.stdout
|
||||
|
||||
|
||||
# ============== Error Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_original_file_not_found():
|
||||
"""Test verify command handles missing original file."""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["verify",
|
||||
"nonexistent_file.jpg",
|
||||
"also_nonexistent.jpg"]
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_verify_command_processed_file_not_found():
|
||||
"""Test verify command handles missing processed file."""
|
||||
from tests.conftest import get_jpg_test_file
|
||||
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
|
||||
result = runner.invoke(app, ["verify", JPG_TEST_FILE, "nonexistent_processed.jpg"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ============== Output Format Tests ==============
|
||||
|
||||
|
||||
def test_verify_command_shows_removed_count(output_dir):
|
||||
"""Test that verify command shows removed count in output."""
|
||||
from tests.conftest import get_jpg_test_file
|
||||
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
|
||||
# Scrub the file
|
||||
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
|
||||
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
|
||||
|
||||
# Verify
|
||||
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert "Removed:" in result.stdout
|
||||
|
||||
|
||||
def test_verify_command_shows_preserved_count(output_dir):
|
||||
"""Test that verify command shows preserved count in output."""
|
||||
from tests.conftest import get_jpg_test_file
|
||||
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
|
||||
# Scrub the file
|
||||
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
|
||||
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
|
||||
|
||||
# Verify
|
||||
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert "Preserved:" in result.stdout
|
||||
|
||||
|
||||
def test_verify_command_shows_comparison_header(output_dir):
|
||||
"""Test that verify command shows comparison header."""
|
||||
from tests.conftest import get_jpg_test_file
|
||||
|
||||
JPG_TEST_FILE = get_jpg_test_file()
|
||||
|
||||
# Scrub the file
|
||||
runner.invoke(app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir)])
|
||||
processed_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
|
||||
|
||||
# Verify
|
||||
result = runner.invoke(app, ["verify", JPG_TEST_FILE, str(processed_file)])
|
||||
|
||||
assert "Comparing:" in result.stdout
|
||||
assert Path(JPG_TEST_FILE).name in result.stdout
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ def test_save_processed_docx_metadata(docx_file):
|
|||
Test saving processed Word document to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
|
|
@ -97,7 +97,7 @@ def test_output_file_has_wiped_metadata(docx_file):
|
|||
Test that the output file has metadata wiped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Process original file
|
||||
handler = WorddocHandler(docx_file)
|
||||
|
|
|
|||
86
uv.lock
86
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" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue