image-to-webp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ build/
11
+ dist/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+
16
+ # Partial conversion output
17
+ *.webp.part
18
+
19
+ # OS / editors
20
+ .DS_Store
21
+ .idea/
22
+ .vscode/
23
+ *.swp
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ - First release as a Python library (`import image_to_webp`).
6
+ - Python API: `convert_file`, `convert_bytes`, `convert_image`, `convert_many`.
7
+ - `image-to-webp` command (also `python -m image_to_webp`).
8
+ - Optional installs for more formats: `[heif]`, `[raw]`, `[svg]`, `[all]`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stakmi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.5
2
+ Name: image-to-webp
3
+ Version: 0.1.0
4
+ Summary: Convert images in almost any format (PNG, JPEG, GIF, TIFF, HEIC, AVIF, RAW, SVG, ...) to WebP.
5
+ Project-URL: Homepage, https://github.com/stakmi/image-to-webp
6
+ Project-URL: Repository, https://github.com/stakmi/image-to-webp
7
+ Project-URL: Issues, https://github.com/stakmi/image-to-webp/issues
8
+ Project-URL: Changelog, https://github.com/stakmi/image-to-webp/blob/main/CHANGELOG.md
9
+ Author: stakmi
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: convert,converter,heic,image,pillow,raw,svg,webp
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: pillow>=10.0
28
+ Provides-Extra: all
29
+ Requires-Dist: cairosvg>=2.7; extra == 'all'
30
+ Requires-Dist: pillow-heif>=0.16; extra == 'all'
31
+ Requires-Dist: rawpy>=0.19; extra == 'all'
32
+ Provides-Extra: dev
33
+ Requires-Dist: build; extra == 'dev'
34
+ Requires-Dist: cairosvg>=2.7; extra == 'dev'
35
+ Requires-Dist: pillow-heif>=0.16; extra == 'dev'
36
+ Requires-Dist: pytest>=7; extra == 'dev'
37
+ Requires-Dist: rawpy>=0.19; extra == 'dev'
38
+ Requires-Dist: twine; extra == 'dev'
39
+ Provides-Extra: heif
40
+ Requires-Dist: pillow-heif>=0.16; extra == 'heif'
41
+ Provides-Extra: raw
42
+ Requires-Dist: rawpy>=0.19; extra == 'raw'
43
+ Provides-Extra: svg
44
+ Requires-Dist: cairosvg>=2.7; extra == 'svg'
45
+ Provides-Extra: test
46
+ Requires-Dist: pytest>=7; extra == 'test'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # image-to-webp
50
+
51
+ Convert images in almost any format to [WebP](https://developers.google.com/speed/webp), from Python or the command line.
52
+
53
+ - Over 70 input formats with Pillow alone: PNG, JPEG, GIF, BMP, TIFF, ICO/ICNS, TGA, PSD, JPEG 2000, PCX, PPM/PGM/PBM, DDS, SGI, QOI, WebP, …
54
+ - More formats through optional installs: HEIC/HEIF/AVIF, camera RAW (CR2, CR3, NEF, ARW, DNG, …) and SVG
55
+ - Applies EXIF rotation, keeps transparency, and turns animated GIF/APNG into animated WebP
56
+ - Converts files in parallel and writes each output in one step, so a failed run never leaves a half-written file
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install image-to-webp # core formats (Pillow only)
62
+ pip install "image-to-webp[all]" # + HEIC/AVIF, RAW and SVG
63
+ ```
64
+
65
+ | Optional install | Adds | Package |
66
+ |---|---|---|
67
+ | `[heif]` | HEIC / HEIF / AVIF | `pillow-heif` |
68
+ | `[raw]` | Camera RAW | `rawpy` |
69
+ | `[svg]` | SVG / SVGZ | `cairosvg` (also needs the system cairo library: `brew install cairo` or `apt install libcairo2`) |
70
+
71
+ Requires Python 3.9+.
72
+
73
+ ## Library usage
74
+
75
+ ```python
76
+ from image_to_webp import convert_file, convert_bytes, convert_image, convert_many, ConversionOptions
77
+
78
+ # One file -> photo.webp next to it (returns the output Path)
79
+ convert_file("photo.heic")
80
+ convert_file("photo.png", "out/photo.webp", quality=90)
81
+
82
+ # In memory
83
+ webp_bytes = convert_bytes(png_bytes, lossless=True)
84
+ webp_bytes = convert_bytes(open("img.cr2", "rb"), filename="img.cr2") # filename hints RAW decoding
85
+ webp_bytes = convert_image(pil_image, quality=75)
86
+
87
+ # Reusable settings
88
+ opts = ConversionOptions(quality=85, method=6, max_size=(2560, 2560), keep_metadata=True)
89
+ convert_file("big.tif", options=opts)
90
+
91
+ # Batch: files and/or directories, in parallel
92
+ summary = convert_many(["images/", "extra.gif"], output_dir="webp", recursive=True,
93
+ jobs=4, on_result=lambda r: print(r.status, r.src))
94
+ print(summary.converted, summary.skipped, summary.failed, summary.bytes_saved)
95
+ ```
96
+
97
+ ### API reference
98
+
99
+ | Name | Description |
100
+ |---|---|
101
+ | `convert_file(src, dst=None, options=None, *, overwrite=True, **opts) -> Path` | Convert one file. `dst` defaults to `src` with a `.webp` extension. |
102
+ | `convert_bytes(data, options=None, *, filename=None, **opts) -> bytes` | Convert bytes or a binary file object. |
103
+ | `convert_image(image, options=None, **opts) -> bytes` | Encode a `PIL.Image.Image`. |
104
+ | `convert_many(inputs, output_dir=None, options=None, *, recursive, overwrite=False, delete_original, jobs, on_result, **opts) -> BatchSummary` | Convert many files. One failed file doesn't stop the run. |
105
+ | `plan_conversions(inputs, output_dir=None, *, recursive, overwrite)` | Preview what `convert_many` would do (dry run). |
106
+ | `ConversionOptions(quality=80, lossless=False, method=4, max_size=None, keep_metadata=False)` | Encoder settings, checked when created. |
107
+ | `ConversionResult` / `BatchSummary` | Results for each file and for the whole batch. |
108
+ | `supported_extensions()`, `is_supported(path)`, `plugin_status()` | See which formats work in the current environment. |
109
+ | `open_image(source, filename=None)` | Open any supported input as a PIL image. |
110
+ | `ImageToWebPError` → `UnsupportedFormatError`, `ConversionError` | Exceptions. |
111
+
112
+ `**opts` accepts the same keywords as `ConversionOptions` (`quality`, `lossless`, `method`, `max_size`, `keep_metadata`).
113
+ The library never prints. Messages go to Python's `logging` under the `image_to_webp` logger.
114
+
115
+ ## Command line
116
+
117
+ ```bash
118
+ image-to-webp photo.jpg # -> photo.webp next to source
119
+ image-to-webp ./images -r -o ./webp # recursive, mirror tree into ./webp
120
+ image-to-webp *.png --lossless
121
+ image-to-webp ./raw -q 85 --max-size 2560x2560 -j 8
122
+ image-to-webp ./images -r --dry-run
123
+ image-to-webp --list-formats # formats + optional plugin status
124
+ python -m image_to_webp --help # same tool
125
+ ```
126
+
127
+ | Option | Description |
128
+ |---|---|
129
+ | `-r, --recursive` | Search directories recursively |
130
+ | `-o, --output DIR` | Output directory (default: next to source) |
131
+ | `-q, --quality N` | Quality 0–100 (default 80) |
132
+ | `--lossless` | Lossless encoding |
133
+ | `-m, --method N` | Compression effort 0–6 (default 4) |
134
+ | `--max-size WxH` | Downscale to fit, keeping aspect ratio |
135
+ | `--keep-metadata` | Keep EXIF and ICC profile |
136
+ | `--overwrite` | Overwrite existing `.webp` (default: skip) |
137
+ | `--delete-original` | Delete source after successful conversion |
138
+ | `-j, --jobs N` | Parallel workers (default: CPU count) |
139
+ | `--dry-run` | Show what would be converted |
140
+ | `-v, --verbose` | Print each file |
141
+ | `--list-formats` | Show supported formats and plugin status |
142
+
143
+ Exit code is `0` on success, `1` if any file failed, `2` for invalid arguments.
144
+
145
+ > The command is called `image-to-webp`, not `img2webp`, because libwebp already ships a tool named `img2webp`.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ ./install.sh # .venv + editable install with all optional formats + link command to /usr/local/bin (macOS) or /usr/bin
151
+ ./install.sh --uninstall # remove the linked command
152
+ .venv/bin/pip install -e ".[dev]"
153
+ .venv/bin/python -m pytest
154
+ ```
155
+
156
+ `install.sh` uses `sudo` only when the target directory isn't writable. Override with `BIN_DIR=~/.local/bin` or `CMD_NAME=...`.
157
+
158
+ ## Releasing
159
+
160
+ 1. Update `__version__` in `src/image_to_webp/__init__.py` and `CHANGELOG.md`.
161
+ 2. Check the build locally:
162
+ ```bash
163
+ python -m build && python -m twine check --strict dist/*
164
+ ```
165
+ 3. Publish:
166
+ - **Automated (recommended):** set up PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) for `stakmi/image-to-webp` with workflow `publish.yml` and environment `pypi`, then create a GitHub release tagged `v<version>`.
167
+ - **Manual:** `python -m twine upload dist/*`
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,123 @@
1
+ # image-to-webp
2
+
3
+ Convert images in almost any format to [WebP](https://developers.google.com/speed/webp), from Python or the command line.
4
+
5
+ - Over 70 input formats with Pillow alone: PNG, JPEG, GIF, BMP, TIFF, ICO/ICNS, TGA, PSD, JPEG 2000, PCX, PPM/PGM/PBM, DDS, SGI, QOI, WebP, …
6
+ - More formats through optional installs: HEIC/HEIF/AVIF, camera RAW (CR2, CR3, NEF, ARW, DNG, …) and SVG
7
+ - Applies EXIF rotation, keeps transparency, and turns animated GIF/APNG into animated WebP
8
+ - Converts files in parallel and writes each output in one step, so a failed run never leaves a half-written file
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install image-to-webp # core formats (Pillow only)
14
+ pip install "image-to-webp[all]" # + HEIC/AVIF, RAW and SVG
15
+ ```
16
+
17
+ | Optional install | Adds | Package |
18
+ |---|---|---|
19
+ | `[heif]` | HEIC / HEIF / AVIF | `pillow-heif` |
20
+ | `[raw]` | Camera RAW | `rawpy` |
21
+ | `[svg]` | SVG / SVGZ | `cairosvg` (also needs the system cairo library: `brew install cairo` or `apt install libcairo2`) |
22
+
23
+ Requires Python 3.9+.
24
+
25
+ ## Library usage
26
+
27
+ ```python
28
+ from image_to_webp import convert_file, convert_bytes, convert_image, convert_many, ConversionOptions
29
+
30
+ # One file -> photo.webp next to it (returns the output Path)
31
+ convert_file("photo.heic")
32
+ convert_file("photo.png", "out/photo.webp", quality=90)
33
+
34
+ # In memory
35
+ webp_bytes = convert_bytes(png_bytes, lossless=True)
36
+ webp_bytes = convert_bytes(open("img.cr2", "rb"), filename="img.cr2") # filename hints RAW decoding
37
+ webp_bytes = convert_image(pil_image, quality=75)
38
+
39
+ # Reusable settings
40
+ opts = ConversionOptions(quality=85, method=6, max_size=(2560, 2560), keep_metadata=True)
41
+ convert_file("big.tif", options=opts)
42
+
43
+ # Batch: files and/or directories, in parallel
44
+ summary = convert_many(["images/", "extra.gif"], output_dir="webp", recursive=True,
45
+ jobs=4, on_result=lambda r: print(r.status, r.src))
46
+ print(summary.converted, summary.skipped, summary.failed, summary.bytes_saved)
47
+ ```
48
+
49
+ ### API reference
50
+
51
+ | Name | Description |
52
+ |---|---|
53
+ | `convert_file(src, dst=None, options=None, *, overwrite=True, **opts) -> Path` | Convert one file. `dst` defaults to `src` with a `.webp` extension. |
54
+ | `convert_bytes(data, options=None, *, filename=None, **opts) -> bytes` | Convert bytes or a binary file object. |
55
+ | `convert_image(image, options=None, **opts) -> bytes` | Encode a `PIL.Image.Image`. |
56
+ | `convert_many(inputs, output_dir=None, options=None, *, recursive, overwrite=False, delete_original, jobs, on_result, **opts) -> BatchSummary` | Convert many files. One failed file doesn't stop the run. |
57
+ | `plan_conversions(inputs, output_dir=None, *, recursive, overwrite)` | Preview what `convert_many` would do (dry run). |
58
+ | `ConversionOptions(quality=80, lossless=False, method=4, max_size=None, keep_metadata=False)` | Encoder settings, checked when created. |
59
+ | `ConversionResult` / `BatchSummary` | Results for each file and for the whole batch. |
60
+ | `supported_extensions()`, `is_supported(path)`, `plugin_status()` | See which formats work in the current environment. |
61
+ | `open_image(source, filename=None)` | Open any supported input as a PIL image. |
62
+ | `ImageToWebPError` → `UnsupportedFormatError`, `ConversionError` | Exceptions. |
63
+
64
+ `**opts` accepts the same keywords as `ConversionOptions` (`quality`, `lossless`, `method`, `max_size`, `keep_metadata`).
65
+ The library never prints. Messages go to Python's `logging` under the `image_to_webp` logger.
66
+
67
+ ## Command line
68
+
69
+ ```bash
70
+ image-to-webp photo.jpg # -> photo.webp next to source
71
+ image-to-webp ./images -r -o ./webp # recursive, mirror tree into ./webp
72
+ image-to-webp *.png --lossless
73
+ image-to-webp ./raw -q 85 --max-size 2560x2560 -j 8
74
+ image-to-webp ./images -r --dry-run
75
+ image-to-webp --list-formats # formats + optional plugin status
76
+ python -m image_to_webp --help # same tool
77
+ ```
78
+
79
+ | Option | Description |
80
+ |---|---|
81
+ | `-r, --recursive` | Search directories recursively |
82
+ | `-o, --output DIR` | Output directory (default: next to source) |
83
+ | `-q, --quality N` | Quality 0–100 (default 80) |
84
+ | `--lossless` | Lossless encoding |
85
+ | `-m, --method N` | Compression effort 0–6 (default 4) |
86
+ | `--max-size WxH` | Downscale to fit, keeping aspect ratio |
87
+ | `--keep-metadata` | Keep EXIF and ICC profile |
88
+ | `--overwrite` | Overwrite existing `.webp` (default: skip) |
89
+ | `--delete-original` | Delete source after successful conversion |
90
+ | `-j, --jobs N` | Parallel workers (default: CPU count) |
91
+ | `--dry-run` | Show what would be converted |
92
+ | `-v, --verbose` | Print each file |
93
+ | `--list-formats` | Show supported formats and plugin status |
94
+
95
+ Exit code is `0` on success, `1` if any file failed, `2` for invalid arguments.
96
+
97
+ > The command is called `image-to-webp`, not `img2webp`, because libwebp already ships a tool named `img2webp`.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ ./install.sh # .venv + editable install with all optional formats + link command to /usr/local/bin (macOS) or /usr/bin
103
+ ./install.sh --uninstall # remove the linked command
104
+ .venv/bin/pip install -e ".[dev]"
105
+ .venv/bin/python -m pytest
106
+ ```
107
+
108
+ `install.sh` uses `sudo` only when the target directory isn't writable. Override with `BIN_DIR=~/.local/bin` or `CMD_NAME=...`.
109
+
110
+ ## Releasing
111
+
112
+ 1. Update `__version__` in `src/image_to_webp/__init__.py` and `CHANGELOG.md`.
113
+ 2. Check the build locally:
114
+ ```bash
115
+ python -m build && python -m twine check --strict dist/*
116
+ ```
117
+ 3. Publish:
118
+ - **Automated (recommended):** set up PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) for `stakmi/image-to-webp` with workflow `publish.yml` and environment `pypi`, then create a GitHub release tagged `v<version>`.
119
+ - **Manual:** `python -m twine upload dist/*`
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "image-to-webp"
7
+ dynamic = ["version"]
8
+ description = "Convert images in almost any format (PNG, JPEG, GIF, TIFF, HEIC, AVIF, RAW, SVG, ...) to WebP."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "stakmi" }]
14
+ keywords = ["webp", "image", "convert", "converter", "heic", "raw", "svg", "pillow"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = ["Pillow>=10.0"]
31
+
32
+ [project.optional-dependencies]
33
+ heif = ["pillow-heif>=0.16"]
34
+ raw = ["rawpy>=0.19"]
35
+ svg = ["cairosvg>=2.7"]
36
+ all = ["image-to-webp[heif,raw,svg]"]
37
+ test = ["pytest>=7"]
38
+ dev = ["image-to-webp[all,test]", "build", "twine"]
39
+
40
+ [project.scripts]
41
+ image-to-webp = "image_to_webp.cli:main"
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/stakmi/image-to-webp"
45
+ Repository = "https://github.com/stakmi/image-to-webp"
46
+ Issues = "https://github.com/stakmi/image-to-webp/issues"
47
+ Changelog = "https://github.com/stakmi/image-to-webp/blob/main/CHANGELOG.md"
48
+
49
+ [tool.hatch.version]
50
+ path = "src/image_to_webp/__init__.py"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/image_to_webp"]
54
+
55
+ [tool.hatch.build.targets.sdist]
56
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = "-ra"
@@ -0,0 +1,38 @@
1
+ """Convert images in almost any format to WebP.
2
+
3
+ Quick start::
4
+
5
+ from image_to_webp import convert_file, convert_bytes, convert_many
6
+
7
+ convert_file("photo.heic") # -> photo.webp
8
+ webp = convert_bytes(png_bytes, quality=90) # in memory
9
+ summary = convert_many("images/", recursive=True, output_dir="webp")
10
+ """
11
+
12
+ from .batch import BatchSummary, ConversionResult, convert_many, plan_conversions
13
+ from .converter import convert_bytes, convert_file, convert_image, default_output_path
14
+ from .errors import ConversionError, ImageToWebPError, UnsupportedFormatError
15
+ from .loaders import is_supported, open_image, plugin_status, supported_extensions
16
+ from .options import ConversionOptions
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "BatchSummary",
22
+ "ConversionError",
23
+ "ConversionOptions",
24
+ "ConversionResult",
25
+ "ImageToWebPError",
26
+ "UnsupportedFormatError",
27
+ "__version__",
28
+ "convert_bytes",
29
+ "convert_file",
30
+ "convert_image",
31
+ "convert_many",
32
+ "default_output_path",
33
+ "is_supported",
34
+ "open_image",
35
+ "plan_conversions",
36
+ "plugin_status",
37
+ "supported_extensions",
38
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,194 @@
1
+ """Convert many files, optionally in parallel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from concurrent.futures import ProcessPoolExecutor, as_completed
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Iterable, List, Optional, Union
10
+
11
+ from .converter import PathLike, convert_file, default_output_path
12
+ from .loaders import supported_extensions
13
+ from .options import ConversionOptions, resolve_options
14
+
15
+ PENDING = "pending"
16
+ CONVERTED = "converted"
17
+ SKIPPED = "skipped"
18
+ FAILED = "failed"
19
+
20
+
21
+ @dataclass
22
+ class ConversionResult:
23
+ """Outcome for one input file.
24
+
25
+ ``status`` is one of ``"pending"`` (planned only), ``"converted"``, ``"skipped"`` or ``"failed"``.
26
+ """
27
+
28
+ src: Path
29
+ dst: Optional[Path]
30
+ status: str = PENDING
31
+ in_size: int = 0
32
+ out_size: int = 0
33
+ error: Optional[str] = None
34
+
35
+ @property
36
+ def ok(self) -> bool:
37
+ return self.status in (CONVERTED, SKIPPED)
38
+
39
+
40
+ @dataclass
41
+ class BatchSummary:
42
+ """Aggregate result of :func:`convert_many`."""
43
+
44
+ results: List[ConversionResult] = field(default_factory=list)
45
+
46
+ def _count(self, status: str) -> int:
47
+ return sum(r.status == status for r in self.results)
48
+
49
+ @property
50
+ def converted(self) -> int:
51
+ return self._count(CONVERTED)
52
+
53
+ @property
54
+ def skipped(self) -> int:
55
+ return self._count(SKIPPED)
56
+
57
+ @property
58
+ def failed(self) -> int:
59
+ return self._count(FAILED)
60
+
61
+ @property
62
+ def bytes_in(self) -> int:
63
+ return sum(r.in_size for r in self.results if r.status == CONVERTED)
64
+
65
+ @property
66
+ def bytes_out(self) -> int:
67
+ return sum(r.out_size for r in self.results if r.status == CONVERTED)
68
+
69
+ @property
70
+ def bytes_saved(self) -> int:
71
+ return self.bytes_in - self.bytes_out
72
+
73
+ @property
74
+ def ok(self) -> bool:
75
+ return self.failed == 0
76
+
77
+
78
+ def _as_list(inputs: Union[PathLike, Iterable[PathLike]]) -> List[Path]:
79
+ if isinstance(inputs, (str, os.PathLike)):
80
+ return [Path(inputs)]
81
+ return [Path(p) for p in inputs]
82
+
83
+
84
+ def plan_conversions(
85
+ inputs: Union[PathLike, Iterable[PathLike]],
86
+ output_dir: Optional[PathLike] = None,
87
+ *,
88
+ recursive: bool = False,
89
+ overwrite: bool = False,
90
+ ) -> List[ConversionResult]:
91
+ """Work out what :func:`convert_many` would do, without converting anything.
92
+
93
+ Files given explicitly are always included; files found in directories are
94
+ included only if their extension is supported (and not already ``.webp``).
95
+ Output paths mirror the directory tree under ``output_dir`` when given.
96
+ """
97
+ exts = supported_extensions()
98
+ out_root = Path(output_dir) if output_dir is not None else None
99
+ results: List[ConversionResult] = []
100
+ seen: set = set()
101
+
102
+ def add(src: Path, dst: Path) -> None:
103
+ key = os.path.normcase(os.path.abspath(dst))
104
+ if key in seen:
105
+ results.append(ConversionResult(src, dst, SKIPPED, error="duplicate output name"))
106
+ elif dst.exists() and not overwrite:
107
+ results.append(ConversionResult(src, dst, SKIPPED, error="output exists"))
108
+ else:
109
+ results.append(ConversionResult(src, dst))
110
+ seen.add(key)
111
+
112
+ for item in _as_list(inputs):
113
+ if item.is_file():
114
+ add(item, (out_root / f"{item.stem}.webp") if out_root else default_output_path(item))
115
+ elif item.is_dir():
116
+ files = item.rglob("*") if recursive else item.iterdir()
117
+ for f in sorted(files):
118
+ ext = f.suffix.lower()
119
+ if ext == ".webp" or ext not in exts or not f.is_file():
120
+ continue
121
+ dst_dir = out_root / f.parent.relative_to(item) if out_root else f.parent
122
+ add(f, dst_dir / f"{f.stem}.webp")
123
+ else:
124
+ results.append(ConversionResult(item, None, FAILED, error="no such file or directory"))
125
+ return results
126
+
127
+
128
+ def _run_one(src: Path, dst: Path, opts: ConversionOptions, delete_original: bool) -> ConversionResult:
129
+ try:
130
+ in_size = src.stat().st_size
131
+ convert_file(src, dst, opts)
132
+ if delete_original and src.resolve() != dst.resolve():
133
+ src.unlink()
134
+ return ConversionResult(src, dst, CONVERTED, in_size, dst.stat().st_size)
135
+ except Exception as exc:
136
+ return ConversionResult(src, dst, FAILED, error=str(exc) or type(exc).__name__)
137
+
138
+
139
+ def convert_many(
140
+ inputs: Union[PathLike, Iterable[PathLike]],
141
+ output_dir: Optional[PathLike] = None,
142
+ options: Optional[ConversionOptions] = None,
143
+ *,
144
+ recursive: bool = False,
145
+ overwrite: bool = False,
146
+ delete_original: bool = False,
147
+ jobs: Optional[int] = None,
148
+ on_result: Optional[Callable[[ConversionResult], None]] = None,
149
+ **kwargs: Any,
150
+ ) -> BatchSummary:
151
+ """Convert files and/or directories to WebP.
152
+
153
+ Args:
154
+ inputs: a path or iterable of paths (files or directories).
155
+ output_dir: where to write output; default is next to each source.
156
+ options: :class:`ConversionOptions`; keyword overrides (``quality=...``) are also accepted.
157
+ recursive: descend into sub-directories.
158
+ overwrite: replace existing ``.webp`` files (default: skip them).
159
+ delete_original: remove each source after it converts successfully.
160
+ jobs: worker processes (default: CPU count; 1 = run in this process).
161
+ on_result: called with each :class:`ConversionResult` as it completes (in the calling process).
162
+
163
+ Individual failures never raise; check :attr:`BatchSummary.failed`.
164
+ """
165
+ opts = resolve_options(options, kwargs)
166
+ planned = plan_conversions(inputs, output_dir, recursive=recursive, overwrite=overwrite)
167
+ summary = BatchSummary()
168
+
169
+ def done(result: ConversionResult) -> None:
170
+ summary.results.append(result)
171
+ if on_result is not None:
172
+ on_result(result)
173
+
174
+ todo = []
175
+ for r in planned:
176
+ if r.status == PENDING:
177
+ todo.append(r)
178
+ else:
179
+ done(r)
180
+
181
+ workers = max(1, min(jobs or os.cpu_count() or 1, len(todo) or 1))
182
+ if workers == 1:
183
+ for r in todo:
184
+ done(_run_one(r.src, r.dst, opts, delete_original)) # type: ignore[arg-type]
185
+ else:
186
+ with ProcessPoolExecutor(max_workers=workers) as pool:
187
+ futures = {pool.submit(_run_one, r.src, r.dst, opts, delete_original): r for r in todo}
188
+ for fut in as_completed(futures):
189
+ r = futures[fut]
190
+ try:
191
+ done(fut.result())
192
+ except Exception as exc: # worker crashed
193
+ done(ConversionResult(r.src, r.dst, FAILED, error=str(exc) or type(exc).__name__))
194
+ return summary