Add 'PROJECTS/metadata-scrubber-tool/' from commit '250a648855780c5f1a9268f9cffef4c65f82cbbc'
git-subtree-dir: PROJECTS/metadata-scrubber-tool git-subtree-mainline:c801a0be53git-subtree-split:250a648855
This commit is contained in:
commit
38aa333d3d
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -0,0 +1 @@
|
|||
3.14
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# ⒸAngelaMos | 2025 | CarterPerez-dev
|
||||
[style]
|
||||
based_on_style = pep8
|
||||
column_limit = 89
|
||||
indent_width = 4
|
||||
continuation_indent_width = 4
|
||||
indent_closing_brackets = false
|
||||
dedent_closing_brackets = true
|
||||
indent_blank_lines = false
|
||||
spaces_before_comment = 2
|
||||
spaces_around_power_operator = false
|
||||
spaces_around_default_or_named_assign = true
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
space_inside_brackets = false
|
||||
spaces_around_subscript_colon = true
|
||||
blank_line_before_nested_class_or_def = false
|
||||
blank_line_before_class_docstring = false
|
||||
blank_lines_around_top_level_definition = 2
|
||||
blank_lines_between_top_level_imports_and_variables = 2
|
||||
blank_line_before_module_docstring = false
|
||||
split_before_logical_operator = true
|
||||
split_before_first_argument = true
|
||||
split_before_named_assigns = true
|
||||
split_complex_comprehension = true
|
||||
split_before_expression_after_opening_paren = false
|
||||
split_before_closing_bracket = true
|
||||
split_all_comma_separated_values = true
|
||||
split_all_top_level_comma_separated_values = false
|
||||
coalesce_brackets = false
|
||||
each_dict_entry_on_separate_line = true
|
||||
allow_multiline_lambdas = false
|
||||
allow_multiline_dictionary_keys = false
|
||||
split_penalty_import_names = 0
|
||||
join_multiple_lines = false
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
arithmetic_precedence_indication = false
|
||||
split_penalty_for_added_line_split = 275
|
||||
use_tabs = false
|
||||
split_before_dot = false
|
||||
split_arguments_when_comma_terminated = true
|
||||
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
|
||||
i18n_comment = ['# Translators:', '# i18n:']
|
||||
split_penalty_comprehension = 80
|
||||
split_penalty_after_opening_bracket = 280
|
||||
split_penalty_before_if_expr = 0
|
||||
split_penalty_bitwise_operator = 290
|
||||
split_penalty_logical_operator = 0
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.venv/
|
||||
venv/
|
||||
env/
|
||||
typings/
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||
[](https://github.com/Heritage-XioN/metadata-scrubber-tool/actions)
|
||||
[](https://python.org)
|
||||
[](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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: metadata-scrubber
|
||||
volumes:
|
||||
- .:/app
|
||||
env_file:
|
||||
- .env
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Best Practices for Secure File Sharing
|
||||
|
||||
Guidelines for protecting your privacy when sharing files.
|
||||
|
||||
## Before Sharing Files
|
||||
|
||||
### 1. Always Check Metadata First
|
||||
|
||||
```bash
|
||||
mst read yourfile.pdf
|
||||
```
|
||||
|
||||
Review the output for any sensitive information.
|
||||
|
||||
### 2. Scrub Before Sharing
|
||||
|
||||
```bash
|
||||
mst scrub yourfile.pdf --output ./clean
|
||||
```
|
||||
|
||||
Always use the cleaned version for sharing.
|
||||
|
||||
### 3. Verify Removal
|
||||
|
||||
```bash
|
||||
mst verify yourfile.pdf ./clean/processed_yourfile.pdf
|
||||
```
|
||||
|
||||
Confirm sensitive data was actually removed.
|
||||
|
||||
---
|
||||
|
||||
## File Type Specific Guidance
|
||||
|
||||
### 📸 Images
|
||||
|
||||
**High Risk:** Photos from smartphones contain GPS coordinates by default.
|
||||
|
||||
```bash
|
||||
# Batch process all photos before uploading
|
||||
mst scrub ./photos -r -ext jpg --output ./clean
|
||||
```
|
||||
|
||||
**Tip:** Disable location services for your camera app to prevent GPS embedding.
|
||||
|
||||
### 📄 PDF Documents
|
||||
|
||||
**High Risk:** PDFs often contain author name, creator software, and sometimes revision history.
|
||||
|
||||
```bash
|
||||
mst scrub report.pdf --output ./clean
|
||||
```
|
||||
|
||||
**Note:** This tool removes document properties. For redacting visible content, use a dedicated PDF editor.
|
||||
|
||||
### 📊 Office Documents (Word, Excel, PowerPoint)
|
||||
|
||||
**High Risk:** Office files store author, company, and modification history.
|
||||
|
||||
```bash
|
||||
# Process all Word documents
|
||||
mst scrub ./documents -r -ext docx --output ./clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
### For Regular File Sharing
|
||||
|
||||
1. Create a "clean" folder for processed files
|
||||
2. Before sharing, always scrub to that folder
|
||||
3. Share only from the clean folder
|
||||
|
||||
### For Automated Pipelines
|
||||
|
||||
```bash
|
||||
# In CI/CD or scripts
|
||||
mst scrub ./output -r -ext pdf --output ./publish
|
||||
```
|
||||
|
||||
### For Verification
|
||||
|
||||
```bash
|
||||
# Verify before publishing
|
||||
mst verify original.pdf ./publish/processed_original.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Preserved (And Why)
|
||||
|
||||
| Property | Reason |
|
||||
|----------|--------|
|
||||
| Created Date | File organization, audit trails |
|
||||
| Modified Date | Version tracking |
|
||||
| Language | Accessibility, screen readers |
|
||||
|
||||
These properties are generally not privacy-sensitive and are often needed for file management.
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Sharing the original instead of the processed file**
|
||||
- Always verify you're sharing from the output folder
|
||||
|
||||
2. **Not checking the output**
|
||||
- Use `mst verify` to confirm removal
|
||||
|
||||
3. **Forgetting about PDFs**
|
||||
- PDF author metadata is often overlooked
|
||||
|
||||
4. **Batch processing without verification**
|
||||
- Spot-check a few files after batch processing
|
||||
|
||||
5. **Assuming social media strips metadata**
|
||||
- Many platforms compress but don't fully remove metadata
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Read metadata
|
||||
mst read file.jpg
|
||||
|
||||
# Scrub single file
|
||||
mst scrub file.jpg --output ./clean
|
||||
|
||||
# Scrub directory
|
||||
mst scrub ./folder -r -ext jpg --output ./clean
|
||||
|
||||
# Preview without changes
|
||||
mst scrub ./folder -r -ext jpg --dry-run
|
||||
|
||||
# Verify removal
|
||||
mst verify original.jpg ./clean/processed_original.jpg
|
||||
```
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# Metadata Privacy Risks
|
||||
|
||||
Understanding why metadata removal matters for privacy and security.
|
||||
|
||||
## What is Metadata?
|
||||
|
||||
Metadata is "data about data" - hidden information embedded in files that describes when, where, how, and by whom a file was created or modified.
|
||||
|
||||
## Common Metadata Types
|
||||
|
||||
### 📸 Images (JPEG, PNG)
|
||||
|
||||
| Metadata Type | Privacy Risk |
|
||||
|---------------|--------------|
|
||||
| **GPS Coordinates** | Reveals exact location where photo was taken |
|
||||
| **Camera Model** | Can identify specific device, linked to owner |
|
||||
| **Date/Time** | Reveals when photo was taken |
|
||||
| **Software** | Shows editing tools used |
|
||||
| **Thumbnail** | May contain original uncropped image |
|
||||
|
||||
### 📄 Documents (Word, PDF)
|
||||
|
||||
| Metadata Type | Privacy Risk |
|
||||
|---------------|--------------|
|
||||
| **Author** | Reveals creator's name/username |
|
||||
| **Company** | Exposes employer information |
|
||||
| **Last Modified By** | Shows who edited the document |
|
||||
| **Comments** | May contain internal notes |
|
||||
| **Revision History** | Can expose previous versions |
|
||||
|
||||
### 📊 Spreadsheets (Excel)
|
||||
|
||||
| Metadata Type | Privacy Risk |
|
||||
|---------------|--------------|
|
||||
| **Author** | Reveals creator identity |
|
||||
| **Company** | Exposes organization |
|
||||
| **Keywords** | May reveal document purpose |
|
||||
| **Custom Properties** | Internal tracking data |
|
||||
|
||||
### 📽️ Presentations (PowerPoint)
|
||||
|
||||
| Metadata Type | Privacy Risk |
|
||||
|---------------|--------------|
|
||||
| **Author** | Creator identity |
|
||||
| **Title/Subject** | May reveal confidential topics |
|
||||
| **Comments** | Presenter notes, internal feedback |
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Case 1: Location Leak via Photo
|
||||
A journalist shared a photo online. The embedded GPS coordinates revealed their safe house location, putting sources at risk.
|
||||
|
||||
### Case 2: Internal Document Exposed
|
||||
A company released a redacted PDF. The author metadata still showed an employee's full name and the legal department.
|
||||
|
||||
### Case 3: Anonymous Submission Failed
|
||||
A whistleblower submitted documents "anonymously" but the Word metadata contained their username and computer name.
|
||||
|
||||
---
|
||||
|
||||
## High-Risk Scenarios
|
||||
|
||||
1. **Sharing photos on social media** - GPS can reveal home address
|
||||
2. **Sending documents to clients** - Author reveals internal usernames
|
||||
3. **Publishing reports externally** - Revision history may expose drafts
|
||||
4. **Submitting anonymous tips** - Metadata can identify the source
|
||||
5. **Legal discovery** - Metadata is often requested in litigation
|
||||
|
||||
---
|
||||
|
||||
## What This Tool Removes
|
||||
|
||||
| Category | Removed Properties |
|
||||
|----------|-------------------|
|
||||
| **Identity** | Author, Last Modified By, Creator |
|
||||
| **Location** | GPS Latitude, Longitude, Altitude |
|
||||
| **Device** | Camera Model, Software, Device ID |
|
||||
| **Tracking** | Comments, Keywords, Custom Properties |
|
||||
|
||||
### Preserved (Intentionally)
|
||||
|
||||
- **Created Date** - Often needed for file organization
|
||||
- **Modified Date** - Required for version tracking
|
||||
- **Language** - Accessibility purposes
|
||||
|
||||
---
|
||||
|
||||
## Best Practice
|
||||
|
||||
**Always scrub metadata before sharing files externally.**
|
||||
|
||||
```bash
|
||||
# Check what metadata exists
|
||||
mst read document.pdf
|
||||
|
||||
# Remove metadata
|
||||
mst scrub document.pdf --output ./clean
|
||||
|
||||
# Verify removal
|
||||
mst verify document.pdf ./clean/processed_document.pdf
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from metadata-scrubber-tool!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"stubPath": "./typings",
|
||||
"reportMissingTypeStubs": false,
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.12",
|
||||
"typeCheckingMode": "standard",
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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')))}")
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
# Commands module
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"""
|
||||
Scrub command - Remove metadata from files.
|
||||
|
||||
This command processes files through the read→wipe→save 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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 read→wipe→save 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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
Binary file not shown.
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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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("")
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"""
|
||||
Unit tests for ExcelHandler.
|
||||
|
||||
Tests the ExcelHandler class in isolation, focusing on individual methods
|
||||
and edge cases including encrypted workbooks, missing metadata, and corrupted files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
|
||||
from src.services.excel_handler import ExcelHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_large_xlsx_test_file, get_xlsx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
XLSX_TEST_FILE = get_xlsx_test_file()
|
||||
LARGE_XLSX_TEST_FILE = get_large_xlsx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_read_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test reading metadata from Excel files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
metadata = handler.read()
|
||||
|
||||
# Check metadata was extracted
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
# Check keys_to_delete is populated
|
||||
assert handler.keys_to_delete is not None
|
||||
assert len(handler.keys_to_delete) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_wipe_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test wiping metadata from Excel files.
|
||||
Verifies that wipe() removes metadata entries.
|
||||
"""
|
||||
assert Path(xlsx_file).exists(), f"Test file not found: {xlsx_file}"
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should differ from original
|
||||
assert handler.processed_metadata != metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_save_processed_excel_metadata(xlsx_file):
|
||||
"""
|
||||
Test saving processed Excel to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(xlsx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_xlsx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies XLSX files.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "xlsx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xlsx_file", [XLSX_TEST_FILE, LARGE_XLSX_TEST_FILE])
|
||||
def test_output_file_has_less_metadata(xlsx_file):
|
||||
"""
|
||||
Test that the output file has metadata stripped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Process original file
|
||||
handler = ExcelHandler(xlsx_file)
|
||||
original_metadata = handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(xlsx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Read output file and verify metadata is reduced or gone
|
||||
try:
|
||||
output_handler = ExcelHandler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
# Output should have fewer metadata entries (some are None now)
|
||||
non_none_original = sum(1 for v in original_metadata.values() if v is not None)
|
||||
non_none_output = sum(1 for v in output_metadata.values() if v is not None)
|
||||
assert non_none_output <= non_none_original
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_preserved_properties_not_deleted():
|
||||
"""
|
||||
Test that created, modified, and language properties are preserved.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
|
||||
# These should NOT be in keys_to_delete
|
||||
assert "created" not in handler.keys_to_delete
|
||||
assert "modified" not in handler.keys_to_delete
|
||||
assert "language" not in handler.keys_to_delete
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that non-Excel files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not an excel file")
|
||||
|
||||
handler = ExcelHandler(str(fake_file))
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
handler._detect_format()
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None or empty.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save("")
|
||||
|
||||
|
||||
def test_save_with_none_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None.
|
||||
"""
|
||||
handler = ExcelHandler(XLSX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_excel_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted Excel files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted Excel file (invalid structure)
|
||||
corrupted_xlsx = tmp_path / "corrupted.xlsx"
|
||||
corrupted_xlsx.write_bytes(b"not a valid xlsx content at all")
|
||||
|
||||
handler = ExcelHandler(str(corrupted_xlsx))
|
||||
# Should raise InvalidFileException or similar from openpyxl
|
||||
with pytest.raises((InvalidFileException, Exception)):
|
||||
handler.read()
|
||||
|
||||
|
||||
def test_format_detection_all_excel_types(tmp_path):
|
||||
"""
|
||||
Test format detection for all supported Excel extensions.
|
||||
"""
|
||||
for ext in ["xlsx", "xlsm", "xltx", "xltm"]:
|
||||
fake_file = tmp_path / f"test.{ext}"
|
||||
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
|
||||
|
||||
handler = ExcelHandler(str(fake_file))
|
||||
detected = handler._detect_format()
|
||||
assert detected == ext
|
||||
|
|
@ -0,0 +1,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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
Unit tests for PowerpointHandler.
|
||||
|
||||
Tests the PowerpointHandler class in isolation, focusing on individual methods
|
||||
and edge cases including missing metadata and corrupted files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.powerpoint_handler import PowerpointHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_large_pptx_test_file, get_pptx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
PPTX_TEST_FILE = get_pptx_test_file()
|
||||
LARGE_PPTX_TEST_FILE = get_large_pptx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_read_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test reading metadata from PowerPoint files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
metadata = handler.read()
|
||||
|
||||
# Check metadata was extracted
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
# Check keys_to_delete is populated
|
||||
assert handler.keys_to_delete is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_wipe_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test wiping metadata from PowerPoint files.
|
||||
Verifies that wipe() prepares metadata for removal.
|
||||
"""
|
||||
assert Path(pptx_file).exists(), f"Test file not found: {pptx_file}"
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should have entries set to None
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_save_processed_pptx_metadata(pptx_file):
|
||||
"""
|
||||
Test saving processed PowerPoint to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(pptx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_pptx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies PPTX files.
|
||||
"""
|
||||
handler = PowerpointHandler(PPTX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "pptx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pptx_file", [PPTX_TEST_FILE, LARGE_PPTX_TEST_FILE])
|
||||
def test_output_file_has_wiped_metadata(pptx_file):
|
||||
"""
|
||||
Test that the output file has metadata wiped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Process original file
|
||||
handler = PowerpointHandler(pptx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(pptx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists and can be read
|
||||
assert output_file.exists()
|
||||
|
||||
# Read output file and verify it's valid
|
||||
try:
|
||||
output_handler = PowerpointHandler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
# Just verify we can read it - the wipe worked if we're here
|
||||
assert isinstance(output_metadata, dict)
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_preserved_properties_not_deleted():
|
||||
"""
|
||||
Test that created, modified, language, last_printed, revision are preserved.
|
||||
"""
|
||||
handler = PowerpointHandler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
|
||||
# These should NOT be in keys_to_delete
|
||||
assert "created" not in handler.keys_to_delete
|
||||
assert "modified" not in handler.keys_to_delete
|
||||
assert "language" not in handler.keys_to_delete
|
||||
assert "last_printed" not in handler.keys_to_delete
|
||||
assert "revision" not in handler.keys_to_delete
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that non-PowerPoint files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not a powerpoint file")
|
||||
|
||||
handler = PowerpointHandler(str(fake_file))
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
handler._detect_format()
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is empty.
|
||||
"""
|
||||
handler = PowerpointHandler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save("")
|
||||
|
||||
|
||||
def test_save_with_none_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None.
|
||||
"""
|
||||
handler = PowerpointHandler(PPTX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_pptx_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted PowerPoint files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted PowerPoint file (invalid structure)
|
||||
corrupted_pptx = tmp_path / "corrupted.pptx"
|
||||
corrupted_pptx.write_bytes(b"not a valid pptx content at all")
|
||||
|
||||
handler = PowerpointHandler(str(corrupted_pptx))
|
||||
# Should raise an exception from python-pptx
|
||||
with pytest.raises(Exception):
|
||||
handler.read()
|
||||
|
||||
|
||||
def test_format_detection_all_pptx_types(tmp_path):
|
||||
"""
|
||||
Test format detection for all supported PowerPoint extensions.
|
||||
"""
|
||||
for ext in ["pptx", "pptm", "potx", "potm"]:
|
||||
fake_file = tmp_path / f"test.{ext}"
|
||||
fake_file.write_bytes(b"dummy") # Not valid, but we're only testing detection
|
||||
|
||||
handler = PowerpointHandler(str(fake_file))
|
||||
detected = handler._detect_format()
|
||||
assert detected == ext
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Unit tests for WorddocHandler.
|
||||
|
||||
Tests the WorddocHandler class in isolation, focusing on individual methods
|
||||
and edge cases including missing metadata and corrupted files.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.worddoc_handler import WorddocHandler
|
||||
from src.utils.exceptions import (
|
||||
MetadataNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
||||
# Import path helpers from conftest
|
||||
from tests.conftest import get_docx_test_file, get_large_docx_test_file
|
||||
|
||||
# Test file paths (cross-platform)
|
||||
DOCX_TEST_FILE = get_docx_test_file()
|
||||
LARGE_DOCX_TEST_FILE = get_large_docx_test_file()
|
||||
|
||||
|
||||
# ============== Success Case Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_read_docx_metadata(docx_file):
|
||||
"""
|
||||
Test reading metadata from Word document files.
|
||||
Verifies that read() extracts metadata and populates keys_to_delete.
|
||||
"""
|
||||
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
|
||||
handler = WorddocHandler(docx_file)
|
||||
metadata = handler.read()
|
||||
|
||||
# Check metadata was extracted
|
||||
assert handler.metadata == metadata
|
||||
assert isinstance(metadata, dict)
|
||||
|
||||
# Check keys_to_delete is populated
|
||||
assert handler.keys_to_delete is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_wipe_docx_metadata(docx_file):
|
||||
"""
|
||||
Test wiping metadata from Word document files.
|
||||
Verifies that wipe() prepares metadata for removal.
|
||||
"""
|
||||
assert Path(docx_file).exists(), f"Test file not found: {docx_file}"
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# processed_metadata should have entries set to None
|
||||
assert handler.processed_metadata is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_save_processed_docx_metadata(docx_file):
|
||||
"""
|
||||
Test saving processed Word document to output path.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
output_file = output_dir / Path(docx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists
|
||||
assert output_file.exists()
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_format_detection_docx():
|
||||
"""
|
||||
Test that _detect_format() correctly identifies DOCX files.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_TEST_FILE)
|
||||
detected = handler._detect_format()
|
||||
assert detected == "docx"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("docx_file", [DOCX_TEST_FILE, LARGE_DOCX_TEST_FILE])
|
||||
def test_output_file_has_wiped_metadata(docx_file):
|
||||
"""
|
||||
Test that the output file has metadata wiped.
|
||||
"""
|
||||
output_dir = Path("./tests/assets/output")
|
||||
output_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Process original file
|
||||
handler = WorddocHandler(docx_file)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
|
||||
# Save processed file
|
||||
output_file = output_dir / Path(docx_file).name
|
||||
handler.save(str(output_file))
|
||||
|
||||
# Verify output file exists and can be read
|
||||
assert output_file.exists()
|
||||
|
||||
# Read output file and verify it's valid
|
||||
try:
|
||||
output_handler = WorddocHandler(str(output_file))
|
||||
output_metadata = output_handler.read()
|
||||
# Just verify we can read it - the wipe worked if we're here
|
||||
assert isinstance(output_metadata, dict)
|
||||
except MetadataNotFoundError:
|
||||
# If no metadata found, that's expected for fully stripped files
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
|
||||
def test_preserved_properties_not_deleted():
|
||||
"""
|
||||
Test that created, modified, language, last_printed, revision are preserved.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
|
||||
# These should NOT be in keys_to_delete
|
||||
assert "created" not in handler.keys_to_delete
|
||||
assert "modified" not in handler.keys_to_delete
|
||||
assert "language" not in handler.keys_to_delete
|
||||
assert "last_printed" not in handler.keys_to_delete
|
||||
assert "revision" not in handler.keys_to_delete
|
||||
|
||||
|
||||
# ============== Error Case Tests ==============
|
||||
|
||||
|
||||
def test_unsupported_format_raises_error(tmp_path):
|
||||
"""
|
||||
Test that non-Word document files raise UnsupportedFormatError.
|
||||
"""
|
||||
# Create a fake text file with .txt extension
|
||||
fake_file = tmp_path / "test.txt"
|
||||
fake_file.write_text("not a word document")
|
||||
|
||||
handler = WorddocHandler(str(fake_file))
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
handler._detect_format()
|
||||
|
||||
|
||||
def test_save_without_output_path_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is empty.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises(ValueError):
|
||||
handler.save("")
|
||||
|
||||
|
||||
def test_save_with_none_raises_error():
|
||||
"""
|
||||
Test that save() raises ValueError when output_path is None.
|
||||
"""
|
||||
handler = WorddocHandler(DOCX_TEST_FILE)
|
||||
handler.read()
|
||||
handler.wipe()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
handler.save(None)
|
||||
|
||||
|
||||
# ============== Edge Case Tests ==============
|
||||
|
||||
|
||||
def test_corrupted_docx_graceful_error(tmp_path):
|
||||
"""
|
||||
Test that corrupted Word document files are handled gracefully.
|
||||
"""
|
||||
# Create a corrupted DOCX file (invalid structure)
|
||||
corrupted_docx = tmp_path / "corrupted.docx"
|
||||
corrupted_docx.write_bytes(b"not a valid docx content at all")
|
||||
|
||||
handler = WorddocHandler(str(corrupted_docx))
|
||||
# Should raise an exception from python-docx
|
||||
with pytest.raises(Exception):
|
||||
handler.read()
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
"""
|
||||
...
|
||||
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
This type stub file was generated by pyright.
|
||||
"""
|
||||
|
||||
class InvalidImageDataError(ValueError):
|
||||
...
|
||||
|
|
@ -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] = ...
|
||||
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
|
|
@ -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], ...]:
|
||||
...
|
||||
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
|
|
@ -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:
|
||||
...
|
||||
|
|
@ -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.
|
||||
"""
|
||||
...
|
||||
|
|
@ -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" },
|
||||
]
|
||||
Loading…
Reference in New Issue