Make the codebase mypy-clean, drop dead media_utils and Pillow, rewrite README

This commit is contained in:
2026-10-17 09:17:37 +02:00
parent 4daf4e960d
commit 364b515e3c
14 changed files with 333 additions and 436 deletions
+21 -9
View File
@@ -19,21 +19,31 @@ Each version entry uses these sections (include only those that apply):
- Always update this file before bumping the version in `pyproject.toml` - Always update this file before bumping the version in `pyproject.toml`
- Document changes as they are made, not all at once at release time - Document changes as they are made, not all at once at release time
## Unreleased ## 1.14.0 — 2026-10-17
### Changed
- **The codebase is mypy-clean** and plain `uv run mypy` works (`[tool.mypy]`
in `pyproject.toml` lists the targets). Qt enums are now fully qualified
(`QDialog.DialogCode.Accepted`, `QMessageBox.StandardButton.Yes`,
`Qt.GlobalColor.red`, …) instead of PySide6's short aliases, and the few
loosely typed spots (tag schema, transforms, `on_files_changed`, dialog cell
widgets) are annotated. No behaviour change.
### Removed
- `src/core/media_utils.py` — Tagger-era leftovers (`load_icon` returned a
Tkinter image, `add_video_resolution_tag` logged via `print()`); nothing in
the app called either.
### Dependencies
- Removed `pillow` — `media_utils` was its only user.
## 1.13.0 — 2026-10-12
### Changed ### Changed
- **The standalone build is now a single file.** `Curator.spec` produces one - **The standalone build is now a single file.** `Curator.spec` produces one
self-contained `dist/Curator` executable (onefile) instead of a self-contained `dist/Curator` executable (onefile) instead of a
`dist/Curator/` folder of libraries. It unpacks to a temp folder on every `dist/Curator/` folder of libraries. It unpacks to a temp folder on every
start, so launching takes a few seconds longer. start, so launching takes a few seconds longer.
### Dependencies
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
now works out of the box (it was used for builds but never declared).
## 1.13.0 — 2026-10-12
### Changed
- **Custom attributes are gone from the GUI.** The attribute table in - **Custom attributes are gone from the GUI.** The attribute table in
*Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are *Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are
removed. Values already stored keep working in miniDLNA filename templates removed. Values already stored keep working in miniDLNA filename templates
@@ -473,6 +483,8 @@ Each version entry uses these sections (include only those that apply):
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md` Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's §11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
upper bounds (`<7.0.0`, …) are gone. upper bounds (`<7.0.0`, …) are gone.
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
now works out of the box (it was used for builds but never declared).
- **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3, - **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3,
python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the
whole codebase was brought in line with it — see *Changed* below. whole codebase was brought in line with it — see *Changed* below.
+60 -32
View File
@@ -1,38 +1,63 @@
# Curator # Curator
Curator is a desktop **movie-library manager (Filmotéka)**. You import video Curator is a PySide6 desktop app for managing a **Jellyfin-first movie and series
files into a managed **pool**, tag them, and Curator generates a browsable library**. You import video files into a managed **pool**; Curator identifies them
**Filmotéka** — a directory tree of hardlinks into the pool — without ever through **TMDb**, names and lays them out the way Jellyfin expects, and can
duplicating the actual video data on disk. generate a genre-split **miniDLNA / Filmotéka** tree of hardlinks for dumb DLNA
players — without ever duplicating the video data on disk.
Curator is a fork of the former *Tagger* project; the tagging, filtering and Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the files,
hardlink-tree machinery is inherited and extended into a full library workflow. their names and the library structure.
## Concepts ## Concepts
- **Pool** — the managed repository of files and the **single source of truth**. - **Pool** — the managed folder and the **single source of truth**. Curator
Curator owns it: it imports and removes files itself, so nothing moves behind imports, renames and removes files itself, so nothing moves behind its back.
its back. The pool has two top-level folders: Each top-level folder is a **library** with a kind:
- `Filmy` — movies, organized by tags into the generated tree.
- `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim.
- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is
fully disposable: delete it and nothing is lost, because it is regenerated from
the pool on demand.
- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON
file at the pool root (title, ČSFD link, tags, date per file).
## Workflow | Kind | Holds | Layout |
|------|-------|--------|
| `movie` | movies | `Title (rok) [tmdbid].ext`, or a folder once a film has several versions |
| `series` | series | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` |
| `mixed` | movies and series together | as above, side by side |
| `homevideo` | files without a TMDb id | mirrored as-is |
1. Configure the **pool** and the **Filmotéka output** folder. - **Pool index** — Curator's own metadata (TMDb id, ČSFD link, versions, tags,
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The library settings) lives in one `.Curator.!index` JSON file at the pool root.
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and - **miniDLNA output** — a generated tree of **hardlinks** into the pool, built
recorded in the index. If a ČSFD link is given, Curator fetches the movie from from the genres/years/ratings in Jellyfin's NFO files. It is disposable: delete
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags it and regenerate it at any time.
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI. ## Features
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as - Import movies and episodes with a mandatory **TMDb id**; Czech titles come from
`Seriály` are mirrored 1:1 as hardlinks. TMDb, with **ČSFD** as a fallback and as a bridge for finding the right TMDb id.
- **Multiple versions** of one film (2D/3D, 4K, director's cut) and **multi-part**
films and episodes, named so Jellyfin groups them.
- Subtitle sidecars follow their video through renames.
- Duplicate check, video integrity check (FFmpeg), library statistics.
- Jellyfin integration: trigger a library scan, read what Jellyfin has.
- miniDLNA tree scoped per library, with "Tipy dne" and "Nově přidané" folders.
## Requirements
- [uv](https://docs.astral.sh/uv/) — manages Python 3.14 and all dependencies
- `ffmpeg` / `ffprobe` on `PATH` — optional, for the integrity check
## Configuration
Secrets and settings are read from a `.env` file in the project root (never
committed):
| Variable | Purpose |
|----------|---------|
| `TMDB_API_KEY` | TMDb API key — required for identifying movies and series |
| `TMDB_LANGUAGE` | TMDb language, default `cs-CZ` |
| `JELLYFIN_URL` | Jellyfin server URL — optional, for the Jellyfin menu |
| `JELLYFIN_API_KEY` | Jellyfin API key — optional |
| `ENV_DEBUG` | `true` enables debug mode |
The pool and output folders are chosen in the app (*Pool* menu).
## Running ## Running
@@ -41,20 +66,23 @@ uv sync
uv run python Curator.py uv run python Curator.py
``` ```
The GUI is built with **PySide6**.
## Development ## Development
```bash ```bash
uv run pytest # tests uv run pytest # tests
uv run ruff check # lint / format uv run ruff check # lint
uv run ruff format # format
uv run mypy # type checking uv run mypy # type checking
``` ```
Detailed documentation — architecture and a per-module reference — is in
[`docs/`](docs/README.md).
## Building a standalone executable ## Building a standalone executable
```bash ```bash
uv run pyinstaller Curator.spec uv run python prebuild.py
uv run pyinstaller Curator.spec --noconfirm
``` ```
The resulting `dist/Curator` is the distribution artifact. The result is a single self-contained `dist/Curator` executable.
-1
View File
@@ -26,7 +26,6 @@ Filmotéka tree.
| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. | | [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. |
| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. | | [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. |
| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. | | [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. |
| [`src/core/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag. |
| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. | | [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. |
| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. | | [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. |
| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. | | [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. |
-27
View File
@@ -1,27 +0,0 @@
# `src/core/media_utils.py`
Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag.
Source: [`src/core/media_utils.py`](../../src/core/media_utils.py)
## Functions
### `load_icon`
*function · line 14*
```python
load_icon(path) -> ImageTk.PhotoImage
```
Load an image file and scale it to 16×16 px as a Qt-compatible icon.
### `add_video_resolution_tag`
*function · line 19*
```python
add_video_resolution_tag(file_obj: File, tagmanager: TagManager)
```
Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/<height>p`. Needs FFmpeg on the PATH.
+4 -2
View File
@@ -1,11 +1,10 @@
[project] [project]
name = "curator" name = "curator"
version = "1.13.0" version = "1.14.0"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"beautifulsoup4>=4.15.0", "beautifulsoup4>=4.15.0",
"loguru>=0.7.3", "loguru>=0.7.3",
"pillow>=12.3.0",
"pyside6>=6.12.0", "pyside6>=6.12.0",
"python-dotenv>=1.2.4", "python-dotenv>=1.2.4",
"requests>=2.34.2", "requests>=2.34.2",
@@ -22,3 +21,6 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[tool.mypy]
files = ["Curator.py", "prebuild.py", "src", "tests", "scripts"]
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "1.13.0" __version__ = "1.14.0"
+1 -1
View File
@@ -41,7 +41,7 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root. # folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
# This lives in the code as the default; the effective schema is stored per-pool # This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``). # in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [ DEFAULT_TAG_SCHEMA: list[dict] = [
{ {
"category": "Žánr", "category": "Žánr",
"csfd_field": "genres", "csfd_field": "genres",
+4 -2
View File
@@ -112,7 +112,9 @@ class FileManager:
def __init__(self, tagmanager: TagManager): def __init__(self, tagmanager: TagManager):
self.filelist: list[File] = [] self.filelist: list[File] = []
self.tagmanager = tagmanager self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI self.on_files_changed: Callable[[list[File]], None] | None = (
None # callback do GUI
)
self.global_config = load_global_config() self.global_config = load_global_config()
self.index: PoolIndex | None = None # unified pool metadata + settings self.index: PoolIndex | None = None # unified pool metadata + settings
@@ -689,7 +691,7 @@ class FileManager:
total_size = 0 total_size = 0
with_nfo = 0 with_nfo = 0
untagged = 0 untagged = 0
ratings: list[int] = [] ratings: list[float] = []
categories: dict[str, Counter] = {} categories: dict[str, Counter] = {}
for f in files: for f in files:
-57
View File
@@ -1,57 +0,0 @@
# Module header
import sys
from .file import File
from .tag_manager import TagManager
if __name__ == "__main__":
sys.exit("This module is not intended to be executed as the main program.")
# Imports
import subprocess
from PIL import Image, ImageTk
# Functions
def load_icon(path) -> ImageTk.PhotoImage:
img = Image.open(path)
img = img.resize((16, 16), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
"""
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
Vyžaduje ffprobe (FFmpeg).
"""
path = str(file_obj.file_path)
try:
# ffprobe vrátí width a height ve formátu JSON
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
path,
],
capture_output=True,
text=True,
check=True,
)
res = result.stdout.strip() # např. "1920x1080"
if "x" not in res:
return
_width, height = map(int, res.split("x"))
tag_name = f"Rozlišení/{height}p"
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
file_obj.add_tag(tag_obj)
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
except (OSError, subprocess.SubprocessError, ValueError) as e:
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
+3 -2
View File
@@ -6,6 +6,7 @@ value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
""" """
import re import re
from collections.abc import Callable
def rating_band(rating: int) -> str: def rating_band(rating: int) -> str:
@@ -18,14 +19,14 @@ def rating_band(rating: int) -> str:
return f"{low}–{high} %" return f"{low}–{high} %"
def _decade_band(value) -> str: def _decade_band(value: str) -> str:
"""``decade_band`` transform: pull the leading integer and bucket it.""" """``decade_band`` transform: pull the leading integer and bucket it."""
match = re.search(r"\d+", str(value)) match = re.search(r"\d+", str(value))
return rating_band(int(match.group())) if match else str(value) return rating_band(int(match.group())) if match else str(value)
# Named value transforms usable from the tag schema (config-driven, extensible). # Named value transforms usable from the tag schema (config-driven, extensible).
TAG_TRANSFORMS = { TAG_TRANSFORMS: dict[str | None, Callable[[str], str]] = {
None: str, None: str,
"identity": str, "identity": str,
"decade_band": _decade_band, "decade_band": _decade_band,
+234 -170
View File
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
import tempfile
import tkinter as tk
from pathlib import Path
import pytest
from PIL import Image, ImageTk
from src.core.media_utils import load_icon
@pytest.fixture(scope="module")
def tk_root():
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
root = tk.Tk()
yield root
root.destroy()
def test_load_icon_returns_photoimage(tk_root):
"""Test že load_icon vrací PhotoImage"""
# vytvoříme dočasný obrázek
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme 100x100 červený obrázek
img = Image.new("RGB", (100, 100), color="red")
img.save(tmp_path)
icon = load_icon(tmp_path)
# musí být PhotoImage
assert isinstance(icon, ImageTk.PhotoImage)
# ověříme velikost 16x16
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_resizes_image(tk_root):
"""Test že load_icon správně změní velikost obrázku"""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme velký obrázek 500x500
img = Image.new("RGB", (500, 500), color="blue")
img.save(tmp_path)
icon = load_icon(tmp_path)
# i velký obrázek by měl být zmenšen na 16x16
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_different_formats(tk_root):
"""Test načítání různých formátů obrázků"""
formats = [".png", ".jpg", ".bmp"]
for fmt in formats:
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img = Image.new("RGB", (32, 32), color="green")
img.save(tmp_path)
icon = load_icon(tmp_path)
assert isinstance(icon, ImageTk.PhotoImage)
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
+3 -2
View File
@@ -1,6 +1,7 @@
"""Tests for the movie object and the pool layout it derives (src.core.movie).""" """Tests for the movie object and the pool layout it derives (src.core.movie)."""
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -79,8 +80,8 @@ class TestIdentityKey:
assert identity_key(None, "Nějaký film", None) == "name-nejaky-film" assert identity_key(None, "Nějaký film", None) == "name-nejaky-film"
def _movie(**kwargs) -> Movie: def _movie(**kwargs: Any) -> Movie:
base = { base: dict[str, Any] = {
"key": "tmdb-19995", "key": "tmdb-19995",
"title": "Avatar", "title": "Avatar",
"year": "2009", "year": "2009",
Generated
+1 -53
View File
@@ -206,12 +206,11 @@ wheels = [
[[package]] [[package]]
name = "curator" name = "curator"
version = "1.13.0" version = "1.14.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "loguru" }, { name = "loguru" },
{ name = "pillow" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "requests" }, { name = "requests" },
@@ -230,7 +229,6 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "beautifulsoup4", specifier = ">=4.15.0" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "pyside6", specifier = ">=6.12.0" }, { name = "pyside6", specifier = ">=6.12.0" },
{ name = "python-dotenv", specifier = ">=1.2.4" }, { name = "python-dotenv", specifier = ">=1.2.4" },
{ name = "requests", specifier = ">=2.34.2" }, { name = "requests", specifier = ">=2.34.2" },
@@ -436,56 +434,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"