Add 'PROJECTS/metadata-scrubber-tool/' from commit '250a648855780c5f1a9268f9cffef4c65f82cbbc'

git-subtree-dir: PROJECTS/metadata-scrubber-tool
git-subtree-mainline: c801a0be53
git-subtree-split: 250a648855
This commit is contained in:
HERITAGE-XION 2026-01-10 21:18:10 +01:00
commit 38aa333d3d
80 changed files with 7180 additions and 0 deletions

View File

@ -0,0 +1,38 @@
name: Publish to PyPI
on:
release:
types: [published]
jobs:
# The Safety Check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv python install 3.10
- run: uv sync --all-extras --dev
- run: uv run pytest -x
# The Publisher
# This ONLY runs if 'test' passes successfully.
publish:
needs: test # <--- The Dependency You Requested
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # IMPORTANT: Required for Trusted Publishing
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Build package
run: uv build
- name: Publish to PyPI
# We use the official PyPA action for OIDC security
uses: pypa/gh-action-pypi-publish@release/v1

View File

@ -0,0 +1,29 @@
name: Tests
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"] # Test multiple versions
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Run Tests
run: uv run pytest -x

View File

@ -0,0 +1,52 @@
# ============== Python ==============
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
*.egg
dist/
build/
# ============== Virtual Environment ==============
.venv/
venv/
ENV/
# ============== IDE / Editor ==============
.vscode/
.idea/
*.swp
*.swo
.qodo/
# ============== Testing ==============
.pytest_cache/
.coverage
htmlcov/
coverage.xml
*.cover
# ============== Type Checking ==============
.mypy_cache/
.pyright/
# ============== Linting ==============
.ruff_cache/
# ============== Environment ==============
.env
.env.local
*.log
# ============== OS Generated ==============
.DS_Store
Thumbs.db
# ============== Project Specific ==============
# Output directory for processed images
tests/assets/output/
.yapfignore/

View File

@ -0,0 +1 @@
3.14

View File

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

View File

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

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 HERITAGE-XION
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,256 @@
# 🔒 Metadata Scrubber
A privacy-focused CLI tool that removes sensitive metadata from files. Supports images, PDFs, and Microsoft Office documents. Perfect for protecting your privacy before sharing files online.
[![Tests](https://github.com/Heritage-XioN/metadata-scrubber-tool/actions/workflows/test.yml/badge.svg)](https://github.com/Heritage-XioN/metadata-scrubber-tool/actions)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
## ✨ Features
- **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
## 📁 Supported Formats
| Category | Extensions | Metadata Removed |
|----------|------------|------------------|
| **Images** | `.jpg`, `.jpeg`, `.png` | EXIF, GPS, camera info, timestamps |
| **PDF** | `.pdf` | Author, creator, producer, dates |
| **Word** | `.docx` | Author, title, comments, keywords |
| **Excel** | `.xlsx`, `.xlsm`, `.xltx`, `.xltm` | Author, title, company, comments |
| **PowerPoint** | `.pptx`, `.pptm`, `.potx`, `.potm` | Author, title, comments, keywords |
## 🚀 Quick Start
### Installation
```bash
# Using uv (recommended)
uv pip install metadata-scrubber
# Or clone and install locally
git clone https://github.com/Heritage-XioN/metadata-scrubber-tool.git
cd metadata-scrubber-tool
uv sync
```
### Basic Usage
```bash
# Read metadata from a file
mst read document.pdf
# Scrub metadata and save to output folder
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 without changes
mst scrub ./files -r -ext xlsx -w 8 # 8 concurrent workers
```
**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 (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 (default: 4, max: 16) |
| `-V`, `--verbose` | Show detailed debug logs |
| `-v`, `--version` | Show version |
---
## 🛠️ Development
### Setup
```bash
git clone https://github.com/Heritage-XioN/metadata-scrubber-tool.git
cd metadata-scrubber-tool
# Install with dev dependencies
uv sync --all-extras
# Run tests
pytest
# Run linting
ruff check .
# Run type checking
mypy src
```
### Project Structure
```
src/
├── main.py # CLI entry point (Typer app)
├── commands/
│ ├── 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
│ ├── powerpoint_handler.py # PowerPoint handler
│ ├── 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
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.
---
Made with ❤️ for privacy

View File

@ -0,0 +1,10 @@
version: '3.8'
services:
app:
build: .
container_name: metadata-scrubber
volumes:
- .:/app
env_file:
- .env

View File

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

View File

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

View File

@ -0,0 +1,6 @@
def main():
print("Hello from metadata-scrubber-tool!")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,102 @@
[project]
name = "metadata-scrubber"
version = "0.3.0"
description = "A privacy-focused CLI tool that removes sensitive metadata from image files"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"typer>=0.21.0",
"rich>=14.0.0",
"pillow>=12.0.0",
"piexif>=1.1.3",
"pypdf>=6.5.0",
"openpyxl>=3.1.5",
"python-pptx>=1.0.2",
"python-docx>=1.2.0",
]
[project.optional-dependencies]
dev = []
[project.scripts]
mst = "src.main:app"
metadata-scrubber = "src.main:app"
# ============ THE BUILD SYSTEM ================
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# ============ PACKAGE DISCOVERY =================
[tool.hatch.build.targets.wheel]
packages = ["src"]
# ============== Ruff Configuration ==============
[tool.ruff]
line-length = 88
target-version = "py310"
extend-exclude = ["typings"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "C4"]
ignore = ["E501"] # Line too long (handled by formatter)
# ============== Pytest Configuration ==============
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
# ============== Coverage Configuration ==============
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/__init__.py", "*/tests/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
show_missing = true
# ============== MyPy Configuration ==============
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_ignores = false
ignore_missing_imports = true
# pptx library doesn't have type stubs
[[tool.mypy.overrides]]
module = "pptx.*"
ignore_missing_imports = true
ignore_errors = true
[[tool.mypy.overrides]]
module = "src.services.powerpoint_handler"
ignore_errors = true
# docx library doesn't have type stubs
[[tool.mypy.overrides]]
module = "docx.*"
ignore_missing_imports = true
ignore_errors = true
[[tool.mypy.overrides]]
module = "src.services.worddoc_handler"
ignore_errors = true
[dependency-groups]
dev = [
"mypy>=1.19.1",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.11",
"yapf>=0.43.0",
]

View File

@ -0,0 +1,8 @@
{
"stubPath": "./typings",
"reportMissingTypeStubs": false,
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.12",
"typeCheckingMode": "standard",
}

Binary file not shown.

View File

@ -0,0 +1,30 @@
"""Generate PNG test images with metadata."""
import random
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))
# Add PNG text metadata
pnginfo = PngImagePlugin.PngInfo()
pnginfo.add_text("Author", f"Test Author {i}")
pnginfo.add_text("Description", f"Test image number {i} for metadata scrubbing")
pnginfo.add_text("Software", "Python PIL Test Generator")
pnginfo.add_text("Copyright", f"Copyright 2024 Test {i}")
pnginfo.add_text("Creation Time", f"2024-01-{i % 28 + 1:02d}")
# Save with metadata
filename = dest_dir / f"generated_test_{i:02d}.png"
img.save(filename, pnginfo = pnginfo)
print("Generated 45 PNG images with metadata")
print(f"Total PNG count: {len(list(dest_dir.glob('*.png')))}")

View File

@ -0,0 +1 @@
# Commands module

View File

@ -0,0 +1,62 @@
"""
Read command - Display metadata from files.
This command reads and displays metadata from image files in a
formatted table view. Supports single files or recursive directory processing.
"""
import logging
from pathlib import Path
import typer
from rich.console import Console
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")
# fmt: off
def read(
file_path: Path = typer.Argument(
exists=True, # Must exist on the filesystem
file_okay=True, # Can be a file
dir_okay=True, # Can be a directory
readable=True, # Must be readable (permissions check)
resolve_path=True, # Auto-convert to absolute path
help="The path to the file you want to process",
),
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively process files in the specified directory."),
ext: str = typer.Option(None,"--extension", "-ext", help="The file extension to filter by. eg: jpg, png, pdf"),
):
if recursive and not ext:
raise typer.BadParameter("If you provide --recursive or -r, you must also provide --extension or -ext.")
if ext and not recursive:
raise typer.BadParameter("If you provide --extension or -ext, you must also provide --recursive or -r.")
for file in get_target_files(file_path, ext) if recursive else [file_path]:
try:
# Get the correct object from the factory
handler = MetadataFactory.get_handler(str(file))
# Read
console.print(f"🔎 Reading [bold cyan]{file.name}[/bold cyan]...")
current_data = handler.read()
if log.isEnabledFor(logging.DEBUG):
# if verbose mode is enabled, log the Info
log.info(f"Successfully read metadata from {file.name}")
print_metadata_table(current_data)
except Exception as e:
# display error in console
console.print(f"❌ [bold red]Skipped[/bold red] [cyan]{file.name}[/cyan]: [dim]{e}[/dim]")
# LOG: Full technical details (Stack trace) for you to debug
if log.isEnabledFor(logging.DEBUG):
# if verbose mode is enabled, log the traceback
log.error(f"Failed to read metadata from {file}", exc_info=True)

View File

@ -0,0 +1,150 @@
"""
Scrub command - Remove metadata from files.
This command processes files through the readwipesave pipeline,
removing privacy-sensitive metadata like EXIF, GPS, and author info.
Supports concurrent processing for efficient batch operations on large file sets.
"""
import logging
import os
from pathlib import Path
import typer
from rich.console import Console
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
TimeElapsedColumn,
)
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")
# fmt: off
def scrub(
file_path: Path = typer.Argument(
exists=True,
file_okay=True,
dir_okay=True,
readable=True,
writable=True,
resolve_path=True,
help="The file or directory to process.",
),
recursive: bool = typer.Option(
False, "--recursive", "-r",
help="Recursively process files in the specified directory."
),
ext: str = typer.Option(
None, "--extension", "-ext",
help="File extension to filter by (e.g., jpg, png)."
),
output_dir: str = typer.Option(
"./scrubbed", "--output", "-o",
help="Directory to save processed files."
),
dry_run: bool = typer.Option(
False, "--dry-run", "-d",
help="Preview what would be processed without making changes."
),
workers: int = typer.Option(
min(4, (os.cpu_count() or 1)),
"--workers", "-w",
help="Number of concurrent worker threads (default: 4 or CPU count)."
),
):
# fmt: on
"""
Remove metadata from files.
Scrubs privacy-sensitive metadata (EXIF, GPS, author info) from images.
Works with JPEG, PNG, and will support PDF/Office docs in future.
Examples:
scrub photo.jpg
scrub ./photos/ -r -ext jpg --output ./cleaned
scrub ./folder/ -r -ext png --dry-run
scrub ./large_batch/ -r -ext jpg --workers 8
"""
# Validate recursive/extension combo
if recursive and not ext:
raise typer.BadParameter(
"If you provide --recursive or -r, you must also provide --extension or -ext."
)
if ext and not recursive:
raise typer.BadParameter(
"If you provide --extension or -ext, you must also provide --recursive or -r."
)
# Show dry-run banner
if dry_run:
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]
if not files:
console.print("[yellow]No files found to process.[/yellow]")
raise typer.Exit(0)
# Show worker count for batch operations
if len(files) > 1:
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
)
# 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
) as progress:
task_id = progress.add_task(
"[cyan]Scrubbing metadata...",
total = len(files),
)
def on_file_complete(result):
"""Callback for progress updates from concurrent workers."""
status = "" if result.success else ""
progress.update(
task_id,
description = f"[cyan]{status} {result.filepath.name}",
advance = 1,
)
# Use concurrent batch processing
processor.process_batch(files, progress_callback = on_file_complete)
# Display summary
print_batch_summary(processor.get_summary())

View File

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

View File

@ -0,0 +1,114 @@
"""
JPEG metadata processor using piexif.
This module provides the JpegProcessor class which handles EXIF metadata
extraction and manipulation for JPEG images using the piexif library.
"""
from typing import TypedDict
import piexif
from PIL import Image
from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError
# Type alias for EXIF tag values (strings, bytes, ints, tuples, or lists)
ExifValue = str | bytes | int | tuple[object, ...] | list[object]
class JpegMetadataResult(TypedDict):
"""Return type for JpegProcessor.get_metadata()."""
data: dict[str, ExifValue]
tags_to_delete: list[int]
class JpegProcessor:
"""
Processor for JPEG image metadata.
Handles reading, extracting, and deleting EXIF metadata from JPEG files.
Preserves essential tags (Orientation, ColorSpace) needed for proper display.
Attributes:
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] = []
self.data: dict[str, ExifValue] = {}
def get_metadata(self, img: Image.Image) -> JpegMetadataResult:
"""
Extract EXIF metadata from a JPEG image.
Args:
img: PIL Image object with EXIF data.
Returns:
Dict with 'data' (metadata dict) and 'tags_to_delete' (tag IDs list).
Raises:
MetadataNotFoundError: If no EXIF data is found in the image.
"""
if "exif" not in img.info:
raise MetadataNotFoundError("No EXIF data found in the image.")
exif_dict = piexif.load(img.info["exif"])
for ifd, value in exif_dict.items():
# Exclude thumbnail IFD (blob data that slows loading if removed)
if not isinstance(exif_dict[ifd], dict):
continue
# Iterate through the IFD
for tag, tag_value in exif_dict[ifd].items():
ifd_tags = piexif.TAGS.get(ifd, {})
tag_info = ifd_tags.get(tag, {}) if isinstance(ifd_tags, dict) else {}
# Get the human-readable name for the tag
tag_name = str(
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"):
continue
# Save to list and dict
self.tags_to_delete.append(tag)
self.data[tag_name] = tag_value
return {"data": self.data, "tags_to_delete": self.tags_to_delete}
def delete_metadata(self, img: Image.Image, tags_to_delete: list[int]):
"""
Remove specified EXIF tags from a JPEG image.
Args:
img: PIL Image object with EXIF data.
tags_to_delete: List of tag IDs to remove.
Returns:
Modified EXIF dictionary with specified tags removed.
Raises:
MetadataProcessingError: If an error occurs during processing.
"""
try:
exif_dict = piexif.load(img.info["exif"])
for ifd, value in exif_dict.items():
# Exclude thumbnail IFD
if not isinstance(exif_dict[ifd], dict):
continue
# Iterate through and delete tags
for tag in list(exif_dict[ifd]):
if tag in tags_to_delete:
del exif_dict[ifd][tag]
return exif_dict
except Exception as e:
raise MetadataProcessingError(f"Error Processing: {str(e)}")

View File

@ -0,0 +1,183 @@
"""
PNG metadata processor using PIL.
This module provides the PngProcessor class which handles metadata
extraction and manipulation for PNG images, including both EXIF data
and PNG textual metadata (PngInfo chunks).
"""
from typing import Any
from PIL import ExifTags, PngImagePlugin
from PIL.Image import Exif, Image
from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError
class PngProcessor:
"""
Processor for PNG image metadata.
Handles reading, extracting, and deleting metadata from PNG files.
Processes both EXIF data and PNG textual chunks (PngInfo).
Attributes:
tags_to_delete: List of EXIF tag IDs to remove.
text_keys_to_delete: List of PngInfo text keys to remove.
data: Dict of extracted metadata with human-readable keys.
"""
# Privacy-sensitive PNG text keys to remove
SENSITIVE_TEXT_KEYS = {
"Author",
"Comment",
"Copyright",
"Creation Time",
"Description",
"Disclaimer",
"Software",
"Source",
"Title",
"Warning",
"XML:com.adobe.xmp", # XMP metadata
}
def __init__(self):
"""Initialize the PNG processor with empty data structures."""
self.tags_to_delete: list[int] = []
self.text_keys_to_delete: list[str] = []
self.data: dict[str, Any] = {}
def get_metadata(self, img: Image) -> dict[str, Any]:
"""
Extract metadata from a PNG image.
Extracts both EXIF data (if present) and PNG textual chunks (PngInfo).
Args:
img: PIL Image object.
Returns:
Dict with 'data' (metadata dict), 'tags_to_delete' (EXIF tag IDs),
and 'text_keys' (PngInfo keys to remove).
Raises:
MetadataNotFoundError: If no metadata is found in the image.
"""
img.load()
found_metadata = False
# Extract EXIF data (if present)
exif = img.getexif()
if exif:
found_metadata = True
# Main IFD
for tag, value in exif.items():
tag_name = ExifTags.TAGS.get(tag, f"Tag_{tag}")
self.tags_to_delete.append(tag)
self.data[f"EXIF:{tag_name}"] = value
# GPS IFD
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
for tag, value in gps_ifd.items():
tag_name = ExifTags.GPSTAGS.get(tag, f"GPSTag_{tag}")
self.tags_to_delete.append(tag)
self.data[f"GPS:{tag_name}"] = value
# Extract PNG textual metadata (PngInfo chunks)
if hasattr(img, "info") and img.info:
for key, value in img.info.items():
# Skip binary/internal data
if key in ("icc_profile", "exif", "transparency", "gamma"):
continue
if isinstance(value, (str, bytes)):
found_metadata = True
if isinstance(value, bytes):
try:
value = value.decode("utf-8", errors = "replace")
except Exception:
value = str(value)
self.data[f"PNG:{key}"] = value
if isinstance(key, str): # Only add string keys
self.text_keys_to_delete.append(key)
if not found_metadata:
raise MetadataNotFoundError("No metadata found in the PNG image.")
return {
"data": self.data,
"tags_to_delete": self.tags_to_delete,
"text_keys": self.text_keys_to_delete,
}
def delete_metadata(self, img: Image, tags_to_delete: list[int]) -> Exif:
"""
Remove EXIF tags from a PNG image.
Args:
img: PIL Image object.
tags_to_delete: List of EXIF tag IDs to remove.
Returns:
Mutated Exif object with specified tags removed.
Raises:
MetadataProcessingError: If an error occurs during processing.
"""
img.load()
exif = img.getexif()
try:
# Delete tags from main IFD (iterate over copy of keys)
for tag_id in list(exif.keys()):
if tag_id in tags_to_delete:
del exif[tag_id]
# Clear GPS IFD entirely (privacy-sensitive)
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
gps_ifd.clear()
return exif
except Exception as e:
raise MetadataProcessingError(f"Error processing PNG EXIF: {str(e)}")
def get_clean_pnginfo(
self,
img: Image,
keys_to_remove: list[str] | None = None
) -> PngImagePlugin.PngInfo | None:
"""
Create a new PngInfo with sensitive keys removed.
Args:
img: PIL Image object.
keys_to_remove: Specific keys to remove. If None, removes all sensitive keys.
Returns:
New PngInfo object with only safe metadata, or None if no safe metadata.
"""
if not hasattr(img, "info") or not img.info:
return None
keys_to_remove = keys_to_remove or list(self.text_keys_to_delete)
# Create new PngInfo with only safe keys
pnginfo = PngImagePlugin.PngInfo()
has_safe_data = False
for key, value in img.info.items():
# Skip keys to remove
if key in keys_to_remove:
continue
# Skip binary/internal data
if key in ("icc_profile", "exif", "transparency", "gamma"):
continue
# Only include text data with string keys
if isinstance(key, str) and isinstance(value, str):
pnginfo.add_text(key, value)
has_safe_data = True
return pnginfo if has_safe_data else None

View File

@ -0,0 +1,79 @@
"""
Metadata Scrubber Tool - CLI Application Entry Point.
This module serves as the main entry point for the CLI application.
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
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)
log = logging.getLogger("metadata-scrubber")
__version__ = "0.3.0"
# ---------------------------------------------------------
# VERSION CALLBACK
# ---------------------------------------------------------
def version_callback(value: bool):
"""
Prints the version and exits.
"""
if value:
print(f"Version: {__version__}")
raise typer.Exit()
# ---------------------------------------------------------
# MAIN ENTRY POINT
# ---------------------------------------------------------
# fmt: off
@app.callback()
def main(
verbose: bool = typer.Option(
False,
"--verbose",
"-V",
help="Show detailed debug logs for every file processed.",
),
version: bool = typer.Option(
None,
"--version",
"-v",
callback=version_callback,
is_eager=True,
help="Show the application version and exit."
),
):
"""
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)
if verbose:
log.debug("🐛 Verbose mode enabled. Detailed logs active.")
# fmt: on
# register commands
app.command(name = "read")(read)
app.command(name = "scrub")(scrub)
app.command(name = "verify")(verify)
# run app
if __name__ == "__main__":
app()

View File

@ -0,0 +1,290 @@
"""
Batch processing service for metadata operations.
This module provides handler-agnostic batch processing that works with any
MetadataHandler subclass (images now, PDF/Office docs in future).
Supports concurrent processing via ThreadPoolExecutor for efficient handling
of large batches (1000+ files).
"""
import logging
import threading
from collections.abc import Callable, Iterable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from rich.console import Console
from src.services.metadata_factory import MetadataFactory
log = logging.getLogger("metadata-scrubber")
console = Console()
@dataclass
class FileResult:
"""Result of processing a single file."""
filepath: Path
success: bool
action: str # "scrubbed", "skipped", "dry-run"
output_path: Path | None = None
error: str | None = None
@dataclass
class BatchSummary:
"""Aggregated statistics for batch processing."""
total: int = 0
success: int = 0
skipped: int = 0
failed: int = 0
dry_run: bool = False
output_dir: Path | None = None
results: list[FileResult] = field(default_factory = list)
class BatchProcessor:
"""
Handler-agnostic batch processor for metadata operations.
Works with any MetadataHandler subclass via MetadataFactory.
Supports dry-run mode, automatic duplicate suffix handling,
and concurrent processing via ThreadPoolExecutor.
"""
def __init__(
self,
output_dir: str | None = None,
dry_run: bool = False,
max_workers: int = 4,
):
"""
Initialize the batch processor.
Args:
output_dir: Directory to save processed files. Defaults to "./scrubbed".
dry_run: If True, preview what would be processed without writing files.
max_workers: Maximum number of concurrent worker threads. Defaults to 4.
"""
self.output_dir = Path(output_dir) if output_dir else Path("./scrubbed")
self.dry_run = dry_run
self.max_workers = max_workers
self.results: list[FileResult] = []
# Thread synchronization
self._path_lock = threading.Lock() # Protects unique path generation
self._results_lock = threading.Lock() # Protects results list
def process_file(self, file: Path) -> FileResult:
"""
Process a single file through the readwipesave pipeline.
Uses MetadataFactory to get the appropriate handler, so this method
automatically works with any file type that has a registered handler.
Args:
file: Path to the file to process.
Returns:
FileResult with success status and details.
"""
output_path: Path | None = None # Track reserved path for cleanup
try:
# Dry-run mode: just report what would happen
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)
result = FileResult(
filepath = file,
success = True,
action = "dry-run",
output_path = output_path,
)
self._append_result(result)
log.debug(f"[DRY-RUN] Would process: {file}")
return result
# Get handler from factory
handler = MetadataFactory.get_handler(str(file))
# Execute the read → wipe → save pipeline
handler.read()
handler.wipe()
output_path = self._get_unique_output_path(file)
handler.save(str(output_path))
result = FileResult(
filepath = file,
success = True,
action = "scrubbed",
output_path = output_path,
)
self._append_result(result)
if log.isEnabledFor(logging.DEBUG):
# if verbose mode is enabled, log the Info
log.info(f"✅ Scrubbed: {file.name}{output_path}")
return result
except Exception as e:
# Cleanup: remove empty placeholder file if reservation failed
self._cleanup_reserved_path(output_path)
result = FileResult(
filepath = file,
success = False,
action = "skipped",
error = str(e),
)
self._append_result(result)
if log.isEnabledFor(logging.DEBUG):
# if verbose mode is enabled, log the traceback
log.warning(f"⚠️ Skipped {file.name}: {e}")
return result
def process_batch(
self,
files: Iterable[Path],
progress_callback: Callable[[FileResult],
None] | None = None,
) -> list[FileResult]:
"""
Process multiple files concurrently using ThreadPoolExecutor.
Args:
files: Iterable of file paths to process.
progress_callback: Optional callback called after each file completes.
Receives the FileResult for progress updates.
Returns:
List of FileResult objects for all processed files.
"""
file_list = list(files)
if not file_list:
return self.results
# Used ThreadPoolExecutor for I/O-bound concurrent processing
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
}
# Collect results as they complete
for future in as_completed(future_to_file):
result = future.result()
if progress_callback:
progress_callback(result)
return self.results
def process_batch_sequential(self, files: Iterable[Path]) -> list[FileResult]:
"""
Process files sequentially (legacy behavior for debugging).
Args:
files: Iterable of file paths to process.
Returns:
List of FileResult objects for all processed files.
"""
for file in files:
self.process_file(file)
return self.results
def get_summary(self) -> BatchSummary:
"""
Return aggregated statistics for all processed files.
Returns:
BatchSummary with counts and result details.
"""
with self._results_lock:
results_copy = list(self.results)
summary = BatchSummary(
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,
)
# Count dry-run as separate from success for clarity
if self.dry_run:
summary.success = sum(1 for r in results_copy if r.success)
return summary
def _append_result(self, result: FileResult) -> None:
"""Thread-safe append to results list."""
with self._results_lock:
self.results.append(result)
def _cleanup_reserved_path(self, output_path: Path | None) -> None:
"""
Remove empty placeholder file created during path reservation.
Called when processing fails after _get_unique_output_path() reserved
a path via touch(). Only removes files that are empty (0 bytes) to
avoid deleting partially written data.
Args:
output_path: Path that was reserved, or None if not yet reserved.
"""
if output_path is None:
return
try:
if output_path.exists() and output_path.stat().st_size == 0:
output_path.unlink()
log.debug(f"Cleaned up empty placeholder: {output_path}")
except OSError:
# Best effort cleanup - don't fail if we can't delete
pass
def _get_unique_output_path(self, file: Path, reserve: bool = True) -> Path:
"""
Generate unique output path with suffix (_1, _2) if file exists.
Thread-safe: uses lock to prevent race conditions during concurrent processing.
Args:
file: Original file path.
reserve: If True, create placeholder file to reserve the path.
Set to False for dry-run mode.
Returns:
Unique path in output directory that doesn't conflict with existing files.
"""
with self._path_lock:
# creates the destination directory if it doesn't exist
self.output_dir.mkdir(parents = True, exist_ok = True)
base_name = file.stem
extension = file.suffix
output_path = self.output_dir / f"processed_{base_name}{extension}"
# If file exists, add incrementing suffix
counter = 1
while output_path.exists():
output_path = (
self.output_dir / f"processed_{base_name}_{counter}{extension}"
)
counter += 1
# Create empty placeholder to reserve the path (skip in dry-run)
if reserve:
output_path.touch()
return output_path

View File

@ -0,0 +1,165 @@
"""
Excel metadata handler for Excel files.
This module provides the ExcelHandler class which implements the MetadataHandler
interface for Excel files (.xlsx, .xlsm, .xltx, .xltm). Uses openpyxl for
reading and writing Excel workbook properties.
Note:
Does not support password-protected/encrypted workbooks.
"""
import shutil
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from src.services.metadata_handler import MetadataHandler
from src.utils.exceptions import (
MetadataNotFoundError,
MetadataReadingError,
UnsupportedFormatError,
)
# Supported Excel formats
FORMAT_MAP = {
"xlsx": "xlsx",
"xlsm": "xlsm",
"xltx": "xltx",
"xltm": "xltm",
}
# Properties to preserve (not deleted during wipe)
PRESERVED_PROPERTIES = {"created", "modified", "language"}
class ExcelHandler(MetadataHandler):
"""
Excel metadata handler for Excel files.
Handles extraction and removal of document properties from Excel workbooks
including author, title, subject, keywords, and other core properties.
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the Excel handler.
Args:
filepath: Path to the Excel file to process.
"""
super().__init__(filepath)
self.keys_to_delete: list[str] = []
def _detect_format(self) -> str:
"""
Detect Excel format from file extension.
Returns:
Normalized format string ('xlsx', 'xlsm', 'xltx', or 'xltm').
Raises:
UnsupportedFormatError: If file extension is not a supported Excel format.
"""
ext = Path(self.filepath).suffix.lower()
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")
return normalised
def read(self) -> dict[str, Any]:
"""
Extract metadata properties from the Excel workbook.
Reads all document properties from the workbook and identifies
which properties should be wiped (excludes created, modified, language).
Returns:
Dictionary of property names to their values.
Raises:
MetadataReadingError: If the workbook is password-protected.
MetadataNotFoundError: If no properties are found.
"""
self.metadata.clear()
self.keys_to_delete.clear()
wb = load_workbook(Path(self.filepath))
try:
if wb.security.workbookPassword is not None:
raise MetadataReadingError("File is encrypted.")
if wb.properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
for attr, value in vars(wb.properties).items():
self.metadata[attr] = value
if attr not in PRESERVED_PROPERTIES:
self.keys_to_delete.append(attr)
return self.metadata
finally:
wb.close()
def wipe(self) -> None:
"""
Remove metadata properties from the Excel workbook.
Clears all properties identified during read() except for
preserved properties (created, modified, language).
Raises:
MetadataNotFoundError: If no properties are found.
"""
self.processed_metadata.clear()
wb = load_workbook(Path(self.filepath))
try:
if wb.properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
# Clear each property marked for deletion
for attr in self.keys_to_delete:
if hasattr(wb.properties, attr):
setattr(wb.properties, attr, None)
self.processed_metadata = wb.properties
finally:
wb.close()
def save(self, output_path: str | None) -> None:
"""
Save the workbook with cleaned metadata to the output path.
Creates a copy of the original file and applies the wiped
metadata properties to it.
Args:
output_path: Path where the cleaned file should be saved.
Raises:
ValueError: If output_path is None or empty.
"""
if not output_path:
raise ValueError("output_path is required")
destination_file_path = Path(output_path)
shutil.copy2(self.filepath, destination_file_path)
# Use keep_vba=True for macro-enabled workbooks
detected_format = self._detect_format()
if detected_format == "xlsm":
wb = load_workbook(destination_file_path, keep_vba = True)
else:
wb = load_workbook(destination_file_path)
try:
# Apply wiped properties
for attr, value in vars(self.processed_metadata).items():
setattr(wb.properties, attr, value)
wb.save(destination_file_path)
finally:
wb.close()

View File

@ -0,0 +1,181 @@
"""
Image metadata handler for JPEG and PNG files.
This module provides the ImageHandler class which implements the MetadataHandler
interface for image files. It delegates the actual metadata operations to
format-specific processors (JpegProcessor, PngProcessor).
"""
import shutil
from pathlib import Path
from typing import Any, cast
import piexif # pyright: ignore[reportMissingTypeStubs]
from PIL import Image
from src.core.jpeg_metadata import JpegProcessor
from src.core.png_metadata import PngProcessor
from src.services.metadata_handler import MetadataHandler
from src.utils.exceptions import UnsupportedFormatError
# Map Pillow format names to processor keys
FORMAT_MAP = {
"jpeg": "jpeg",
"jpg": "jpeg",
"png": "png",
}
class ImageHandler(MetadataHandler):
"""
Metadata handler for image files (JPEG, PNG).
Implements the MetadataHandler interface using format-specific processors
to read, wipe, and save image metadata. Uses Pillow's format detection
to handle files with incorrect extensions.
Attributes:
processors: Dict mapping format names to processor instances.
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.
Args:
filepath: Path to the image file to process.
"""
super().__init__(filepath)
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] = []
def _detect_format(self) -> str:
"""
Detect actual image format using Pillow, not file extension.
This protects against misnamed files (e.g., a PNG saved as .jpg).
Returns:
Normalized format string ('jpeg' or 'png').
Raises:
UnsupportedFormatError: If format is not supported or undetectable.
"""
with Image.open(Path(self.filepath)) as img:
if img.format is None:
raise UnsupportedFormatError(
f"Could not detect format for: {self.filepath}"
)
pillow_format = img.format.lower()
normalized = FORMAT_MAP.get(pillow_format)
if normalized is None:
raise UnsupportedFormatError(
f"Unsupported format: {pillow_format} (file: {self.filepath})"
)
return normalized
def read(self):
"""
Extract metadata from the file.
Uses actual format detection to select the appropriate processor.
"""
self.metadata.clear()
self.text_keys_to_delete.clear()
self.tags_to_delete.clear()
self.detected_format = self._detect_format()
processor = self.processors.get(self.detected_format)
if not processor:
raise UnsupportedFormatError(f"Unsupported format: {self.detected_format}")
with Image.open(Path(self.filepath)) as img:
result = processor.get_metadata(img)
self.metadata = result["data"]
self.tags_to_delete = result["tags_to_delete"]
# Store text keys for PNG processing
if isinstance(result, PngProcessor):
self.text_keys_to_delete = result.get("text_keys", [])
return self.metadata
def wipe(self) -> None:
"""
Remove privacy-sensitive metadata from the file.
Uses actual format detection to select the appropriate processor.
"""
self.processed_metadata.clear()
self.clean_pnginfo = None
# Use cached format if available, otherwise detect
if not self.detected_format:
self.detected_format = self._detect_format()
processor = self.processors.get(self.detected_format)
if not processor:
raise UnsupportedFormatError(f"Unsupported format: {self.detected_format}")
with Image.open(Path(self.filepath)) as img:
self.processed_metadata = cast(
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
)
def save(self, output_path: str | Path | None = None) -> None:
"""
Writes the changes to a copy of the original file.
Handles format-specific saving:
- JPEG: Uses piexif to write cleaned EXIF data
- PNG: Saves without EXIF and strips textual metadata
Args:
output_path: Full path to the destination file.
"""
destination_file_path = Path(output_path) if output_path else None
if not destination_file_path:
raise ValueError("output_path is required")
# Use detected format (falls back to extension if not detected)
actual_format = self.detected_format or self._detect_format()
if actual_format == "jpeg":
# JPEG: Copy then write cleaned EXIF data
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)
elif actual_format == "png":
# PNG: Open original, save fresh copy without metadata
with Image.open(Path(self.filepath)) as img:
# Save without exif and without pnginfo to strip all metadata
# Preserve image mode and data integrity
img.save(
destination_file_path,
format = "PNG",
exif = None,
pnginfo = getattr(self,
"clean_pnginfo",
None),
)

View File

@ -0,0 +1,66 @@
"""
Factory for creating metadata handlers.
This module provides the MetadataFactory class which uses the factory pattern
to create appropriate handler instances based on file type. It enables
extensibility for supporting new file formats (PDF, Office docs, etc.).
"""
from pathlib import Path
from src.services.excel_handler import ExcelHandler
from src.services.image_handler import ImageHandler
from src.services.pdf_handler import PDFHandler
from src.services.powerpoint_handler import PowerpointHandler
from src.services.worddoc_handler import WorddocHandler
from src.utils.exceptions import UnsupportedFormatError
class MetadataFactory:
"""
Factory class for creating metadata handlers.
Uses the factory pattern to return the appropriate handler instance
based on file extension. This design allows easy extension to support
new file types without modifying existing code.
Supported formats:
- Images: .jpg, .jpeg, .png
- Future: .pdf, .docx, .xlsx, .pptx
"""
@staticmethod
def get_handler(filepath: str):
"""
Create and return the appropriate metadata handler for a file.
Args:
filepath: Path to the file to process.
Returns:
MetadataHandler: An instance of the appropriate handler subclass.
Raises:
UnsupportedFormatError: If no handler is defined for the file type.
ValueError: If the path is not a valid file.
"""
supported_extensions = ".jpg, .jpeg, .png, .pdf, .docx, .xlsx, .xlsm, .xltx, .xltm, .pptx, .pptm, .potx, .potm"
ext = Path(filepath).suffix.lower()
if Path(filepath).is_file():
if ext in [".jpg", ".jpeg", ".png"]:
return ImageHandler(filepath)
elif ext == ".pdf":
return PDFHandler(filepath)
elif ext in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
return ExcelHandler(filepath)
elif ext in [".pptx", ".pptm", ".potx", ".potm"]:
return PowerpointHandler(filepath)
elif ext == ".docx":
return WorddocHandler(filepath)
else:
raise UnsupportedFormatError(
f"No handler defined for {ext} files. we curently only support {supported_extensions} files."
)
else:
raise ValueError(
f"{filepath} is not a file. if you want to process a directory, use the --recursive or -r flag."
)

View File

@ -0,0 +1,71 @@
"""
Abstract base class for metadata handlers.
This module defines the MetadataHandler ABC which establishes the interface
for all metadata handlers. Concrete implementations (ImageHandler, PDFHandler, etc.)
must implement the read, wipe, and save methods.
"""
from abc import ABC, abstractmethod
from typing import Any
class MetadataHandler(ABC):
"""
Abstract base class for all metadata handlers.
Defines the common interface for reading, modifying, and saving
metadata across different file types. All concrete handlers must
implement the abstract methods.
Attributes:
filepath: Path to the file being processed.
metadata: Dictionary containing the extracted metadata.
processed_metadata: Dictionary containing metadata after processing.
"""
def __init__(self, filepath: str):
"""
Initialize the metadata handler.
Args:
filepath: Path to the file to process.
"""
self.filepath = filepath
self.metadata: dict[str, Any] = {}
self.processed_metadata: dict[str, Any] = {}
@abstractmethod
def read(self) -> dict[str, Any]:
"""
Extract metadata from the file.
Returns:
Dict containing the extracted metadata with human-readable keys.
Raises:
MetadataNotFoundError: If no metadata is found in the file.
"""
pass
@abstractmethod
def wipe(self) -> None:
"""
Remove privacy-sensitive metadata from the file.
Updates self.processed_metadata with the cleaned metadata state.
Raises:
MetadataProcessingError: If an error occurs during processing.
"""
pass
@abstractmethod
def save(self, output_path: str) -> None:
"""
Save the processed file with cleaned metadata.
Args:
output_path: Destination path for the processed file.
If None, uses a default location.
"""
pass

View File

@ -0,0 +1,134 @@
"""
PDF metadata handler for PDF files.
This module provides the PDFHandler class which implements the MetadataHandler
interface for PDF files. Uses pypdf library for reading and writing PDF
document information dictionary.
Note:
Does not support encrypted/password-protected PDF files.
"""
import shutil
from pathlib import Path
from typing import Any
from pypdf import PdfReader, PdfWriter
from src.services.metadata_handler import MetadataHandler
from src.utils.exceptions import (
MetadataNotFoundError,
MetadataReadingError,
UnsupportedFormatError,
)
class PDFHandler(MetadataHandler):
"""
PDF metadata handler for PDF files.
Handles extraction and removal of document information from PDF files
including author, creator, title, subject, and other standard PDF metadata.
Attributes:
keys_to_delete: List of metadata keys to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the PDF handler.
Args:
filepath: Path to the PDF file to process.
"""
super().__init__(filepath)
self.keys_to_delete: list[str] = []
def _detect_format(self) -> str:
"""
Validate that the file has a PDF extension.
Returns:
Normalized format string ('pdf').
Raises:
UnsupportedFormatError: If file extension is not .pdf.
"""
ext = Path(self.filepath).suffix.lower()
if ext != ".pdf":
raise UnsupportedFormatError(f"Unsupported format: {ext}")
return ext[1 :] # Return 'pdf' without the dot
def read(self) -> dict[str, Any]:
"""
Extract metadata from the PDF document information dictionary.
Returns:
Dictionary of metadata keys to their values.
Raises:
MetadataReadingError: If the PDF is encrypted/password-protected.
MetadataNotFoundError: If no metadata is found in the PDF.
"""
self.metadata.clear()
self.keys_to_delete.clear()
with PdfReader(Path(self.filepath)) as reader:
if reader.is_encrypted:
raise MetadataReadingError("File is encrypted.")
if reader.metadata is None:
raise MetadataNotFoundError("No metadata found in the file.")
for key, value in reader.metadata.items():
self.metadata[key] = value
self.keys_to_delete.append(key)
return self.metadata
def wipe(self) -> None:
"""
Remove metadata entries from the PDF document.
Clears all metadata keys identified during read().
Raises:
MetadataNotFoundError: If no metadata is found in the PDF.
"""
self.processed_metadata.clear()
with PdfReader(Path(self.filepath)) as reader:
metadata = reader.metadata
if metadata is None:
raise MetadataNotFoundError("No metadata found in the file.")
for key in list(metadata):
if key in self.keys_to_delete:
del metadata[key]
self.processed_metadata = metadata
def save(self, output_path: str | None) -> None:
"""
Save the PDF with cleaned metadata to the output path.
Creates a copy of the original file and rebuilds the PDF
with the wiped metadata.
Args:
output_path: Path where the cleaned PDF should be saved.
Raises:
ValueError: If output_path is None or empty.
"""
if not output_path:
raise ValueError("output_path is required")
destination_file_path = Path(output_path)
shutil.copy2(self.filepath, destination_file_path)
with PdfReader(destination_file_path) as reader, PdfWriter() as writer:
# Copy all pages
for page in reader.pages:
writer.add_page(page)
writer.add_metadata(self.processed_metadata)
writer.write(destination_file_path)

View File

@ -0,0 +1,180 @@
"""
PowerPoint metadata handler for presentation files.
This module provides the PowerpointHandler class which implements the MetadataHandler
interface for PowerPoint presentation files (.pptx, .pptm, .potx, .potm). Uses python-pptx
for reading and writing presentation properties.
Note:
Does not support password-protected/encrypted presentations.
"""
import shutil
from pathlib import Path
from typing import Any
from pptx import Presentation
from src.services.metadata_handler import MetadataHandler
from src.utils.exceptions import (
MetadataNotFoundError,
MetadataProcessingError,
MetadataReadingError,
UnsupportedFormatError,
)
# Supported PowerPoint formats
FORMAT_MAP = {
"pptx": "pptx",
"pptm": "pptm",
"potx": "potx",
"potm": "potm",
}
# Properties to preserve (not deleted during wipe)
PRESERVED_PROPERTIES = {"created", "modified", "language", "last_printed", "revision"}
# Core properties available in PowerPoint presentations
CORE_PROPERTIES = [
"author",
"category",
"comments",
"content_status",
"created",
"identifier",
"keywords",
"language",
"last_modified_by",
"last_printed",
"modified",
"revision",
"subject",
"title",
"version",
]
class PowerpointHandler(MetadataHandler):
"""
PowerPoint metadata handler for presentation files.
Handles extraction and removal of document properties from PowerPoint presentations
including author, title, subject, keywords, and other core properties.
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the PowerPoint handler.
Args:
filepath: Path to the PowerPoint file to process.
"""
super().__init__(filepath)
self.keys_to_delete: list[str] = []
def _detect_format(self) -> str:
"""
Detect PowerPoint format from file extension.
Returns:
Normalized format string ('pptx', 'pptm', 'potx', or 'potm').
Raises:
UnsupportedFormatError: If file extension is not a supported PowerPoint format.
"""
ext = Path(self.filepath).suffix.lower()
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")
return normalised
def read(self) -> dict[str, Any]:
"""
Extract metadata properties from the PowerPoint presentation.
Reads all document properties from the presentation and identifies
which properties should be wiped (excludes created, modified, language, etc.).
Returns:
Dictionary of property names to their values.
Raises:
MetadataNotFoundError: If no properties are found.
"""
self.metadata.clear()
self.keys_to_delete.clear()
prs = Presentation(str(Path(self.filepath)))
try:
if prs.core_properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
for attr in CORE_PROPERTIES:
if hasattr(prs.core_properties, attr):
self.metadata[attr] = getattr(prs.core_properties, attr)
if attr not in PRESERVED_PROPERTIES:
self.keys_to_delete.append(attr)
return self.metadata
except Exception as e:
raise MetadataReadingError(f"error reading metadata. {e}")
finally:
del prs
def wipe(self) -> None:
"""
Remove metadata properties from the PowerPoint presentation.
Clears all properties identified during read() except for
preserved properties (created, modified, language, etc.).
Raises:
MetadataNotFoundError: If no properties are found.
"""
self.processed_metadata.clear()
prs = Presentation(str(Path(self.filepath)))
try:
if prs.core_properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
# Clear each property marked for deletion
for attr in self.keys_to_delete:
self.processed_metadata[attr] = None
except Exception as e:
raise MetadataProcessingError(f"error processing metadata. {e}")
finally:
del prs
def save(self, output_path: str | None = None) -> None:
"""
Save the presentation with cleaned metadata to the output path.
Creates a copy of the original file and applies the wiped
metadata properties to it.
Args:
output_path: Path where the cleaned file should be saved.
Raises:
ValueError: If output_path is None or empty.
"""
if not output_path:
raise ValueError("output_path is required")
destination_file_path = Path(output_path)
shutil.copy2(self.filepath, destination_file_path)
prs = Presentation(str(Path(destination_file_path)))
try:
# Apply wiped properties
for attr in self.processed_metadata:
if hasattr(prs.core_properties, attr):
setattr(prs.core_properties, attr, self.processed_metadata[attr])
prs.save(str(destination_file_path))
except Exception as e:
raise MetadataProcessingError(f"error processing metadata. {e}")
finally:
del prs

View File

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

View File

@ -0,0 +1,177 @@
"""
Word document metadata handler for .docx files.
This module provides the WorddocHandler class which implements the MetadataHandler
interface for Word document files (.docx). Uses python-docx for reading and writing
document properties.
Note:
Does not support password-protected/encrypted documents.
"""
import shutil
from pathlib import Path
from typing import Any
from docx import Document # type: ignore[import-untyped]
from src.services.metadata_handler import MetadataHandler
from src.utils.exceptions import (
MetadataNotFoundError,
MetadataProcessingError,
MetadataReadingError,
UnsupportedFormatError,
)
# Supported Word document formats
FORMAT_MAP = {
"docx": "docx",
}
# Properties to preserve (not deleted during wipe)
PRESERVED_PROPERTIES = {"created", "modified", "language", "last_printed", "revision"}
# Core properties available in Word documents
CORE_PROPERTIES = [
"author",
"category",
"comments",
"content_status",
"created",
"identifier",
"keywords",
"language",
"last_modified_by",
"last_printed",
"modified",
"revision",
"subject",
"title",
"version",
]
class WorddocHandler(MetadataHandler):
"""
Word document metadata handler for .docx files.
Handles extraction and removal of document properties from Word documents
including author, title, subject, keywords, and other core properties.
Attributes:
keys_to_delete: List of property names to be wiped.
"""
def __init__(self, filepath: str):
"""
Initialize the Word document handler.
Args:
filepath: Path to the Word document file to process.
"""
super().__init__(filepath)
self.keys_to_delete: list[str] = []
def _detect_format(self) -> str:
"""
Detect Word document format from file extension.
Returns:
Normalized format string ('docx').
Raises:
UnsupportedFormatError: If file extension is not a supported Word format.
"""
ext = Path(self.filepath).suffix.lower()
normalised = FORMAT_MAP.get(ext[1 :]) # Remove leading dot
if normalised is None:
raise UnsupportedFormatError(f"Unsupported format: {ext}")
return normalised
def read(self) -> dict[str, Any]:
"""
Extract metadata properties from the Word document.
Reads all document properties and identifies which properties
should be wiped (excludes created, modified, language, etc.).
Returns:
Dictionary of property names to their values.
Raises:
MetadataNotFoundError: If no properties are found.
"""
self.metadata.clear()
self.keys_to_delete.clear()
doc = Document(str(Path(self.filepath)))
try:
if doc.core_properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
for attr in CORE_PROPERTIES:
if hasattr(doc.core_properties, attr):
self.metadata[attr] = getattr(doc.core_properties, attr)
if attr not in PRESERVED_PROPERTIES:
self.keys_to_delete.append(attr)
return self.metadata
except Exception as e:
raise MetadataReadingError(f"error reading metadata. {e}")
finally:
del doc
def wipe(self) -> None:
"""
Remove metadata properties from the Word document.
Clears all properties identified during read() except for
preserved properties (created, modified, language, etc.).
Raises:
MetadataNotFoundError: If no properties are found.
"""
self.processed_metadata.clear()
doc = Document(str(Path(self.filepath)))
try:
if doc.core_properties is None:
raise MetadataNotFoundError("No metadata found in the file.")
# Clear each property marked for deletion
for attr in self.keys_to_delete:
self.processed_metadata[attr] = None
except Exception as e:
raise MetadataProcessingError(f"error processing metadata. {e}")
finally:
del doc
def save(self, output_path: str | None = None) -> None:
"""
Save the document with cleaned metadata to the output path.
Creates a copy of the original file and applies the wiped
metadata properties to it.
Args:
output_path: Path where the cleaned file should be saved.
Raises:
ValueError: If output_path is None or empty.
"""
if not output_path:
raise ValueError("output_path is required")
destination_file_path = Path(output_path)
shutil.copy2(self.filepath, destination_file_path)
doc = Document(str(Path(destination_file_path)))
try:
# Apply wiped properties
for attr in self.processed_metadata:
if hasattr(doc.core_properties, attr):
setattr(doc.core_properties, attr, self.processed_metadata[attr])
doc.save(str(destination_file_path))
except Exception as e:
raise MetadataProcessingError(f"error processing metadata. {e}")
finally:
del doc

View File

@ -0,0 +1,167 @@
"""
Display utilities for rich terminal output.
This module provides functions for displaying metadata and batch processing
results in beautifully formatted tables and panels using the Rich library.
"""
from typing import Any
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from src.utils.formatter import clean_value
console = Console()
def print_metadata_table(metadata: dict[str, Any]):
"""
Display metadata in a formatted table organized by logical groups.
Organizes metadata into categories (Device Info, Exposure Settings,
Image Data, Dates) and displays them in a Rich panel with color coding.
Args:
metadata: Dict of metadata key-value pairs to display.
"""
# Define the groups using simple lists of keys
groups = {
"📄 Document Info": [
"Author",
"author",
"/Author",
"/Creator",
],
"📸 Device Info": ["Make",
"Model",
"Software",
"ExifVersion"],
"⚙️ Exposure Settings": [
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"ShutterSpeedValue",
"ApertureValue",
"Flash",
"FocalLength",
],
"🖼️ Image Data": [
"ImageWidth",
"ImageLength",
"PixelXDimension",
"PixelYDimension",
"Orientation",
"ResolutionUnit",
],
"📅 Dates": [
"DateTime",
"DateTimeOriginal",
"DateTimeDigitized",
"OffsetTime",
"created",
"modified",
"/CreationDate",
"/ModDate",
],
}
# 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")
# Track which keys we have displayed to handle the "leftovers"
displayed_keys = set()
# Loop through the defined groups to create sections
for section_name, keys in groups.items():
# Check if we have any data for this section
section_data = {k: metadata[k] for k in keys if k in metadata}
if section_data:
# Add a section row (acts as a sub-header)
table.add_row(Text(section_name, style = "bold yellow"), "")
for key, val in section_data.items():
table.add_row(f" {key}", clean_value(val))
displayed_keys.add(key)
# Add a blank row for spacing
table.add_section()
# Handle "Other" (Any keys that isn't in the groups)
leftovers = {
k: v
for k, v in metadata.items()
if k not in displayed_keys and k != "JPEGInterchangeFormat"
} # skip binary blobs
if leftovers:
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)
)
def print_batch_summary(summary) -> None:
"""
Display batch processing results in a rich panel.
Args:
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")
if summary.dry_run:
table.add_row("Mode", "[yellow]DRY-RUN (no changes made)[/yellow]")
table.add_row("Would process", str(summary.success))
table.add_row("Would skip", str(summary.skipped))
else:
table.add_row("Total processed", str(summary.total))
table.add_row("✅ Success", f"[green]{summary.success}[/green]")
table.add_row("⚠️ Skipped", f"[yellow]{summary.skipped}[/yellow]")
if summary.output_dir:
table.add_row("📁 Output", str(summary.output_dir.resolve()))
# Show failed files if any
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(f" {result.filepath.name}", f"[dim]{result.error}[/dim]")
if len(failed) > 5:
table.add_row("", f"[dim]... and {len(failed) - 5} more[/dim]")
# Determine panel title and style
if summary.dry_run:
title = "🔍 Dry-Run Summary"
border_style = "yellow"
elif summary.skipped == 0:
title = "✅ Scrub Complete"
border_style = "green"
else:
title = "⚠️ Scrub Complete (with warnings)"
border_style = "yellow"
console.print(
Panel(table,
title = title,
border_style = border_style,
expand = False)
)

View File

@ -0,0 +1,36 @@
"""
Custom exceptions for metadata processing operations.
This module defines a hierarchy of exceptions used throughout the
metadata scrubber tool for handling various error conditions.
"""
class MetadataException(Exception):
"""Base class for all metadata-related exceptions."""
pass
class UnsupportedFormatError(MetadataException):
"""Raised when attempting to process an unsupported file format."""
pass
class MetadataNotFoundError(MetadataException):
"""Raised when no metadata is found in a file."""
pass
class MetadataProcessingError(MetadataException):
"""Raised when an error occurs during metadata processing."""
pass
class MetadataReadingError(MetadataException):
"""Raised when an error occurs during metadata reading."""
pass

View File

@ -0,0 +1,42 @@
"""
Value formatting utilities for metadata display.
This module provides helper functions to convert raw EXIF data into
human-readable strings for display in the terminal.
"""
from typing import Any
def clean_value(value: Any) -> str:
"""
Convert raw EXIF data into a human-readable string.
Handles various EXIF value types including bytes, tuples, and empty values.
Args:
value: Raw EXIF value (bytes, tuple, str, int, etc.).
Returns:
Human-readable string representation of the value.
"""
# Decode bytes (e.g., b'samsung' -> 'samsung')
if isinstance(value, bytes):
try:
return value.decode("utf-8").strip()
except UnicodeDecodeError:
return str(value)
# Format Tuples (e.g., (1, 50) -> '1/50')
if isinstance(value, tuple) or isinstance(value, list):
return "/".join(map(str, value))
if isinstance(value, str) and value.startswith("D:"):
clean_iso = f"{value[2:6]}-{value[6:8]}-{value[8:10]}T{value[10:12]}:{value[12:14]}:{value[14:16]}"
return clean_iso
# Handle empty values
if value == "":
return "-"
return str(value)

View File

@ -0,0 +1,31 @@
"""
File discovery utilities for batch processing.
This module provides functions to find and yield files for processing,
supporting recursive directory traversal with extension filtering.
"""
from collections.abc import Generator
from pathlib import Path
def get_target_files(input_path_str: Path, ext: str) -> Generator[Path, None, None]:
"""
Yield files to process based on input path and extension filter.
Handles both directories (recursive search) and single files (defensive).
Args:
input_path_str: Path object pointing to a file or directory.
ext: File extension to filter by (without dot, e.g., 'jpg').
Yields:
Path objects for each matching file found.
"""
if input_path_str.is_dir():
# Directory: recursively yield all files with the specified extension
yield from input_path_str.rglob(f"*.{ext.lower()}")
elif input_path_str.is_file():
# Defensive: if a file is passed, yield it if extension matches
if input_path_str.suffix.lstrip(".").lower() == ext.lower():
yield input_path_str

View File

@ -0,0 +1,48 @@
"""
Logging configuration for the metadata scrubber CLI.
This module provides the setup_logging function which configures the
application's logging with Rich handlers for beautiful terminal output.
"""
import logging
from rich.logging import RichHandler
def setup_logging(verbose: bool = False):
"""
Configure application logging with Rich formatting.
Sets up the logger with appropriate level and Rich handlers for
beautiful terminal output including colorful stack traces.
Args:
verbose: If True, enables DEBUG level logging.
If False (default), enables INFO level logging.
Returns:
Logger instance for 'metadata-scrubber'.
"""
# Define the log level
level = logging.DEBUG if verbose else logging.INFO
# Configure the logger
# Remove existing handlers to avoid duplicate lines if the app restarts
logging.getLogger().handlers.clear()
logging.basicConfig(
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)
)
],
)
# Return the logger instance
return logging.getLogger("metadata-scrubber")

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,171 @@
"""
Pytest configuration and shared fixtures.
Provides cross-platform test file paths that work on both Windows and Linux (CI).
"""
from pathlib import Path
import pytest
# Get the tests directory (this file's parent)
TESTS_DIR = Path(__file__).parent
ASSETS_DIR = TESTS_DIR / "assets"
TEST_IMAGES_DIR = ASSETS_DIR / "test_images"
TEST_PDFS_DIR = ASSETS_DIR / "test_pdfs"
TEST_XLSX_DIR = ASSETS_DIR / "test_xlsx"
@pytest.fixture
def jpg_test_file() -> Path:
"""Return path to a JPG test file."""
return TEST_IMAGES_DIR / "test_fuji.jpg"
@pytest.fixture
def png_test_file() -> Path:
"""Return path to a PNG test file."""
return TEST_IMAGES_DIR / "generated_test_03.png"
@pytest.fixture
def test_images_dir() -> Path:
"""Return path to test images directory."""
return TEST_IMAGES_DIR
# String versions for parametrize (which doesn't support fixtures directly)
def get_jpg_test_file() -> str:
"""Get JPG test file path as string."""
return str(TEST_IMAGES_DIR / "test_fuji.jpg")
def get_png_test_file() -> str:
"""Get PNG test file path as string."""
return str(TEST_IMAGES_DIR / "generated_test_03.png")
def get_test_images_dir() -> str:
"""Get test images directory path as string."""
return str(TEST_IMAGES_DIR)
@pytest.fixture
def pdf_test_file() -> Path:
"""Return path to a PDF test file with metadata."""
return TEST_PDFS_DIR / "sample.pdf"
@pytest.fixture
def test_pdfs_dir() -> Path:
"""Return path to test PDFs directory."""
return TEST_PDFS_DIR
# String versions for parametrize (PDF)
def get_pdf_test_file() -> str:
"""Get PDF test file path as string."""
return str(TEST_PDFS_DIR / "sample.pdf")
def get_large_pdf_test_file() -> str:
"""Get large PDF test file path as string."""
return str(TEST_PDFS_DIR / "file-example_PDF_1MB.pdf")
def get_test_pdfs_dir() -> str:
"""Get test PDFs directory path as string."""
return str(TEST_PDFS_DIR)
# Excel fixtures
@pytest.fixture
def xlsx_test_file() -> Path:
"""Return path to an XLSX test file with metadata."""
return TEST_XLSX_DIR / "file_example_XLSX_1000.xlsx"
@pytest.fixture
def test_xlsx_dir() -> Path:
"""Return path to test XLSX directory."""
return TEST_XLSX_DIR
# String versions for parametrize (Excel)
def get_xlsx_test_file() -> str:
"""Get XLSX test file path as string."""
return str(TEST_XLSX_DIR / "file_example_XLSX_1000.xlsx")
def get_large_xlsx_test_file() -> str:
"""Get large XLSX test file path as string."""
return str(TEST_XLSX_DIR / "file_example_XLSX_5000.xlsx")
def get_test_xlsx_dir() -> str:
"""Get test XLSX directory path as string."""
return str(TEST_XLSX_DIR)
# PowerPoint fixtures
TEST_PPTX_DIR = ASSETS_DIR / "test_pptx"
@pytest.fixture
def pptx_test_file() -> Path:
"""Return path to a PPTX test file with metadata."""
return TEST_PPTX_DIR / "Extlst-test.pptx"
@pytest.fixture
def test_pptx_dir() -> Path:
"""Return path to test PPTX directory."""
return TEST_PPTX_DIR
# String versions for parametrize (PowerPoint)
def get_pptx_test_file() -> str:
"""Get PPTX test file path as string."""
return str(TEST_PPTX_DIR / "Extlst-test.pptx")
def get_large_pptx_test_file() -> str:
"""Get second PPTX test file path as string."""
return str(TEST_PPTX_DIR / "sample3.pptx")
def get_test_pptx_dir() -> str:
"""Get test PPTX directory path as string."""
return str(TEST_PPTX_DIR)
# Word document fixtures
TEST_DOCX_DIR = ASSETS_DIR / "test_docx"
@pytest.fixture
def docx_test_file() -> Path:
"""Return path to a DOCX test file with metadata."""
return TEST_DOCX_DIR / "file-sample_500kB.docx"
@pytest.fixture
def test_docx_dir() -> Path:
"""Return path to test DOCX directory."""
return TEST_DOCX_DIR
# String versions for parametrize (Word document)
def get_docx_test_file() -> str:
"""Get DOCX test file path as string."""
return str(TEST_DOCX_DIR / "file-sample_500kB.docx")
def get_large_docx_test_file() -> str:
"""Get large DOCX test file path as string."""
return str(TEST_DOCX_DIR / "file-sample_1MB.docx")
def get_test_docx_dir() -> str:
"""Get test DOCX directory path as string."""
return str(TEST_DOCX_DIR)

View File

@ -0,0 +1,24 @@
from typer.testing import CliRunner
from src.main import __version__, app
# Initialize the runner
runner = CliRunner()
def test_app_version():
"""
Ensure the --version flag prints the correct version and exits cleanly.
"""
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0, "The app crashed!"
assert __version__ in result.stdout, "Version number not found in output"
def test_app_help():
"""
Ensure the --help flag works.
"""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "Metadata Scrubber Tool" in result.stdout

View File

@ -0,0 +1,173 @@
"""
E2E tests for the 'read' command.
Tests the full CLI flow for reading metadata from files.
"""
from pathlib import Path
import pytest
from typer.testing import CliRunner
from src.main import app
# Import path helpers from conftest
from tests.conftest import (
get_jpg_test_file,
get_pdf_test_file,
get_png_test_file,
get_test_images_dir,
get_test_pdfs_dir,
get_test_xlsx_dir,
get_xlsx_test_file,
)
runner = CliRunner()
# Test file paths (cross-platform)
JPG_TEST_FILE = get_jpg_test_file()
PNG_TEST_FILE = get_png_test_file()
TEST_DIR = get_test_images_dir()
PDF_TEST_FILE = get_pdf_test_file()
PDF_DIR = get_test_pdfs_dir()
XLSX_TEST_FILE = get_xlsx_test_file()
XLSX_DIR = get_test_xlsx_dir()
# ============== Image Tests ==============
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_read_command_single_file_success(x):
"""Test the 'read' command with a single image file."""
result = runner.invoke(app, ["read", str(x)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
assert Path(x).name in result.stdout
@pytest.mark.parametrize("ext", ["jpg", "png"])
def test_read_command_recursive_directory_success(ext):
"""Test the 'read' command with recursive directory processing."""
result = runner.invoke(app, ["read", TEST_DIR, "-r", "-ext", ext])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
def test_read_command_requires_ext_with_recursive():
"""Test that --recursive requires --extension flag."""
result = runner.invoke(app, ["read", TEST_DIR, "-r"])
assert result.exit_code != 0
def test_read_command_requires_recursive_with_ext():
"""Test that --extension requires --recursive flag."""
result = runner.invoke(app, ["read", JPG_TEST_FILE, "-ext", "jpg"])
assert result.exit_code != 0
# ============== Error Tests ==============
def test_read_command_file_not_found():
"""Test that the app handles missing files gracefully."""
result = runner.invoke(app, ["read", "ghost_file.jpg"])
assert result.exit_code == 2
assert "Invalid value for 'FILE_PATH'" in result.stderr
assert "does not exist" in result.stderr
# ============== PDF Tests ==============
def test_read_command_pdf_single_file_success():
"""Test the 'read' command with a single PDF file."""
result = runner.invoke(app, ["read", PDF_TEST_FILE])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
assert Path(PDF_TEST_FILE).name in result.stdout
def test_read_command_recursive_pdf_success():
"""Test the 'read' command with recursive PDF directory processing."""
result = runner.invoke(app, ["read", PDF_DIR, "-r", "-ext", "pdf"])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
# ============== Excel Tests ==============
def test_read_command_xlsx_single_file_success():
"""Test the 'read' command with a single Excel file."""
result = runner.invoke(app, ["read", XLSX_TEST_FILE])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
assert Path(XLSX_TEST_FILE).name in result.stdout
def test_read_command_recursive_xlsx_success():
"""Test the 'read' command with recursive Excel directory processing."""
result = runner.invoke(app, ["read", XLSX_DIR, "-r", "-ext", "xlsx"])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
# ============== PowerPoint Tests ==============
def test_read_command_pptx_single_file_success():
"""Test the 'read' command with a single PowerPoint file."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
result = runner.invoke(app, ["read", PPTX_TEST_FILE])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
assert Path(PPTX_TEST_FILE).name in result.stdout
def test_read_command_recursive_pptx_success():
"""Test the 'read' command with recursive PowerPoint directory processing."""
from tests.conftest import get_test_pptx_dir
PPTX_DIR = get_test_pptx_dir()
result = runner.invoke(app, ["read", PPTX_DIR, "-r", "-ext", "pptx"])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
# ============== Word Document Tests ==============
def test_read_command_docx_single_file_success():
"""Test the 'read' command with a single Word document file."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
result = runner.invoke(app, ["read", DOCX_TEST_FILE])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout
assert Path(DOCX_TEST_FILE).name in result.stdout
def test_read_command_recursive_docx_success():
"""Test the 'read' command with recursive Word document directory processing."""
from tests.conftest import get_test_docx_dir
DOCX_DIR = get_test_docx_dir()
result = runner.invoke(app, ["read", DOCX_DIR, "-r", "-ext", "docx"])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "Reading" in result.stdout

View File

@ -0,0 +1,406 @@
"""
E2E tests for the 'scrub' command.
Tests the full CLI flow for scrubbing metadata from files.
"""
from pathlib import Path
import pytest
from typer.testing import CliRunner
from src.main import app
# Import path helpers from conftest
from tests.conftest import (
get_jpg_test_file,
get_pdf_test_file,
get_png_test_file,
get_test_images_dir,
get_test_pdfs_dir,
get_test_xlsx_dir,
get_xlsx_test_file,
)
runner = CliRunner()
# Test file paths (cross-platform)
JPG_TEST_FILE = get_jpg_test_file()
PNG_TEST_FILE = get_png_test_file()
EXAMPLES_DIR = get_test_images_dir()
PDF_TEST_FILE = get_pdf_test_file()
PDF_DIR = get_test_pdfs_dir()
XLSX_TEST_FILE = get_xlsx_test_file()
XLSX_DIR = get_test_xlsx_dir()
@pytest.fixture
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)
return output
# ============== Image Tests ==============
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_scrub_command_single_file_success(x, output_dir):
"""Test the 'scrub' command with a single image file."""
result = runner.invoke(app, ["scrub", x, "--output", str(output_dir)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_file = output_dir / f"processed_{Path(x).name}"
assert output_file.exists()
def test_scrub_command_recursive_jpg_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for JPG."""
result = runner.invoke(
app,
["scrub",
EXAMPLES_DIR,
"-r",
"-ext",
"jpg",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_files = list(output_dir.glob("processed_*.jpg"))
assert len(output_files) > 0
def test_scrub_command_dry_run(output_dir):
"""Test that --dry-run doesn't create files."""
result = runner.invoke(
app,
["scrub",
JPG_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "DRY-RUN" in result.stdout
output_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}"
assert not output_file.exists()
def test_scrub_command_with_workers(output_dir):
"""Test the --workers option for concurrent processing."""
result = runner.invoke(
app,
[
"scrub",
EXAMPLES_DIR,
"-r",
"-ext",
"jpg",
"--output",
str(output_dir),
"--workers",
"2",
],
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
# ============== Error Tests ==============
def test_scrub_command_file_not_found():
"""Test that the app handles missing files gracefully."""
result = runner.invoke(app, ["scrub", "ghost_file.jpg"])
assert result.exit_code == 2
assert "Invalid value" in result.stderr
def test_scrub_command_requires_ext_with_recursive():
"""Test that --recursive requires --extension flag."""
result = runner.invoke(app, ["scrub", EXAMPLES_DIR, "-r"])
assert result.exit_code != 0
def test_scrub_command_requires_recursive_with_ext():
"""Test that --extension requires --recursive flag."""
result = runner.invoke(app, ["scrub", JPG_TEST_FILE, "-ext", "jpg"])
assert result.exit_code != 0
# ============== PDF Tests ==============
def test_scrub_command_pdf_single_file_success(output_dir):
"""Test the 'scrub' command with a single PDF file."""
result = runner.invoke(app, ["scrub", PDF_TEST_FILE, "--output", str(output_dir)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
assert output_file.exists()
def test_scrub_command_recursive_pdf_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for PDF."""
result = runner.invoke(
app,
["scrub",
PDF_DIR,
"-r",
"-ext",
"pdf",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_files = list(output_dir.glob("processed_*.pdf"))
assert len(output_files) > 0
def test_scrub_command_pdf_dry_run(output_dir):
"""Test that --dry-run doesn't create PDF files."""
result = runner.invoke(
app,
["scrub",
PDF_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "DRY-RUN" in result.stdout
output_file = output_dir / f"processed_{Path(PDF_TEST_FILE).name}"
assert not output_file.exists()
# ============== Excel Tests ==============
def test_scrub_command_xlsx_single_file_success(output_dir):
"""Test the 'scrub' command with a single Excel file."""
result = runner.invoke(app, ["scrub", XLSX_TEST_FILE, "--output", str(output_dir)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
assert output_file.exists()
def test_scrub_command_recursive_xlsx_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for Excel."""
result = runner.invoke(
app,
["scrub",
XLSX_DIR,
"-r",
"-ext",
"xlsx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_files = list(output_dir.glob("processed_*.xlsx"))
assert len(output_files) > 0
def test_scrub_command_xlsx_dry_run(output_dir):
"""Test that --dry-run doesn't create Excel files."""
result = runner.invoke(
app,
["scrub",
XLSX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "DRY-RUN" in result.stdout
output_file = output_dir / f"processed_{Path(XLSX_TEST_FILE).name}"
assert not output_file.exists()
def test_scrub_command_xlsx_with_workers(output_dir):
"""Test the --workers option for concurrent Excel processing."""
result = runner.invoke(
app,
[
"scrub",
XLSX_DIR,
"-r",
"-ext",
"xlsx",
"--output",
str(output_dir),
"--workers",
"2",
],
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
# ============== PowerPoint Tests ==============
def test_scrub_command_pptx_single_file_success(output_dir):
"""Test the 'scrub' command with a single PowerPoint file."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
result = runner.invoke(app, ["scrub", PPTX_TEST_FILE, "--output", str(output_dir)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_file = output_dir / f"processed_{Path(PPTX_TEST_FILE).name}"
assert output_file.exists()
def test_scrub_command_recursive_pptx_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for PowerPoint."""
from tests.conftest import get_test_pptx_dir
PPTX_DIR = get_test_pptx_dir()
result = runner.invoke(
app,
["scrub",
PPTX_DIR,
"-r",
"-ext",
"pptx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_files = list(output_dir.glob("processed_*.pptx"))
assert len(output_files) > 0
def test_scrub_command_pptx_dry_run(output_dir):
"""Test that --dry-run doesn't create PowerPoint files."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
result = runner.invoke(
app,
["scrub",
PPTX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "DRY-RUN" in result.stdout
output_file = output_dir / f"processed_{Path(PPTX_TEST_FILE).name}"
assert not output_file.exists()
def test_scrub_command_pptx_with_workers(output_dir):
"""Test the --workers option for concurrent PowerPoint processing."""
from tests.conftest import get_test_pptx_dir
PPTX_DIR = get_test_pptx_dir()
result = runner.invoke(
app,
[
"scrub",
PPTX_DIR,
"-r",
"-ext",
"pptx",
"--output",
str(output_dir),
"--workers",
"2",
],
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
# ============== Word Document Tests ==============
def test_scrub_command_docx_single_file_success(output_dir):
"""Test the 'scrub' command with a single Word document file."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
result = runner.invoke(app, ["scrub", DOCX_TEST_FILE, "--output", str(output_dir)])
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_file = output_dir / f"processed_{Path(DOCX_TEST_FILE).name}"
assert output_file.exists()
def test_scrub_command_recursive_docx_success(output_dir):
"""Test the 'scrub' command with recursive directory processing for Word documents."""
from tests.conftest import get_test_docx_dir
DOCX_DIR = get_test_docx_dir()
result = runner.invoke(
app,
["scrub",
DOCX_DIR,
"-r",
"-ext",
"docx",
"--output",
str(output_dir)]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
output_files = list(output_dir.glob("processed_*.docx"))
assert len(output_files) > 0
def test_scrub_command_docx_dry_run(output_dir):
"""Test that --dry-run doesn't create Word document files."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
result = runner.invoke(
app,
["scrub",
DOCX_TEST_FILE,
"--output",
str(output_dir),
"--dry-run"]
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"
assert "DRY-RUN" in result.stdout
output_file = output_dir / f"processed_{Path(DOCX_TEST_FILE).name}"
assert not output_file.exists()
def test_scrub_command_docx_with_workers(output_dir):
"""Test the --workers option for concurrent Word document processing."""
from tests.conftest import get_test_docx_dir
DOCX_DIR = get_test_docx_dir()
result = runner.invoke(
app,
[
"scrub",
DOCX_DIR,
"-r",
"-ext",
"docx",
"--output",
str(output_dir),
"--workers",
"2",
],
)
assert result.exit_code == 0, f"Failed with: {result.stdout}"

View File

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

View File

@ -0,0 +1,321 @@
"""
Integration tests for MetadataFactory.
Tests the factory pattern integration with all handler types (Image, PDF, Excel).
"""
import shutil
from pathlib import Path
import pytest
from src.core.jpeg_metadata import JpegProcessor
from src.core.png_metadata import PngProcessor
from src.services.excel_handler import ExcelHandler
from src.services.metadata_factory import MetadataFactory
from src.services.pdf_handler import PDFHandler
from src.utils.exceptions import UnsupportedFormatError
# Import path helpers from conftest
from tests.conftest import (
get_jpg_test_file,
get_pdf_test_file,
get_png_test_file,
get_xlsx_test_file,
)
# Test file paths (cross-platform)
JPG_TEST_FILE = get_jpg_test_file()
PNG_TEST_FILE = get_png_test_file()
PDF_TEST_FILE = get_pdf_test_file()
XLSX_TEST_FILE = get_xlsx_test_file()
# ============== Image Tests ==============
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_read_image_metadata(x):
"""Test reading image metadata through factory."""
assert Path(x).exists(), f"Test file not found: {x}"
handler = MetadataFactory.get_handler(str(x))
metadata = handler.read()
assert handler.metadata == metadata
assert isinstance(metadata, dict)
if isinstance(handler, (JpegProcessor, PngProcessor)):
assert (
handler.tags_to_delete is not None or handler.text_keys_to_delete is not None
)
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_wipe_image_metadata(x):
"""Test wiping image metadata through factory."""
assert Path(x).exists(), f"Test file not found: {x}"
handler = MetadataFactory.get_handler(str(x))
metadata = handler.read()
handler.wipe()
assert handler.processed_metadata != metadata
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
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)
handler = MetadataFactory.get_handler(str(x))
handler.read()
handler.wipe()
output_file = output_dir / Path(x).name
handler.save(str(output_file))
assert output_file.exists()
shutil.rmtree(output_dir)
def test_image_format_detection():
"""Test format detection for images."""
handler = MetadataFactory.get_handler(JPG_TEST_FILE)
assert handler._detect_format() == "jpeg"
handler_png = MetadataFactory.get_handler(PNG_TEST_FILE)
assert handler_png._detect_format() == "png"
# ============== PDF Tests ==============
def test_read_pdf_metadata_via_factory():
"""Test reading PDF metadata through MetadataFactory."""
assert Path(PDF_TEST_FILE).exists(), f"Test file not found: {PDF_TEST_FILE}"
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
assert isinstance(handler, PDFHandler)
metadata = handler.read()
assert handler.metadata == metadata
assert isinstance(metadata, dict)
assert handler.keys_to_delete is not None
def test_wipe_pdf_metadata_via_factory():
"""Test wiping PDF metadata through MetadataFactory."""
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
metadata = handler.read()
handler.wipe()
assert handler.processed_metadata != metadata
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)
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
handler.read()
handler.wipe()
output_file = output_dir / Path(PDF_TEST_FILE).name
handler.save(str(output_file))
assert output_file.exists()
shutil.rmtree(output_dir)
def test_pdf_format_detection():
"""Test format detection for PDF files."""
handler = MetadataFactory.get_handler(PDF_TEST_FILE)
assert handler._detect_format() == "pdf"
# ============== Excel Tests ==============
def test_read_excel_metadata_via_factory():
"""Test reading Excel metadata through MetadataFactory."""
assert Path(XLSX_TEST_FILE).exists(), f"Test file not found: {XLSX_TEST_FILE}"
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
assert isinstance(handler, ExcelHandler)
metadata = handler.read()
assert handler.metadata == metadata
assert isinstance(metadata, dict)
assert handler.keys_to_delete is not None
def test_wipe_excel_metadata_via_factory():
"""Test wiping Excel metadata through MetadataFactory."""
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
metadata = handler.read()
handler.wipe()
assert handler.processed_metadata != metadata
def test_save_processed_excel_metadata_via_factory():
"""Test saving processed Excel metadata through MetadataFactory."""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
handler.read()
handler.wipe()
output_file = output_dir / Path(XLSX_TEST_FILE).name
handler.save(str(output_file))
assert output_file.exists()
shutil.rmtree(output_dir)
def test_excel_format_detection():
"""Test format detection for Excel files."""
handler = MetadataFactory.get_handler(XLSX_TEST_FILE)
assert handler._detect_format() == "xlsx"
# ============== PowerPoint Tests ==============
def test_read_pptx_metadata_via_factory():
"""Test reading PowerPoint metadata through MetadataFactory."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
assert Path(PPTX_TEST_FILE).exists(), f"Test file not found: {PPTX_TEST_FILE}"
from src.services.powerpoint_handler import PowerpointHandler
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
assert isinstance(handler, PowerpointHandler)
metadata = handler.read()
assert handler.metadata == metadata
assert isinstance(metadata, dict)
def test_wipe_pptx_metadata_via_factory():
"""Test wiping PowerPoint metadata through MetadataFactory."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
handler.read()
handler.wipe()
assert handler.processed_metadata is not None
def test_save_processed_pptx_metadata_via_factory():
"""Test saving processed PowerPoint metadata through MetadataFactory."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
handler.read()
handler.wipe()
output_file = output_dir / Path(PPTX_TEST_FILE).name
handler.save(str(output_file))
assert output_file.exists()
shutil.rmtree(output_dir)
def test_pptx_format_detection():
"""Test format detection for PowerPoint files."""
from tests.conftest import get_pptx_test_file
PPTX_TEST_FILE = get_pptx_test_file()
handler = MetadataFactory.get_handler(PPTX_TEST_FILE)
assert handler._detect_format() == "pptx"
# ============== Word Document Tests ==============
def test_read_docx_metadata_via_factory():
"""Test reading Word document metadata through MetadataFactory."""
from src.services.worddoc_handler import WorddocHandler
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
assert Path(DOCX_TEST_FILE).exists(), f"Test file not found: {DOCX_TEST_FILE}"
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
assert isinstance(handler, WorddocHandler)
metadata = handler.read()
assert handler.metadata == metadata
assert isinstance(metadata, dict)
def test_wipe_docx_metadata_via_factory():
"""Test wiping Word document metadata through MetadataFactory."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
handler.read()
handler.wipe()
assert handler.processed_metadata is not None
def test_save_processed_docx_metadata_via_factory():
"""Test saving processed Word document metadata through MetadataFactory."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
handler.read()
handler.wipe()
output_file = output_dir / Path(DOCX_TEST_FILE).name
handler.save(str(output_file))
assert output_file.exists()
shutil.rmtree(output_dir)
def test_docx_format_detection():
"""Test format detection for Word document files."""
from tests.conftest import get_docx_test_file
DOCX_TEST_FILE = get_docx_test_file()
handler = MetadataFactory.get_handler(DOCX_TEST_FILE)
assert handler._detect_format() == "docx"
# ============== Error Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""Test that unsupported file formats raise an error."""
fake_file = tmp_path / "test.txt"
fake_file.write_text("not an image")
with pytest.raises(UnsupportedFormatError):
MetadataFactory.get_handler(str(fake_file))
def test_save_without_output_path_raises_error():
"""Test that save() raises ValueError when output_path is empty."""
handler = MetadataFactory.get_handler(JPG_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save("")

View File

@ -0,0 +1,207 @@
"""
Unit tests for ExcelHandler.
Tests the ExcelHandler class in isolation, focusing on individual methods
and edge cases including encrypted workbooks, missing metadata, and corrupted files.
"""
import shutil
from pathlib import Path
import pytest
from openpyxl.utils.exceptions import InvalidFileException
from src.services.excel_handler import ExcelHandler
from src.utils.exceptions import (
MetadataNotFoundError,
UnsupportedFormatError,
)
# Import path helpers from conftest
from tests.conftest import get_large_xlsx_test_file, get_xlsx_test_file
# Test file paths (cross-platform)
XLSX_TEST_FILE = get_xlsx_test_file()
LARGE_XLSX_TEST_FILE = get_large_xlsx_test_file()
# ============== Success Case Tests ==============
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
def test_read_excel_metadata(xlsx_file):
"""
Test reading metadata from Excel files.
Verifies that read() extracts metadata and populates keys_to_delete.
"""
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
handler = ExcelHandler(xlsx_file)
metadata = handler.read()
# Check metadata was extracted
assert handler.metadata == metadata
assert isinstance(metadata, dict)
# Check keys_to_delete is populated
assert handler.keys_to_delete is not None
assert len(handler.keys_to_delete) > 0
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
def test_wipe_excel_metadata(xlsx_file):
"""
Test wiping metadata from Excel files.
Verifies that wipe() removes metadata entries.
"""
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
handler = ExcelHandler(xlsx_file)
metadata = handler.read()
handler.wipe()
# processed_metadata should differ from original
assert handler.processed_metadata != metadata
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
def test_save_processed_excel_metadata(xlsx_file):
"""
Test saving processed Excel to output path.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = ExcelHandler(xlsx_file)
handler.read()
handler.wipe()
output_file = output_dir / Path(xlsx_file).name
handler.save(str(output_file))
# Verify output file exists
assert output_file.exists()
# Cleanup
shutil.rmtree(output_dir)
def test_format_detection_xlsx():
"""
Test that _detect_format() correctly identifies XLSX files.
"""
handler = ExcelHandler(XLSX_TEST_FILE)
detected = handler._detect_format()
assert detected == "xlsx"
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
def test_output_file_has_less_metadata(xlsx_file):
"""
Test that the output file has metadata stripped.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = ExcelHandler(xlsx_file)
original_metadata = handler.read()
handler.wipe()
# Save processed file
output_file = output_dir / Path(xlsx_file).name
handler.save(str(output_file))
# Read output file and verify metadata is reduced or gone
try:
output_handler = ExcelHandler(str(output_file))
output_metadata = output_handler.read()
# Output should have fewer metadata entries (some are None now)
non_none_original = sum(1 for v in original_metadata.values() if v is not None)
non_none_output = sum(1 for v in output_metadata.values() if v is not None)
assert non_none_output <= non_none_original
except MetadataNotFoundError:
# If no metadata found, that's expected for fully stripped files
pass
# Cleanup
shutil.rmtree(output_dir)
def test_preserved_properties_not_deleted():
"""
Test that created, modified, and language properties are preserved.
"""
handler = ExcelHandler(XLSX_TEST_FILE)
handler.read()
# These should NOT be in keys_to_delete
assert "created" not in handler.keys_to_delete
assert "modified" not in handler.keys_to_delete
assert "language" not in handler.keys_to_delete
# ============== Error Case Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""
Test that non-Excel files raise UnsupportedFormatError.
"""
# Create a fake text file with .txt extension
fake_file = tmp_path / "test.txt"
fake_file.write_text("not an excel file")
handler = ExcelHandler(str(fake_file))
with pytest.raises(UnsupportedFormatError):
handler._detect_format()
def test_save_without_output_path_raises_error():
"""
Test that save() raises ValueError when output_path is None or empty.
"""
handler = ExcelHandler(XLSX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save("")
def test_save_with_none_raises_error():
"""
Test that save() raises ValueError when output_path is None.
"""
handler = ExcelHandler(XLSX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises((ValueError, TypeError)):
handler.save(None)
# ============== Edge Case Tests ==============
def test_corrupted_excel_graceful_error(tmp_path):
"""
Test that corrupted Excel files are handled gracefully.
"""
# Create a corrupted Excel file (invalid structure)
corrupted_xlsx = tmp_path / "corrupted.xlsx"
corrupted_xlsx.write_bytes(b"not a valid xlsx content at all")
handler = ExcelHandler(str(corrupted_xlsx))
# Should raise InvalidFileException or similar from openpyxl
with pytest.raises((InvalidFileException, Exception)):
handler.read()
def test_format_detection_all_excel_types(tmp_path):
"""
Test format detection for all supported Excel extensions.
"""
for ext in ["xlsx", "xlsm", "xltx", "xltm"]:
fake_file = tmp_path / f"test.{ext}"
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
handler = ExcelHandler(str(fake_file))
detected = handler._detect_format()
assert detected == ext

View File

@ -0,0 +1,153 @@
import shutil
from pathlib import Path
import pytest
from src.services.image_handler import ImageHandler
from src.utils.exceptions import MetadataNotFoundError, UnsupportedFormatError
# Import path helpers from conftest
from tests.conftest import get_jpg_test_file, get_png_test_file
# Test file paths (cross-platform)
JPG_TEST_FILE = get_jpg_test_file()
PNG_TEST_FILE = get_png_test_file()
# ============== Success Case Tests ==============
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_read_image_metadata(x):
"""
test for reading image metadata
"""
# checks if file exists
assert Path(x).exists(), f"Test file not found: {x}"
processor = ImageHandler(x)
metadata = processor.read()
# checks if metadata is not empty
assert processor.metadata == metadata
# 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
)
# checks if metadata is a dictionary
assert isinstance(metadata, dict)
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_wipe_image_metadata(x):
"""
test for wiping image metadata
"""
# checks if file exists
assert Path(x).exists(), f"Test file not found: {x}"
processor = ImageHandler(x)
metadata = processor.read()
processor.wipe()
# checks if processed_metadata is not equal to metadata
assert processor.processed_metadata != metadata
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_save_processed_image_metadata(x):
"""
test for saving image processed metadata to a copy of the file
"""
# creates output directory
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
processor = ImageHandler(x)
metadata = processor.read()
processor.wipe()
# checks if processed_metadata is not equal to metadata
assert processor.processed_metadata != metadata
# Pass full file path
output_file = output_dir / Path(x).name
processor.save(str(output_file))
# checks if output file exists and then deletes it
assert output_file.exists()
shutil.rmtree(output_dir)
# ============== Error Case Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""
Test that unsupported file formats raise an error (PIL or custom)
"""
from PIL import UnidentifiedImageError
# Create a fake text file
fake_file = tmp_path / "test.txt"
fake_file.write_text("not an image")
handler = ImageHandler(str(fake_file))
# Either our UnsupportedFormatError or PIL's UnidentifiedImageError is acceptable
with pytest.raises((UnsupportedFormatError, UnidentifiedImageError)):
handler.read()
def test_save_without_output_path_raises_error():
"""
Test that save() raises ValueError when output_path is None
"""
handler = ImageHandler(JPG_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save(None)
@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE])
def test_output_file_has_less_metadata(x):
"""
Test that the output file has metadata stripped
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
processor = ImageHandler(x)
original_metadata = processor.read()
original_count = len(original_metadata)
processor.wipe()
# Save processed file
output_file = output_dir / Path(x).name
processor.save(str(output_file))
# Read output file and verify metadata is reduced or gone
try:
output_processor = ImageHandler(str(output_file))
output_metadata = output_processor.read()
# Output should have fewer metadata entries
assert len(output_metadata) < original_count
except MetadataNotFoundError:
# If no metadata found, that's expected for fully stripped files
pass
# clean up
shutil.rmtree(output_dir)
def test_format_detection_works():
"""
Test that format detection uses Pillow, not file extension
"""
handler = ImageHandler(JPG_TEST_FILE)
detected = handler._detect_format()
assert detected == "jpeg"
handler_png = ImageHandler(PNG_TEST_FILE)
detected_png = handler_png._detect_format()
assert detected_png == "png"

View File

@ -0,0 +1,223 @@
"""
Unit tests for PDFHandler.
Tests the PDFHandler class in isolation, focusing on individual methods
and edge cases including encrypted PDFs, missing metadata, and corrupted files.
"""
import shutil
from pathlib import Path
import pytest
from pypdf.errors import PdfReadError
from src.services.pdf_handler import PDFHandler
from src.utils.exceptions import (
MetadataNotFoundError,
MetadataReadingError,
UnsupportedFormatError,
)
# Import path helpers from conftest
from tests.conftest import get_large_pdf_test_file, get_pdf_test_file
# Test file paths (cross-platform)
PDF_TEST_FILE = get_pdf_test_file()
LARGE_PDF_TEST_FILE = get_large_pdf_test_file()
# ============== Success Case Tests ==============
@pytest.mark.parametrize("pdf_file", [PDF_TEST_FILE, LARGE_PDF_TEST_FILE])
def test_read_pdf_metadata(pdf_file):
"""
Test reading metadata from PDF files.
Verifies that read() extracts metadata and populates keys_to_delete.
"""
assert Path(pdf_file).exists(), f"Test file not found: {pdf_file}"
handler = PDFHandler(pdf_file)
metadata = handler.read()
# Check metadata was extracted
assert handler.metadata == metadata
assert isinstance(metadata, dict)
# Check keys_to_delete is populated
assert handler.keys_to_delete is not None
assert len(handler.keys_to_delete) > 0
@pytest.mark.parametrize("pdf_file", [PDF_TEST_FILE, LARGE_PDF_TEST_FILE])
def test_wipe_pdf_metadata(pdf_file):
"""
Test wiping metadata from PDF files.
Verifies that wipe() removes metadata entries.
"""
assert Path(pdf_file).exists(), f"Test file not found: {pdf_file}"
handler = PDFHandler(pdf_file)
metadata = handler.read()
handler.wipe()
# processed_metadata should differ from original
assert handler.processed_metadata != metadata
@pytest.mark.parametrize("pdf_file", [PDF_TEST_FILE, LARGE_PDF_TEST_FILE])
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)
handler = PDFHandler(pdf_file)
handler.read()
handler.wipe()
output_file = output_dir / Path(pdf_file).name
handler.save(str(output_file))
# Verify output file exists
assert output_file.exists()
# Cleanup
shutil.rmtree(output_dir)
def test_format_detection_works():
"""
Test that _detect_format() correctly identifies PDF files.
"""
handler = PDFHandler(PDF_TEST_FILE)
detected = handler._detect_format()
assert detected == "pdf"
@pytest.mark.parametrize("pdf_file", [PDF_TEST_FILE, LARGE_PDF_TEST_FILE])
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)
# Process original file
handler = PDFHandler(pdf_file)
original_metadata = handler.read()
original_count = len(original_metadata)
handler.wipe()
# Save processed file
output_file = output_dir / Path(pdf_file).name
handler.save(str(output_file))
# Read output file and verify metadata is reduced or gone
try:
output_handler = PDFHandler(str(output_file))
output_metadata = output_handler.read()
assert len(output_metadata) < original_count
except MetadataNotFoundError:
# If no metadata found, that's expected for fully stripped files
pass
# Cleanup
shutil.rmtree(output_dir)
# ============== Error Case Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""
Test that non-PDF files raise UnsupportedFormatError.
"""
# Create a fake text file with .txt extension
fake_file = tmp_path / "test.txt"
fake_file.write_text("not a pdf")
handler = PDFHandler(str(fake_file))
with pytest.raises(UnsupportedFormatError):
handler._detect_format()
def test_save_without_output_path_raises_error():
"""
Test that save() raises ValueError when output_path is None.
"""
handler = PDFHandler(PDF_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save(None)
# ============== Edge Case Tests ==============
def test_encrypted_pdf_raises_error(tmp_path):
"""
Test that encrypted PDFs raise MetadataReadingError.
Note: This test creates a minimal encrypted PDF for testing.
"""
# Create a minimal encrypted PDF structure
encrypted_pdf = tmp_path / "encrypted.pdf"
# Write minimal PDF with encryption marker
# This is a simplified test - in production you'd use a real encrypted PDF
encrypted_pdf.write_bytes(
b"%PDF-1.4\n"
b"1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"
b"2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n"
b"3 0 obj\n<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>\nendobj\n"
b"xref\n0 4\n"
b"0000000000 65535 f \n"
b"0000000009 00000 n \n"
b"0000000058 00000 n \n"
b"0000000115 00000 n \n"
b"trailer\n<</Size 4/Root 1 0 R/Encrypt<</V 1>>>>\n"
b"startxref\n191\n%%EOF"
)
handler = PDFHandler(str(encrypted_pdf))
# Encrypted PDF should raise MetadataReadingError or PdfReadError
with pytest.raises((MetadataReadingError, PdfReadError, Exception)):
handler.read()
def test_pdf_without_metadata_raises_error(tmp_path):
"""
Test that PDFs with no metadata raise MetadataNotFoundError.
"""
# Create a minimal PDF without metadata
no_metadata_pdf = tmp_path / "no_metadata.pdf"
no_metadata_pdf.write_bytes(
b"%PDF-1.4\n"
b"1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"
b"2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n"
b"3 0 obj\n<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>\nendobj\n"
b"xref\n0 4\n"
b"0000000000 65535 f \n"
b"0000000009 00000 n \n"
b"0000000058 00000 n \n"
b"0000000115 00000 n \n"
b"trailer\n<</Size 4/Root 1 0 R>>\n"
b"startxref\n191\n%%EOF"
)
handler = PDFHandler(str(no_metadata_pdf))
with pytest.raises((MetadataNotFoundError, Exception)):
handler.read()
def test_corrupted_pdf_graceful_error(tmp_path):
"""
Test that corrupted PDFs are handled gracefully.
"""
# Create a corrupted PDF (invalid structure)
corrupted_pdf = tmp_path / "corrupted.pdf"
corrupted_pdf.write_bytes(b"not a valid pdf content at all")
handler = PDFHandler(str(corrupted_pdf))
# Should raise PdfReadError or similar from pypdf
with pytest.raises((PdfReadError, Exception)):
handler.read()

View File

@ -0,0 +1,208 @@
"""
Unit tests for PowerpointHandler.
Tests the PowerpointHandler class in isolation, focusing on individual methods
and edge cases including missing metadata and corrupted files.
"""
import shutil
from pathlib import Path
import pytest
from src.services.powerpoint_handler import PowerpointHandler
from src.utils.exceptions import (
MetadataNotFoundError,
UnsupportedFormatError,
)
# Import path helpers from conftest
from tests.conftest import get_large_pptx_test_file, get_pptx_test_file
# Test file paths (cross-platform)
PPTX_TEST_FILE = get_pptx_test_file()
LARGE_PPTX_TEST_FILE = get_large_pptx_test_file()
# ============== Success Case Tests ==============
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
def test_read_pptx_metadata(pptx_file):
"""
Test reading metadata from PowerPoint files.
Verifies that read() extracts metadata and populates keys_to_delete.
"""
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
handler = PowerpointHandler(pptx_file)
metadata = handler.read()
# Check metadata was extracted
assert handler.metadata == metadata
assert isinstance(metadata, dict)
# Check keys_to_delete is populated
assert handler.keys_to_delete is not None
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
def test_wipe_pptx_metadata(pptx_file):
"""
Test wiping metadata from PowerPoint files.
Verifies that wipe() prepares metadata for removal.
"""
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
handler = PowerpointHandler(pptx_file)
handler.read()
handler.wipe()
# processed_metadata should have entries set to None
assert handler.processed_metadata is not None
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
def test_save_processed_pptx_metadata(pptx_file):
"""
Test saving processed PowerPoint to output path.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = PowerpointHandler(pptx_file)
handler.read()
handler.wipe()
output_file = output_dir / Path(pptx_file).name
handler.save(str(output_file))
# Verify output file exists
assert output_file.exists()
# Cleanup
shutil.rmtree(output_dir)
def test_format_detection_pptx():
"""
Test that _detect_format() correctly identifies PPTX files.
"""
handler = PowerpointHandler(PPTX_TEST_FILE)
detected = handler._detect_format()
assert detected == "pptx"
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
def test_output_file_has_wiped_metadata(pptx_file):
"""
Test that the output file has metadata wiped.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = PowerpointHandler(pptx_file)
handler.read()
handler.wipe()
# Save processed file
output_file = output_dir / Path(pptx_file).name
handler.save(str(output_file))
# Verify output file exists and can be read
assert output_file.exists()
# Read output file and verify it's valid
try:
output_handler = PowerpointHandler(str(output_file))
output_metadata = output_handler.read()
# Just verify we can read it - the wipe worked if we're here
assert isinstance(output_metadata, dict)
except MetadataNotFoundError:
# If no metadata found, that's expected for fully stripped files
pass
# Cleanup
shutil.rmtree(output_dir)
def test_preserved_properties_not_deleted():
"""
Test that created, modified, language, last_printed, revision are preserved.
"""
handler = PowerpointHandler(PPTX_TEST_FILE)
handler.read()
# These should NOT be in keys_to_delete
assert "created" not in handler.keys_to_delete
assert "modified" not in handler.keys_to_delete
assert "language" not in handler.keys_to_delete
assert "last_printed" not in handler.keys_to_delete
assert "revision" not in handler.keys_to_delete
# ============== Error Case Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""
Test that non-PowerPoint files raise UnsupportedFormatError.
"""
# Create a fake text file with .txt extension
fake_file = tmp_path / "test.txt"
fake_file.write_text("not a powerpoint file")
handler = PowerpointHandler(str(fake_file))
with pytest.raises(UnsupportedFormatError):
handler._detect_format()
def test_save_without_output_path_raises_error():
"""
Test that save() raises ValueError when output_path is empty.
"""
handler = PowerpointHandler(PPTX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save("")
def test_save_with_none_raises_error():
"""
Test that save() raises ValueError when output_path is None.
"""
handler = PowerpointHandler(PPTX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises((ValueError, TypeError)):
handler.save(None)
# ============== Edge Case Tests ==============
def test_corrupted_pptx_graceful_error(tmp_path):
"""
Test that corrupted PowerPoint files are handled gracefully.
"""
# Create a corrupted PowerPoint file (invalid structure)
corrupted_pptx = tmp_path / "corrupted.pptx"
corrupted_pptx.write_bytes(b"not a valid pptx content at all")
handler = PowerpointHandler(str(corrupted_pptx))
# Should raise an exception from python-pptx
with pytest.raises(Exception):
handler.read()
def test_format_detection_all_pptx_types(tmp_path):
"""
Test format detection for all supported PowerPoint extensions.
"""
for ext in ["pptx", "pptm", "potx", "potm"]:
fake_file = tmp_path / f"test.{ext}"
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
handler = PowerpointHandler(str(fake_file))
detected = handler._detect_format()
assert detected == ext

View File

@ -0,0 +1,195 @@
"""
Unit tests for WorddocHandler.
Tests the WorddocHandler class in isolation, focusing on individual methods
and edge cases including missing metadata and corrupted files.
"""
import shutil
from pathlib import Path
import pytest
from src.services.worddoc_handler import WorddocHandler
from src.utils.exceptions import (
MetadataNotFoundError,
UnsupportedFormatError,
)
# Import path helpers from conftest
from tests.conftest import get_docx_test_file, get_large_docx_test_file
# Test file paths (cross-platform)
DOCX_TEST_FILE = get_docx_test_file()
LARGE_DOCX_TEST_FILE = get_large_docx_test_file()
# ============== Success Case Tests ==============
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
def test_read_docx_metadata(docx_file):
"""
Test reading metadata from Word document files.
Verifies that read() extracts metadata and populates keys_to_delete.
"""
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
handler = WorddocHandler(docx_file)
metadata = handler.read()
# Check metadata was extracted
assert handler.metadata == metadata
assert isinstance(metadata, dict)
# Check keys_to_delete is populated
assert handler.keys_to_delete is not None
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
def test_wipe_docx_metadata(docx_file):
"""
Test wiping metadata from Word document files.
Verifies that wipe() prepares metadata for removal.
"""
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
handler = WorddocHandler(docx_file)
handler.read()
handler.wipe()
# processed_metadata should have entries set to None
assert handler.processed_metadata is not None
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
def test_save_processed_docx_metadata(docx_file):
"""
Test saving processed Word document to output path.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
handler = WorddocHandler(docx_file)
handler.read()
handler.wipe()
output_file = output_dir / Path(docx_file).name
handler.save(str(output_file))
# Verify output file exists
assert output_file.exists()
# Cleanup
shutil.rmtree(output_dir)
def test_format_detection_docx():
"""
Test that _detect_format() correctly identifies DOCX files.
"""
handler = WorddocHandler(DOCX_TEST_FILE)
detected = handler._detect_format()
assert detected == "docx"
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
def test_output_file_has_wiped_metadata(docx_file):
"""
Test that the output file has metadata wiped.
"""
output_dir = Path("./tests/assets/output")
output_dir.mkdir(parents = True, exist_ok = True)
# Process original file
handler = WorddocHandler(docx_file)
handler.read()
handler.wipe()
# Save processed file
output_file = output_dir / Path(docx_file).name
handler.save(str(output_file))
# Verify output file exists and can be read
assert output_file.exists()
# Read output file and verify it's valid
try:
output_handler = WorddocHandler(str(output_file))
output_metadata = output_handler.read()
# Just verify we can read it - the wipe worked if we're here
assert isinstance(output_metadata, dict)
except MetadataNotFoundError:
# If no metadata found, that's expected for fully stripped files
pass
# Cleanup
shutil.rmtree(output_dir)
def test_preserved_properties_not_deleted():
"""
Test that created, modified, language, last_printed, revision are preserved.
"""
handler = WorddocHandler(DOCX_TEST_FILE)
handler.read()
# These should NOT be in keys_to_delete
assert "created" not in handler.keys_to_delete
assert "modified" not in handler.keys_to_delete
assert "language" not in handler.keys_to_delete
assert "last_printed" not in handler.keys_to_delete
assert "revision" not in handler.keys_to_delete
# ============== Error Case Tests ==============
def test_unsupported_format_raises_error(tmp_path):
"""
Test that non-Word document files raise UnsupportedFormatError.
"""
# Create a fake text file with .txt extension
fake_file = tmp_path / "test.txt"
fake_file.write_text("not a word document")
handler = WorddocHandler(str(fake_file))
with pytest.raises(UnsupportedFormatError):
handler._detect_format()
def test_save_without_output_path_raises_error():
"""
Test that save() raises ValueError when output_path is empty.
"""
handler = WorddocHandler(DOCX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises(ValueError):
handler.save("")
def test_save_with_none_raises_error():
"""
Test that save() raises ValueError when output_path is None.
"""
handler = WorddocHandler(DOCX_TEST_FILE)
handler.read()
handler.wipe()
with pytest.raises((ValueError, TypeError)):
handler.save(None)
# ============== Edge Case Tests ==============
def test_corrupted_docx_graceful_error(tmp_path):
"""
Test that corrupted Word document files are handled gracefully.
"""
# Create a corrupted DOCX file (invalid structure)
corrupted_docx = tmp_path / "corrupted.docx"
corrupted_docx.write_bytes(b"not a valid docx content at all")
handler = WorddocHandler(str(corrupted_docx))
# Should raise an exception from python-docx
with pytest.raises(Exception):
handler.read()

View File

@ -0,0 +1,15 @@
"""
This type stub file was generated by pyright.
"""
from ._dump import dump as dump
from ._exceptions import *
from ._exif import *
from ._insert import insert as insert
from ._load import load as load
from ._remove import remove as remove
from ._transplant import transplant as transplant
__all__ = ["remove", "load", "dump", "transplant", "insert", "VERSION"]
VERSION: str

View File

@ -0,0 +1,23 @@
"""
This type stub file was generated by pyright.
"""
def split_into_segments(data): # -> list[bytes]:
"""Slices JPEG meta data into a list from JPEG binary data.
"""
...
def read_exif_from_file(filename): # -> bytes | None:
"""Slices JPEG meta data into a list from JPEG binary data.
"""
...
def get_exif_seg(segments): # -> None:
"""Returns Exif from JPEG meta data list
"""
...
def merge_segments(segments, exif=...): # -> bytes:
"""Merges Exif with APP0 and APP1 manipulations.
"""
...

View File

@ -0,0 +1,19 @@
"""
This type stub file was generated by pyright.
"""
from ._common import *
from ._exif import *
TIFF_HEADER_LENGTH = ...
def dump(exif_dict_original): # -> bytes:
"""
py:function:: piexif.load(data)
Return exif as bytes.
:param dict exif: Exif data({"0th":dict, "Exif":dict, "GPS":dict, "Interop":dict, "1st":dict, "thumbnail":bytes})
:return: Exif
:rtype: bytes
"""
...

View File

@ -0,0 +1,6 @@
"""
This type stub file was generated by pyright.
"""
class InvalidImageDataError(ValueError):
...

View File

@ -0,0 +1,341 @@
"""
Type stubs for piexif EXIF tag definitions.
"""
from typing import Final
class TYPES:
"""EXIF data type identifiers."""
Byte: Final[int] = ...
Ascii: Final[int] = ...
Short: Final[int] = ...
Long: Final[int] = ...
Rational: Final[int] = ...
SByte: Final[int] = ...
Undefined: Final[int] = ...
SShort: Final[int] = ...
SLong: Final[int] = ...
SRational: Final[int] = ...
Float: Final[int] = ...
DFloat: Final[int] = ...
TAGS: dict[str, dict[int, dict[str, str | int]]]
class ImageIFD:
"""Exif tag number reference - 0th IFD"""
ProcessingSoftware: Final[int] = ...
NewSubfileType: Final[int] = ...
SubfileType: Final[int] = ...
ImageWidth: Final[int] = ...
ImageLength: Final[int] = ...
BitsPerSample: Final[int] = ...
Compression: Final[int] = ...
PhotometricInterpretation: Final[int] = ...
Threshholding: Final[int] = ...
CellWidth: Final[int] = ...
CellLength: Final[int] = ...
FillOrder: Final[int] = ...
DocumentName: Final[int] = ...
ImageDescription: Final[int] = ...
Make: Final[int] = ...
Model: Final[int] = ...
StripOffsets: Final[int] = ...
Orientation: Final[int] = ...
SamplesPerPixel: Final[int] = ...
RowsPerStrip: Final[int] = ...
StripByteCounts: Final[int] = ...
XResolution: Final[int] = ...
YResolution: Final[int] = ...
PlanarConfiguration: Final[int] = ...
GrayResponseUnit: Final[int] = ...
GrayResponseCurve: Final[int] = ...
T4Options: Final[int] = ...
T6Options: Final[int] = ...
ResolutionUnit: Final[int] = ...
TransferFunction: Final[int] = ...
Software: Final[int] = ...
DateTime: Final[int] = ...
Artist: Final[int] = ...
HostComputer: Final[int] = ...
Predictor: Final[int] = ...
WhitePoint: Final[int] = ...
PrimaryChromaticities: Final[int] = ...
ColorMap: Final[int] = ...
HalftoneHints: Final[int] = ...
TileWidth: Final[int] = ...
TileLength: Final[int] = ...
TileOffsets: Final[int] = ...
TileByteCounts: Final[int] = ...
SubIFDs: Final[int] = ...
InkSet: Final[int] = ...
InkNames: Final[int] = ...
NumberOfInks: Final[int] = ...
DotRange: Final[int] = ...
TargetPrinter: Final[int] = ...
ExtraSamples: Final[int] = ...
SampleFormat: Final[int] = ...
SMinSampleValue: Final[int] = ...
SMaxSampleValue: Final[int] = ...
TransferRange: Final[int] = ...
ClipPath: Final[int] = ...
XClipPathUnits: Final[int] = ...
YClipPathUnits: Final[int] = ...
Indexed: Final[int] = ...
JPEGTables: Final[int] = ...
OPIProxy: Final[int] = ...
JPEGProc: Final[int] = ...
JPEGInterchangeFormat: Final[int] = ...
JPEGInterchangeFormatLength: Final[int] = ...
JPEGRestartInterval: Final[int] = ...
JPEGLosslessPredictors: Final[int] = ...
JPEGPointTransforms: Final[int] = ...
JPEGQTables: Final[int] = ...
JPEGDCTables: Final[int] = ...
JPEGACTables: Final[int] = ...
YCbCrCoefficients: Final[int] = ...
YCbCrSubSampling: Final[int] = ...
YCbCrPositioning: Final[int] = ...
ReferenceBlackWhite: Final[int] = ...
XMLPacket: Final[int] = ...
Rating: Final[int] = ...
RatingPercent: Final[int] = ...
ImageID: Final[int] = ...
CFARepeatPatternDim: Final[int] = ...
CFAPattern: Final[int] = ...
BatteryLevel: Final[int] = ...
Copyright: Final[int] = ...
ExposureTime: Final[int] = ...
ImageResources: Final[int] = ...
ExifTag: Final[int] = ...
InterColorProfile: Final[int] = ...
GPSTag: Final[int] = ...
Interlace: Final[int] = ...
TimeZoneOffset: Final[int] = ...
SelfTimerMode: Final[int] = ...
FlashEnergy: Final[int] = ...
SpatialFrequencyResponse: Final[int] = ...
Noise: Final[int] = ...
FocalPlaneXResolution: Final[int] = ...
FocalPlaneYResolution: Final[int] = ...
FocalPlaneResolutionUnit: Final[int] = ...
ImageNumber: Final[int] = ...
SecurityClassification: Final[int] = ...
ImageHistory: Final[int] = ...
ExposureIndex: Final[int] = ...
TIFFEPStandardID: Final[int] = ...
SensingMethod: Final[int] = ...
XPTitle: Final[int] = ...
XPComment: Final[int] = ...
XPAuthor: Final[int] = ...
XPKeywords: Final[int] = ...
XPSubject: Final[int] = ...
PrintImageMatching: Final[int] = ...
DNGVersion: Final[int] = ...
DNGBackwardVersion: Final[int] = ...
UniqueCameraModel: Final[int] = ...
LocalizedCameraModel: Final[int] = ...
CFAPlaneColor: Final[int] = ...
CFALayout: Final[int] = ...
LinearizationTable: Final[int] = ...
BlackLevelRepeatDim: Final[int] = ...
BlackLevel: Final[int] = ...
BlackLevelDeltaH: Final[int] = ...
BlackLevelDeltaV: Final[int] = ...
WhiteLevel: Final[int] = ...
DefaultScale: Final[int] = ...
DefaultCropOrigin: Final[int] = ...
DefaultCropSize: Final[int] = ...
ColorMatrix1: Final[int] = ...
ColorMatrix2: Final[int] = ...
CameraCalibration1: Final[int] = ...
CameraCalibration2: Final[int] = ...
ReductionMatrix1: Final[int] = ...
ReductionMatrix2: Final[int] = ...
AnalogBalance: Final[int] = ...
AsShotNeutral: Final[int] = ...
AsShotWhiteXY: Final[int] = ...
BaselineExposure: Final[int] = ...
BaselineNoise: Final[int] = ...
BaselineSharpness: Final[int] = ...
BayerGreenSplit: Final[int] = ...
LinearResponseLimit: Final[int] = ...
CameraSerialNumber: Final[int] = ...
LensInfo: Final[int] = ...
ChromaBlurRadius: Final[int] = ...
AntiAliasStrength: Final[int] = ...
ShadowScale: Final[int] = ...
DNGPrivateData: Final[int] = ...
MakerNoteSafety: Final[int] = ...
CalibrationIlluminant1: Final[int] = ...
CalibrationIlluminant2: Final[int] = ...
BestQualityScale: Final[int] = ...
RawDataUniqueID: Final[int] = ...
OriginalRawFileName: Final[int] = ...
OriginalRawFileData: Final[int] = ...
ActiveArea: Final[int] = ...
MaskedAreas: Final[int] = ...
AsShotICCProfile: Final[int] = ...
AsShotPreProfileMatrix: Final[int] = ...
CurrentICCProfile: Final[int] = ...
CurrentPreProfileMatrix: Final[int] = ...
ColorimetricReference: Final[int] = ...
CameraCalibrationSignature: Final[int] = ...
ProfileCalibrationSignature: Final[int] = ...
AsShotProfileName: Final[int] = ...
NoiseReductionApplied: Final[int] = ...
ProfileName: Final[int] = ...
ProfileHueSatMapDims: Final[int] = ...
ProfileHueSatMapData1: Final[int] = ...
ProfileHueSatMapData2: Final[int] = ...
ProfileToneCurve: Final[int] = ...
ProfileEmbedPolicy: Final[int] = ...
ProfileCopyright: Final[int] = ...
ForwardMatrix1: Final[int] = ...
ForwardMatrix2: Final[int] = ...
PreviewApplicationName: Final[int] = ...
PreviewApplicationVersion: Final[int] = ...
PreviewSettingsName: Final[int] = ...
PreviewSettingsDigest: Final[int] = ...
PreviewColorSpace: Final[int] = ...
PreviewDateTime: Final[int] = ...
RawImageDigest: Final[int] = ...
OriginalRawFileDigest: Final[int] = ...
SubTileBlockSize: Final[int] = ...
RowInterleaveFactor: Final[int] = ...
ProfileLookTableDims: Final[int] = ...
ProfileLookTableData: Final[int] = ...
OpcodeList1: Final[int] = ...
OpcodeList2: Final[int] = ...
OpcodeList3: Final[int] = ...
NoiseProfile: Final[int] = ...
ZZZTestSlong1: Final[int] = ...
ZZZTestSlong2: Final[int] = ...
ZZZTestSByte: Final[int] = ...
ZZZTestSShort: Final[int] = ...
ZZZTestDFloat: Final[int] = ...
class ExifIFD:
"""Exif tag number reference - Exif IFD"""
ExposureTime: Final[int] = ...
FNumber: Final[int] = ...
ExposureProgram: Final[int] = ...
SpectralSensitivity: Final[int] = ...
ISOSpeedRatings: Final[int] = ...
OECF: Final[int] = ...
SensitivityType: Final[int] = ...
StandardOutputSensitivity: Final[int] = ...
RecommendedExposureIndex: Final[int] = ...
ISOSpeed: Final[int] = ...
ISOSpeedLatitudeyyy: Final[int] = ...
ISOSpeedLatitudezzz: Final[int] = ...
ExifVersion: Final[int] = ...
DateTimeOriginal: Final[int] = ...
DateTimeDigitized: Final[int] = ...
OffsetTime: Final[int] = ...
OffsetTimeOriginal: Final[int] = ...
OffsetTimeDigitized: Final[int] = ...
ComponentsConfiguration: Final[int] = ...
CompressedBitsPerPixel: Final[int] = ...
ShutterSpeedValue: Final[int] = ...
ApertureValue: Final[int] = ...
BrightnessValue: Final[int] = ...
ExposureBiasValue: Final[int] = ...
MaxApertureValue: Final[int] = ...
SubjectDistance: Final[int] = ...
MeteringMode: Final[int] = ...
LightSource: Final[int] = ...
Flash: Final[int] = ...
FocalLength: Final[int] = ...
Temperature: Final[int] = ...
Humidity: Final[int] = ...
Pressure: Final[int] = ...
WaterDepth: Final[int] = ...
Acceleration: Final[int] = ...
CameraElevationAngle: Final[int] = ...
SubjectArea: Final[int] = ...
MakerNote: Final[int] = ...
UserComment: Final[int] = ...
SubSecTime: Final[int] = ...
SubSecTimeOriginal: Final[int] = ...
SubSecTimeDigitized: Final[int] = ...
FlashpixVersion: Final[int] = ...
ColorSpace: Final[int] = ...
PixelXDimension: Final[int] = ...
PixelYDimension: Final[int] = ...
RelatedSoundFile: Final[int] = ...
InteroperabilityTag: Final[int] = ...
FlashEnergy: Final[int] = ...
SpatialFrequencyResponse: Final[int] = ...
FocalPlaneXResolution: Final[int] = ...
FocalPlaneYResolution: Final[int] = ...
FocalPlaneResolutionUnit: Final[int] = ...
SubjectLocation: Final[int] = ...
ExposureIndex: Final[int] = ...
SensingMethod: Final[int] = ...
FileSource: Final[int] = ...
SceneType: Final[int] = ...
CFAPattern: Final[int] = ...
CustomRendered: Final[int] = ...
ExposureMode: Final[int] = ...
WhiteBalance: Final[int] = ...
DigitalZoomRatio: Final[int] = ...
FocalLengthIn35mmFilm: Final[int] = ...
SceneCaptureType: Final[int] = ...
GainControl: Final[int] = ...
Contrast: Final[int] = ...
Saturation: Final[int] = ...
Sharpness: Final[int] = ...
DeviceSettingDescription: Final[int] = ...
SubjectDistanceRange: Final[int] = ...
ImageUniqueID: Final[int] = ...
CameraOwnerName: Final[int] = ...
BodySerialNumber: Final[int] = ...
LensSpecification: Final[int] = ...
LensMake: Final[int] = ...
LensModel: Final[int] = ...
LensSerialNumber: Final[int] = ...
Gamma: Final[int] = ...
class GPSIFD:
"""Exif tag number reference - GPS IFD"""
GPSVersionID: Final[int] = ...
GPSLatitudeRef: Final[int] = ...
GPSLatitude: Final[int] = ...
GPSLongitudeRef: Final[int] = ...
GPSLongitude: Final[int] = ...
GPSAltitudeRef: Final[int] = ...
GPSAltitude: Final[int] = ...
GPSTimeStamp: Final[int] = ...
GPSSatellites: Final[int] = ...
GPSStatus: Final[int] = ...
GPSMeasureMode: Final[int] = ...
GPSDOP: Final[int] = ...
GPSSpeedRef: Final[int] = ...
GPSSpeed: Final[int] = ...
GPSTrackRef: Final[int] = ...
GPSTrack: Final[int] = ...
GPSImgDirectionRef: Final[int] = ...
GPSImgDirection: Final[int] = ...
GPSMapDatum: Final[int] = ...
GPSDestLatitudeRef: Final[int] = ...
GPSDestLatitude: Final[int] = ...
GPSDestLongitudeRef: Final[int] = ...
GPSDestLongitude: Final[int] = ...
GPSDestBearingRef: Final[int] = ...
GPSDestBearing: Final[int] = ...
GPSDestDistanceRef: Final[int] = ...
GPSDestDistance: Final[int] = ...
GPSProcessingMethod: Final[int] = ...
GPSAreaInformation: Final[int] = ...
GPSDateStamp: Final[int] = ...
GPSDifferential: Final[int] = ...
GPSHPositioningError: Final[int] = ...
class InteropIFD:
"""Exif tag number reference - Interoperability IFD"""
InteroperabilityIndex: Final[int] = ...

View File

@ -0,0 +1,16 @@
"""
This type stub file was generated by pyright.
"""
from ._common import *
def insert(exif, image, new_file=...): # -> None:
"""
py:function:: piexif.insert(exif_bytes, filename)
Insert exif into JPEG.
:param bytes exif_bytes: Exif as bytes
:param str filename: JPEG
"""
...

View File

@ -0,0 +1,29 @@
"""
This type stub file was generated by pyright.
"""
from ._common import *
from ._exif import *
LITTLE_ENDIAN = ...
def load(input_data, key_is_name=...): # -> dict[str, Any]:
"""
py:function:: piexif.load(filename)
Return exif data as dict. Keys(IFD name), be contained, are "0th", "Exif", "GPS", "Interop", "1st", and "thumbnail". Without "thumbnail", the value is dict(tag name/tag value). "thumbnail" value is JPEG as bytes.
:param str filename: JPEG or TIFF
:return: Exif data({"0th":dict, "Exif":dict, "GPS":dict, "Interop":dict, "1st":dict, "thumbnail":bytes})
:rtype: dict
"""
...
class _ExifReader:
def __init__(self, data) -> None:
...
def get_ifd_dict(self, pointer, ifd_name, read_unknown=...): # -> dict[Any, Any]:
...
def convert_value(self, val): # -> Any | tuple[Any, Any] | bytes | tuple[Any, ...] | tuple[tuple[Any, Any], ...]:
...

View File

@ -0,0 +1,15 @@
"""
This type stub file was generated by pyright.
"""
from ._common import *
def remove(src, new_file=...): # -> None:
"""
py:function:: piexif.remove(filename)
Remove exif from JPEG.
:param str filename: JPEG
"""
...

View File

@ -0,0 +1,16 @@
"""
This type stub file was generated by pyright.
"""
from ._common import *
def transplant(exif_src, image, new_file=...): # -> None:
"""
py:function:: piexif.transplant(filename1, filename2)
Transplant exif from filename1 to filename2.
:param str filename1: JPEG
:param str filename2: JPEG
"""
...

View File

@ -0,0 +1,27 @@
"""
This type stub file was generated by pyright.
"""
def split(data): # -> list[Any]:
...
def merge_chunks(chunks): # -> bytes:
...
def set_vp8x(chunks):
...
def get_file_header(chunks): # -> bytes:
...
def get_exif(data): # -> None:
...
def insert_exif_into_chunks(chunks, exif_bytes):
...
def insert(webp_bytes, exif_bytes): # -> bytes:
...
def remove(webp_bytes): # -> bytes:
...

View File

@ -0,0 +1,41 @@
"""
This type stub file was generated by pyright.
"""
class UserComment:
ASCII = ...
JIS = ...
UNICODE = ...
ENCODINGS = ...
_JIS = ...
_UNICODE = ...
_PREFIX_SIZE = ...
_ASCII_PREFIX = ...
_JIS_PREFIX = ...
_UNICODE_PREFIX = ...
_UNDEFINED_PREFIX = ...
@classmethod
def load(cls, data):
"""
Convert "UserComment" value in exif format to str.
:param bytes data: "UserComment" value from exif
:return: u"foobar"
:rtype: str(Unicode)
:raises: ValueError if the data does not conform to the EXIF specification,
or the encoding is unsupported.
"""
...
@classmethod
def dump(cls, data, encoding=...):
"""
Convert str to appropriate format for "UserComment".
:param data: Like u"foobar"
:param str encoding: "ascii", "jis", or "unicode"
:return: b"ASCII\x00\x00\x00foobar"
:rtype: bytes
:raises: ValueError if the encoding is unsupported.
"""
...

View File

@ -0,0 +1,856 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" },
{ url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" },
{ url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" },
{ url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" },
{ url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" },
{ url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" },
{ url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" },
{ url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" },
{ url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" },
{ url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" },
{ url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" },
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.7.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/84/2cfb1f3b9b60bab52e16a220c931223fc8e963d0d7bb9132bef012aafc3f/librt-0.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4836c5645f40fbdc275e5670819bde5ab5f2e882290d304e3c6ddab1576a6d0", size = 54709, upload-time = "2026-01-01T23:50:48.326Z" },
{ url = "https://files.pythonhosted.org/packages/19/a1/3127b277e9d3784a8040a54e8396d9ae5c64d6684dc6db4b4089b0eedcfb/librt-0.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae8aec43117a645a31e5f60e9e3a0797492e747823b9bda6972d521b436b4e8", size = 56658, upload-time = "2026-01-01T23:50:49.74Z" },
{ url = "https://files.pythonhosted.org/packages/3a/e9/b91b093a5c42eb218120445f3fef82e0b977fa2225f4d6fc133d25cdf86a/librt-0.7.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aea05f701ccd2a76b34f0daf47ca5068176ff553510b614770c90d76ac88df06", size = 161026, upload-time = "2026-01-01T23:50:50.853Z" },
{ url = "https://files.pythonhosted.org/packages/c7/cb/1ded77d5976a79d7057af4a010d577ce4f473ff280984e68f4974a3281e5/librt-0.7.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b16ccaeff0ed4355dfb76fe1ea7a5d6d03b5ad27f295f77ee0557bc20a72495", size = 169529, upload-time = "2026-01-01T23:50:52.24Z" },
{ url = "https://files.pythonhosted.org/packages/da/6e/6ca5bdaa701e15f05000ac1a4c5d1475c422d3484bd3d1ca9e8c2f5be167/librt-0.7.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c7e150c095d5e3cea7452347ba26094be905d6099d24f9319a8b475fcd3e0", size = 183271, upload-time = "2026-01-01T23:50:55.287Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2d/55c0e38073997b4bbb5ddff25b6d1bbba8c2f76f50afe5bb9c844b702f34/librt-0.7.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dcee2f921a8632636d1c37f1bbdb8841d15666d119aa61e5399c5268e7ce02e", size = 179039, upload-time = "2026-01-01T23:50:56.807Z" },
{ url = "https://files.pythonhosted.org/packages/33/4e/3662a41ae8bb81b226f3968426293517b271d34d4e9fd4b59fc511f1ae40/librt-0.7.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14ef0f4ac3728ffd85bfc58e2f2f48fb4ef4fa871876f13a73a7381d10a9f77c", size = 173505, upload-time = "2026-01-01T23:50:58.291Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5d/cf768deb8bdcbac5f8c21fcb32dd483d038d88c529fd351bbe50590b945d/librt-0.7.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e4ab69fa37f8090f2d971a5d2bc606c7401170dbdae083c393d6cbf439cb45b8", size = 193570, upload-time = "2026-01-01T23:50:59.546Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/ee70effd13f1d651976d83a2812391f6203971740705e3c0900db75d4bce/librt-0.7.7-cp310-cp310-win32.whl", hash = "sha256:4bf3cc46d553693382d2abf5f5bd493d71bb0f50a7c0beab18aa13a5545c8900", size = 42600, upload-time = "2026-01-01T23:51:00.694Z" },
{ url = "https://files.pythonhosted.org/packages/f0/eb/dc098730f281cba76c279b71783f5de2edcba3b880c1ab84a093ef826062/librt-0.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:f0c8fe5aeadd8a0e5b0598f8a6ee3533135ca50fd3f20f130f9d72baf5c6ac58", size = 48977, upload-time = "2026-01-01T23:51:01.726Z" },
{ url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" },
{ url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" },
{ url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" },
{ url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" },
{ url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" },
{ url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" },
{ url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" },
{ url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" },
{ url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" },
{ url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" },
{ url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" },
{ url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" },
{ url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" },
{ url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" },
{ url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" },
{ url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" },
{ url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" },
{ url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" },
{ url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" },
{ url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" },
{ url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" },
{ url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" },
{ url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" },
{ url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" },
{ url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" },
{ url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" },
{ url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" },
{ url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" },
{ url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" },
{ url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" },
{ url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" },
{ url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" },
{ url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" },
{ url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" },
{ url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" },
{ url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" },
{ url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" },
{ url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" },
{ url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" },
{ url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" },
{ url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" },
{ url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" },
{ url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" },
{ url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" },
{ url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" },
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
{ url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" },
{ url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" },
{ url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" },
{ url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "metadata-scrubber"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "openpyxl" },
{ name = "piexif" },
{ name = "pillow" },
{ name = "pypdf" },
{ name = "python-docx" },
{ name = "python-pptx" },
{ name = "rich" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "yapf" },
]
[package.metadata]
requires-dist = [
{ 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 = "python-docx", specifier = ">=1.2.0" },
{ name = "python-pptx", specifier = ">=1.0.2" },
{ name = "rich", specifier = ">=14.0.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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" },
{ url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" },
{ url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" },
{ url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
{ url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pathspec"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
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/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]]
name = "piexif"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" },
]
[[package]]
name = "pillow"
version = "12.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" },
{ url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" },
{ url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" },
{ url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" },
{ url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" },
{ url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" },
{ url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" },
{ url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" },
{ url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" },
{ url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" },
{ url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
{ url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
{ url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
{ url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" },
{ url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" },
{ url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" },
{ url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" },
{ url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pypdf"
version = "6.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/9b/db1056a54eda8cd44f9e5128e87e1142cb328295dad92bbec0d39f251641/pypdf-6.5.0.tar.gz", hash = "sha256:9e78950906380ae4f2ce1d9039e9008098ba6366a4d9c7423c4bdbd6e6683404", size = 5277655, upload-time = "2025-12-21T11:07:19.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/db/f2e7703791a1f32532618b82789ddddb7173b9e22d97e34cc11950d8e330/pypdf-6.5.0-py3-none-any.whl", hash = "sha256:9cef8002aaedeecf648dfd9ff1ce38f20ae8d88e2534fced6630038906440b25", size = 329560, upload-time = "2025-12-21T11:07:18.173Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-pptx"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "pillow" },
{ name = "typing-extensions" },
{ name = "xlsxwriter" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "ruff"
version = "0.14.11"
source = { registry = "https://pypi.org/simple" }
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/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]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "typer"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "xlsxwriter"
version = "3.2.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]
[[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" },
]