Skip to content

Commit e8dd914

Browse files
committed
Add inplace flag
1 parent 2c9e938 commit e8dd914

7 files changed

Lines changed: 112 additions & 1524 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,6 @@ public/
113113
*.png
114114
*.jpg
115115
shrunk/
116+
117+
# Lock
118+
uv.lock

conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
import pytest
66

77

8-
@pytest.fixture()
8+
@pytest.fixture
99
def resources() -> pathlib.Path:
1010
"""Open test image."""
1111
return pathlib.Path(__file__).parent.resolve() / "tests" / "resources"
1212

1313

14-
@pytest.fixture()
14+
@pytest.fixture
1515
def image_path(resources: pathlib.Path) -> pathlib.Path:
1616
"""Open test image."""
1717
return resources / "test.jpg"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "shrinkix"
3-
version = "1.0.1"
3+
version = "1.1.0rc1"
44
description = "Reduces the size of images for the web."
55
authors = [
66
{ name = "Dashstrom", email = "dashstrom.pro@gmail.com" }

shrinkix/cli.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,14 @@ def get_parser() -> argparse.ArgumentParser:
6262
parser.add_argument(
6363
"-o",
6464
"--output-dir",
65-
default="shrunk",
6665
help="Output for all images.",
6766
)
67+
parser.add_argument(
68+
"-i",
69+
"--inplace",
70+
action="store_true",
71+
help="Replace original images.",
72+
)
6873
parser.add_argument(
6974
"-e",
7075
"--experimental-color-reduction",
@@ -128,13 +133,27 @@ def entrypoint(argv: Optional[Sequence[str]] = None) -> None:
128133
max_height=args.max_height,
129134
keep_metadata=args.keep_metadata,
130135
experimental_color_reduction=args.experimental_color_reduction,
131-
format=args.format,
132136
copyright=args.copyright,
133137
artist=args.artist,
134138
background=args.background,
135139
quality=args.quality,
136140
)
137-
shrinker.bulk(args.path, args.output_dir, colors=args.colors)
141+
if args.output_dir and args.inplace:
142+
parser.error(
143+
'"-o/--output-dir" and "-i/--inplace" are mutually exclusive'
144+
)
145+
if not args.output_dir or not args.inplace:
146+
parser.error(
147+
"you should provide at least "
148+
'"-o/--output-dir" or "-i/--inplace"'
149+
)
150+
shrinker.bulk(
151+
args.path,
152+
output=args.output_dir,
153+
format=args.format,
154+
inplace=args.inplace,
155+
colors=args.colors,
156+
)
138157
except Exception as err: # NoQA: BLE001 # pragma: no cover
139158
logger.critical("Unexpected error", exc_info=err)
140159
logger.critical("Please, report this error to %s.", __issues__)

shrinkix/shrinker.py

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
"""Module for shrink images."""
22

3+
import logging
34
import pathlib
45
from math import floor, sqrt
56
from time import time
6-
from typing import Any, Literal, Optional
7+
from typing import Any, Optional
78

89
from PIL import Image
910
from tqdm import tqdm
1011

1112
from .utils import (
1213
AllImageSource,
14+
Formats,
1315
PathLike,
1416
PathOrFile,
1517
open_image,
1618
ratio,
1719
size,
20+
verify_format,
1821
)
1922

2023
MAX_COLORS = 256
2124
MAX_SAMPLE = 100_00
2225

26+
logger = logging.getLogger(__name__)
27+
2328

2429
class Shrinkix:
2530
def __init__( # noqa: PLR0913
2631
self,
2732
*,
28-
format: Optional[Literal["PNG", "JPEG", "JPG", "WEBP"]] = None, # noqa: A002
2933
keep_metadata: Optional[bool] = None,
3034
max_width: Optional[int] = None,
3135
max_height: Optional[int] = None,
@@ -46,20 +50,26 @@ def __init__( # noqa: PLR0913
4650
self.artist = artist
4751
self.background = background
4852
self.quality = int(quality) if quality is not None else None
49-
if format is None:
50-
self.format = "PNG"
51-
elif format.casefold() == "jpg":
52-
self.format = "JPEG"
53-
else:
54-
self.format = format.upper()
5553

56-
def shrink( # noqa: PLR0912, C901
54+
def shrink( # noqa: PLR0912, C901, PLR0915
5755
self,
5856
image: AllImageSource,
5957
output: PathOrFile,
58+
format: Optional[Formats] = None, # noqa: A002
6059
colors: Optional[int] = None,
6160
) -> None:
6261
"""Shrink an image."""
62+
# Get the output format
63+
if format is None:
64+
if isinstance(output, (str, pathlib.Path)):
65+
format = verify_format(pathlib.Path(output).suffix) # noqa: A001
66+
else:
67+
msg = (
68+
"Cannot infer the format from the output; "
69+
"please specify the format parameter."
70+
)
71+
raise ValueError(msg)
72+
6373
# Load image
6474
im = open_image(image)
6575

@@ -88,9 +98,14 @@ def shrink( # noqa: PLR0912, C901
8898
im = Image.new("RGBA", im.size)
8999
im.putdata(data)
90100

91-
# Reduce colors
92-
im = self.reduce(im, colors=colors)
93-
options: dict[str, Any] = {"format": self.format}
101+
# Reduce colors (Palette is not supported on JPEG or WEBP)
102+
if format not in ("JPEG", "WEBP"):
103+
im = self.reduce(im, colors=colors)
104+
else:
105+
im = im.convert("RGB") if im.mode != "RGB" else im
106+
107+
# Specify format in options
108+
options: dict[str, Any] = {"format": format}
94109

95110
# Add exif information
96111
import piexif
@@ -106,16 +121,17 @@ def shrink( # noqa: PLR0912, C901
106121
# Save with optimization
107122
if self.quality is None:
108123
quality = floor(30 + 65 * (1 - min(sqrt(w * h) / 4096, 1)))
124+
logger.info("Resolved quality is %s", quality)
109125
else:
110126
quality = self.quality
111127
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#png
112-
if self.format == "PNG":
128+
if format == "PNG":
113129
options["optimize"] = True
114130
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg
115-
elif self.format == "JPEG":
131+
elif format == "JPEG":
116132
options["quality"] = quality
117133
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp
118-
elif self.format == "WEBP":
134+
elif format == "WEBP":
119135
options["lossless"] = False
120136
options["quality"] = quality
121137
options["alpha_quality"] = quality
@@ -127,29 +143,50 @@ def shrink( # noqa: PLR0912, C901
127143
else:
128144
im.save(output, **options)
129145

130-
def export_name(self, path: pathlib.Path) -> str:
146+
def export_name(self, path: pathlib.Path, format: Optional[str]) -> str: # noqa: A002
131147
"""Export name."""
132-
return path.with_suffix(f".{self.format.lower()}").name
148+
if format:
149+
return path.with_suffix(f".{format.lower()}").name
150+
return path.name
133151

134-
def bulk(
152+
def bulk( # noqa: PLR0912
135153
self,
136154
files: list[PathLike],
137-
output: PathLike,
155+
output: Optional[PathLike],
156+
inplace: Optional[bool] = None,
157+
format: Optional[Formats] = None, # noqa: A002
138158
colors: Optional[int] = None,
139159
) -> None:
140160
"""Shrink a list of file and export it in output."""
141-
root = pathlib.Path(output)
142-
paths = {}
161+
if inplace is None:
162+
inplace = False
163+
if format:
164+
format = verify_format(format) # noqa: A001
165+
166+
if inplace:
167+
if output is not None:
168+
error_message = '"output" and "inplace" are mutually exclusive'
169+
raise ValueError(error_message)
170+
elif output is None:
171+
error_message = 'You should provide at least "output" or "inplace"'
172+
raise ValueError(error_message)
173+
else:
174+
output = pathlib.Path(output)
175+
176+
paths: dict[pathlib.Path, pathlib.Path] = {}
143177
for file in files:
144178
path = pathlib.Path(file)
145179
if path.is_dir():
146-
for sub_path in path.glob("**/*"):
180+
source_paths = list(path.glob("**/*"))
181+
for sub_path in source_paths:
147182
if sub_path.is_file():
148-
output_path = root / self.export_name(sub_path)
149-
paths[sub_path] = output_path
183+
name = self.export_name(sub_path, format)
184+
parent = sub_path.parent if inplace else output
185+
paths[sub_path] = parent / name # type: ignore[operator]
150186
else:
151-
output_path = root / self.export_name(path)
152-
paths[path] = output_path
187+
name = self.export_name(path, format)
188+
parent = path.parent if inplace else output
189+
paths[path] = parent / name # type: ignore[operator]
153190

154191
with tqdm(paths.items()) as bar:
155192
for src, dst in bar:
@@ -163,6 +200,8 @@ def bulk(
163200
f"ratio: {ratio(src, dst):.2%}, time: {elapsed:.3f}s, "
164201
f"size: {size(src)} to {size(dst)}",
165202
)
203+
if inplace and src.resolve() != dst.resolve():
204+
src.unlink(missing_ok=True)
166205

167206
def reduce(
168207
self,
@@ -173,10 +212,6 @@ def reduce(
173212
# Open and format for model
174213
im = open_image(image)
175214

176-
# Palette is not supported on JPEG or WEBP
177-
if self.format in ("JPEG", "WEBP"):
178-
return im.convert("RGB") if im.mode != "RGB" else im
179-
180215
# No optimization on palette or black and white
181216
if im.mode in ("L", "LA", "P", "PA"):
182217
return im
@@ -210,6 +245,7 @@ def reduce(
210245
)
211246
colors = len(block_counts)
212247
colors = min(colors, MAX_COLORS)
248+
logger.info("Use %s colors", colors)
213249

214250
if not self.experimental_color_reduction:
215251
return im.quantize(colors)

shrinkix/utils.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
"""Utils functions."""
22

33
import pathlib
4-
from typing import IO, TYPE_CHECKING, Any, Union
4+
from typing import IO, Literal, Union
55

66
from PIL import Image
77

8-
if TYPE_CHECKING:
9-
import numpy.typing as npt
10-
118
PathLike = Union[str, pathlib.Path]
129
PathOrFile = Union[PathLike, IO[bytes]]
13-
AllImageSource = Union[PathOrFile, Image.Image, "npt.NDArray[Any]"]
10+
AllImageSource = Union[PathOrFile, Image.Image, Image.SupportsArrayInterface]
11+
Formats = Literal["PNG", "JPEG", "JPG", "WEBP"]
12+
FORMAT_MAPPER: dict[str, Formats] = {
13+
"png": "PNG",
14+
"jpeg": "JPEG",
15+
"jpg": "JPEG",
16+
"webp": "WEBP",
17+
}
18+
19+
20+
def verify_format(format: str) -> Formats: # noqa: A002
21+
"""Get normalize format and raise error if not supported."""
22+
key = format.casefold().replace(".", "")
23+
if key in FORMAT_MAPPER:
24+
return FORMAT_MAPPER[key]
25+
message_error = f"Invalid format {format!r}"
26+
raise ValueError(message_error)
1427

1528

1629
def open_image(image: AllImageSource) -> Image.Image:

0 commit comments

Comments
 (0)