pyverter 0.1.1__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,48 @@
1
+ CHANGELOG
2
+ =========
3
+
4
+ .. towncrier release notes start
5
+
6
+ pyverter 0.1.1 (2026-08-23)
7
+ ===========================
8
+
9
+ Miscellaneous
10
+ -------------
11
+
12
+ - Update license configuration in pyproject.toml to use SPDX expression and license-files.
13
+
14
+
15
+ pyverter 0.1.0 (2026-08-23)
16
+ ===========================
17
+
18
+ Features
19
+ --------
20
+
21
+ - Add tests for resizer (`#2 <https://github.com/fizyk/pyverter/issues/2>`_)
22
+ - Implemented multiprocessing - now images will be converted faster. (`#4 <https://github.com/fizyk/pyverter/issues/4>`_)
23
+ - Build a package structure and define an entrypoint script. This will make the resizer properly installable. (`#9 <https://github.com/fizyk/pyverter/issues/9>`_)
24
+ - List images and error encountered while processing them (`#27 <https://github.com/fizyk/pyverter/issues/27>`_)
25
+ - Print size change in summary (`#28 <https://github.com/fizyk/pyverter/issues/28>`_)
26
+ - Migrate to python 3.12 (`#40 <https://github.com/fizyk/pyverter/issues/40>`_)
27
+
28
+
29
+ Miscellaneous
30
+ -------------
31
+
32
+ - Lint and typecheck code. (`#1 <https://github.com/fizyk/pyverter/issues/1>`_)
33
+ - Added dependabot configuration and automerge workflow (`#3 <https://github.com/fizyk/pyverter/issues/3>`_)
34
+ - Add towncrier and tbump configuration and dependency to (`#5 <https://github.com/fizyk/pyverter/issues/5>`_)
35
+ - Drop dpi related code as they do not really matter. (`#10 <https://github.com/fizyk/pyverter/issues/10>`_)
36
+ - Removed resizer editable installation from Pipfile. It breaks dependabot. (`#15 <https://github.com/fizyk/pyverter/issues/15>`_)
37
+ - Freeze Pillow and click requirements in Pipfile (`#19 <https://github.com/fizyk/pyverter/issues/19>`_)
38
+ - Adjust progressbar typing for mypy and new click. (`#38 <https://github.com/fizyk/pyverter/issues/38>`_)
39
+ - Update code formatting with black 24.1 (`#55 <https://github.com/fizyk/pyverter/issues/55>`_)
40
+ - Extend pre-commit with pyproject-fmt to format pyproject.toml (`#337 <https://github.com/fizyk/pyverter/issues/337>`_)
41
+ - Migrate development environment and CI to uv (`#338 <https://github.com/fizyk/pyverter/issues/338>`_)
42
+ - Add release-schedule workflow. (`#343 <https://github.com/fizyk/pyverter/issues/343>`_)
43
+ - Migrated the Automerge workflow to `fizyk/actions-reuse` version 5.4.1. (`#348 <https://github.com/fizyk/pyverter/issues/348>`_)
44
+ - Configure Dependabot to update pre-commit dependencies. (`#351 <https://github.com/fizyk/pyverter/issues/351>`_)
45
+ - `#352 <https://github.com/fizyk/pyverter/issues/352>`_
46
+ - Settle on pyverter as the package name
47
+ - Update workflows for actions-reuse 3
48
+ - Use pre-commit for maintaining code style and linting
pyverter-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Grzegorz Śliwiński
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,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyverter
3
+ Version: 0.1.1
4
+ Summary: (Batch) Image resizer cli command based on Pillow
5
+ Keywords: cli,image,processing
6
+ Author: Grzegorz Śliwiński
7
+ Author-email: Grzegorz Śliwiński <fizyk+pypi@fizyk.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Dist: click
17
+ Requires-Dist: pillow
18
+ Requires-Python: >=3.12
19
+ Project-URL: Source, https://github.com/fizyk/pyverter
20
+ Project-URL: Bug Tracker, https://github.com/fizyk/pyverter/issues
21
+ Project-URL: Changelog, https://github.com/fizyk/pyverter/blob/v0.1.1/CHANGES.rst
22
+ Description-Content-Type: text/x-rst
23
+
24
+ A (batch) File converter
25
+ ========================
26
+
27
+ Program that will help you convert a whole collection of files in a batch.
28
+
29
+ At this stage, it can resize Images with Pillow
30
+
31
+ Plan is to allow more and provide a plugin system to be able to allow even more.
@@ -0,0 +1,8 @@
1
+ A (batch) File converter
2
+ ========================
3
+
4
+ Program that will help you convert a whole collection of files in a batch.
5
+
6
+ At this stage, it can resize Images with Pillow
7
+
8
+ Plan is to allow more and provide a plugin system to be able to allow even more.
@@ -0,0 +1,162 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = ["uv-build>=0.12.5,<0.13"]
4
+
5
+ [project]
6
+ name = "pyverter"
7
+ version = "0.1.1"
8
+ description = "(Batch) Image resizer cli command based on Pillow"
9
+ readme = "README.rst"
10
+ keywords = [
11
+ "cli",
12
+ "image",
13
+ "processing",
14
+ ]
15
+ license = "MIT"
16
+ license-files = ["LICENSE"]
17
+ requires-python = ">=3.12"
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Natural Language :: English",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.12",
25
+ ]
26
+ dependencies = [
27
+ "click",
28
+ "pillow",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Grzegorz Śliwiński"
33
+ email = "fizyk+pypi@fizyk.dev"
34
+
35
+ [project.urls]
36
+ Source = "https://github.com/fizyk/pyverter"
37
+ "Bug Tracker" = "https://github.com/fizyk/pyverter/issues"
38
+ Changelog = "https://github.com/fizyk/pyverter/blob/v0.1.1/CHANGES.rst"
39
+
40
+ [project.scripts]
41
+ pyverter = "pyverter.cli:pyverter"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "click==8.4.2",
46
+ "mypy==2.3.1",
47
+ "pdbpp==0.12.1",
48
+ "pillow==12.3.0",
49
+ "pytest==9.1.1",
50
+ "pytest-cov==7.1.0",
51
+ "towncrier==25.8.0",
52
+ "types-pillow==10.2.0.20240822",
53
+ ]
54
+ release = ["tbump==6.11.0"]
55
+
56
+ [tool.uv.build-backend]
57
+ module-name = "pyverter"
58
+ module-root = ""
59
+ source-include = ["CHANGES.rst"]
60
+
61
+ [tool.ruff]
62
+ target-version = "py311"
63
+ line-length = 120
64
+
65
+ [tool.ruff.lint]
66
+ select = [
67
+ "E",
68
+ "F",
69
+ "I",
70
+ "D",
71
+ "RUF",
72
+ ]
73
+
74
+ [tool.pyproject-fmt]
75
+ column_width = 120
76
+ indent = 4
77
+ keep_full_version = true
78
+ generate_python_version_classifiers = false
79
+ table_format = "long"
80
+ sub_table_spacing = """
81
+
82
+ """
83
+
84
+ [tool.mypy]
85
+ exclude = ["build/*"]
86
+
87
+ [tool.pytest.ini_options]
88
+ xfail_strict = true
89
+ addopts = "--showlocals --verbose --cov"
90
+ testpaths = "tests"
91
+
92
+ [[tool.tbump.before_commit]]
93
+ name = "Build changelog"
94
+ cmd = "uv run towncrier build --version {new_version} --yes"
95
+
96
+ [[tool.tbump.field]]
97
+ name = "extra"
98
+ default = ""
99
+
100
+ [[tool.tbump.file]]
101
+ src = "pyverter/__init__.py"
102
+
103
+ [[tool.tbump.file]]
104
+ src = "pyproject.toml"
105
+ search = 'version = "{current_version}"'
106
+
107
+ [[tool.tbump.file]]
108
+ src = "pyproject.toml"
109
+ search = 'Changelog = "https://github.com/fizyk/pyverter/blob/v{current_version}/CHANGES.rst"'
110
+
111
+ [tool.tbump.git]
112
+ message_template = "Release {new_version}"
113
+ tag_template = "v{new_version}"
114
+
115
+ [tool.tbump.version]
116
+ current = "0.1.1"
117
+ regex = '''
118
+ (?P<major>\d+)
119
+ \.
120
+ (?P<minor>\d+)
121
+ \.
122
+ (?P<patch>\d+)
123
+ (\-
124
+ (?P<extra>.+)
125
+ )?
126
+ '''
127
+
128
+ [tool.towncrier]
129
+ directory = "newsfragments"
130
+ filename = "CHANGES.rst"
131
+ issue_format = "`#{issue} <https://github.com/fizyk/pyverter/issues/{issue}>`_"
132
+ single_file = true
133
+
134
+ [[tool.towncrier.type]]
135
+ directory = "break"
136
+ name = "Breaking changes"
137
+ showcontent = true
138
+
139
+ [[tool.towncrier.type]]
140
+ directory = "depr"
141
+ name = "Deprecations"
142
+ showcontent = true
143
+
144
+ [[tool.towncrier.type]]
145
+ directory = "feature"
146
+ name = "Features"
147
+ showcontent = true
148
+
149
+ [[tool.towncrier.type]]
150
+ directory = "bugfix"
151
+ name = "Bugfixes"
152
+ showcontent = true
153
+
154
+ [[tool.towncrier.type]]
155
+ directory = "docs"
156
+ name = "Documentation"
157
+ showcontent = true
158
+
159
+ [[tool.towncrier.type]]
160
+ directory = "misc"
161
+ name = "Miscellaneous"
162
+ showcontent = true
@@ -0,0 +1,162 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = [ "uv-build>=0.12.5,<0.13" ]
4
+
5
+ [project]
6
+ name = "pyverter"
7
+ version = "0.1.1"
8
+ description = "(Batch) Image resizer cli command based on Pillow"
9
+ readme = "README.rst"
10
+ keywords = [ "cli", "image", "processing" ]
11
+ license = "MIT"
12
+ license-files = [ "LICENSE" ]
13
+ requires-python = ">=3.12"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Natural Language :: English",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "click",
24
+ "pillow",
25
+ ]
26
+
27
+ [[project.authors]]
28
+ name = "Grzegorz Śliwiński"
29
+ email = "fizyk+pypi@fizyk.dev"
30
+
31
+ [project.urls]
32
+ Source = "https://github.com/fizyk/pyverter"
33
+ "Bug Tracker" = "https://github.com/fizyk/pyverter/issues"
34
+ Changelog = "https://github.com/fizyk/pyverter/blob/v0.1.1/CHANGES.rst"
35
+
36
+ [project.scripts]
37
+ pyverter = "pyverter.cli:pyverter"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "click==8.4.2",
42
+ "mypy==2.3.1",
43
+ "pdbpp==0.12.1",
44
+ "pillow==12.3.0",
45
+ "pytest==9.1.1",
46
+ "pytest-cov==7.1.0",
47
+ "towncrier==25.8.0",
48
+ "types-pillow==10.2.0.20240822",
49
+ ]
50
+ release = [
51
+ "tbump==6.11.0",
52
+ ]
53
+
54
+ [tool.uv.build-backend]
55
+ module-name = "pyverter"
56
+ module-root = ""
57
+ source-include = [ "CHANGES.rst" ]
58
+
59
+ [tool.ruff]
60
+ target-version = "py311"
61
+ line-length = 120
62
+
63
+ [tool.ruff.lint]
64
+ select = [
65
+ "E", # pycodestyle
66
+ "F", # pyflakes
67
+ "I", # isort
68
+ "D", # pydocstyle
69
+ "RUF", # Ruff specific
70
+ ]
71
+
72
+ [tool.pyproject-fmt]
73
+ column_width = 120
74
+ indent = 4
75
+ keep_full_version = true
76
+ generate_python_version_classifiers = false
77
+ table_format = "long"
78
+ sub_table_spacing = "\n"
79
+
80
+ [tool.mypy]
81
+ exclude = [
82
+ "build/*",
83
+ ]
84
+
85
+ [tool.pytest.ini_options]
86
+ xfail_strict = true
87
+ addopts = "--showlocals --verbose --cov"
88
+ testpaths = "tests"
89
+
90
+ [tool.tbump]
91
+
92
+ [[tool.tbump.before_commit]]
93
+ name = "Build changelog"
94
+ cmd = "uv run towncrier build --version {new_version} --yes"
95
+
96
+ [[tool.tbump.field]]
97
+ name = "extra"
98
+ default = ""
99
+
100
+ [[tool.tbump.file]]
101
+ src = "pyverter/__init__.py"
102
+
103
+ [[tool.tbump.file]]
104
+ src = "pyproject.toml"
105
+ search = 'version = "{current_version}"'
106
+
107
+ [[tool.tbump.file]]
108
+ src = "pyproject.toml"
109
+ search = 'Changelog = "https://github.com/fizyk/pyverter/blob/v{current_version}/CHANGES.rst"'
110
+
111
+ [tool.tbump.git]
112
+ message_template = "Release {new_version}"
113
+ tag_template = "v{new_version}"
114
+
115
+ [tool.tbump.version]
116
+ current = "0.1.1"
117
+ regex = '''
118
+ (?P<major>\d+)
119
+ \.
120
+ (?P<minor>\d+)
121
+ \.
122
+ (?P<patch>\d+)
123
+ (\-
124
+ (?P<extra>.+)
125
+ )?
126
+ '''
127
+
128
+ [tool.towncrier]
129
+ directory = "newsfragments"
130
+ filename = "CHANGES.rst"
131
+ issue_format = "`#{issue} <https://github.com/fizyk/pyverter/issues/{issue}>`_"
132
+ single_file = true
133
+
134
+ [[tool.towncrier.type]]
135
+ directory = "break"
136
+ name = "Breaking changes"
137
+ showcontent = true
138
+
139
+ [[tool.towncrier.type]]
140
+ directory = "depr"
141
+ name = "Deprecations"
142
+ showcontent = true
143
+
144
+ [[tool.towncrier.type]]
145
+ directory = "feature"
146
+ name = "Features"
147
+ showcontent = true
148
+
149
+ [[tool.towncrier.type]]
150
+ directory = "bugfix"
151
+ name = "Bugfixes"
152
+ showcontent = true
153
+
154
+ [[tool.towncrier.type]]
155
+ directory = "docs"
156
+ name = "Documentation"
157
+ showcontent = true
158
+
159
+ [[tool.towncrier.type]]
160
+ directory = "misc"
161
+ name = "Miscellaneous"
162
+ showcontent = true
@@ -0,0 +1,3 @@
1
+ """Pyverter's main entry file."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,41 @@
1
+ """Main pyverter module."""
2
+
3
+ import pathlib
4
+
5
+ import click
6
+ from click import Context
7
+
8
+ from pyverter.resize import Resizer
9
+ from pyverter.stat import image_stats, list_images
10
+
11
+
12
+ @click.group()
13
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=pathlib.Path))
14
+ @click.pass_context
15
+ def pyverter(ctx: Context, path: pathlib.Path) -> None:
16
+ """Pyverter command group."""
17
+ ctx.ensure_object(dict)
18
+ ctx.obj["path"] = path
19
+
20
+
21
+ @pyverter.command()
22
+ @click.pass_context
23
+ def stats(ctx: Context) -> None:
24
+ """Print path's images stats."""
25
+ ctx.ensure_object(dict)
26
+ path = ctx.obj["path"]
27
+ click.echo(f"Reading images to process from {path.absolute()}")
28
+ count = image_stats(list_images(path))
29
+ click.echo(f"There's a total of {count} images.")
30
+ pass
31
+
32
+
33
+ @pyverter.command()
34
+ @click.option("--max-size", type=int)
35
+ @click.pass_context
36
+ def resize(ctx: Context, max_size: int) -> None:
37
+ """Resize images in given path."""
38
+ ctx.ensure_object(dict)
39
+ path = ctx.obj["path"]
40
+ resizer = Resizer(max_size, path, path)
41
+ resizer()
@@ -0,0 +1,171 @@
1
+ """Resize algorithm."""
2
+
3
+ import multiprocessing
4
+ import pathlib
5
+ import signal
6
+ from multiprocessing import Process, Queue
7
+ from multiprocessing.synchronize import Event
8
+ from queue import Empty
9
+ from typing import Any, Dict, List, NamedTuple
10
+
11
+ import click
12
+ from PIL import Image
13
+
14
+ from pyverter.stat import list_images
15
+ from pyverter.tools import sizeof_fmt
16
+
17
+
18
+ class ProcessedImages(NamedTuple):
19
+ """Processed image message."""
20
+
21
+ image_path: pathlib.Path
22
+ width: int
23
+ height: int
24
+ size_before: int
25
+ size_after: int
26
+ processed: bool
27
+ error: Exception | None
28
+
29
+
30
+ class NonInteruptableProcess(Process):
31
+ """Catch signal interrupt for processes."""
32
+
33
+ def run(self) -> None:
34
+ """Run process but immune to SIGINT."""
35
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
36
+ super().run()
37
+
38
+
39
+ class Resizer:
40
+ """Resizer algorithm."""
41
+
42
+ def __init__(self, max_size: int, source: pathlib.Path, destination: pathlib.Path) -> None:
43
+ """Initialize resizer."""
44
+ self.cpu_cores: int = multiprocessing.cpu_count()
45
+ self.max_size = max_size
46
+ self.source = source
47
+ self.destination = destination
48
+
49
+ def __call__(self) -> None:
50
+ """Run resizer."""
51
+ stop_event: Event = multiprocessing.Event()
52
+ process_no: int = int(self.cpu_cores / 2)
53
+
54
+ # initial shard queue
55
+ images_to_process: Queue = Queue()
56
+ images_processed: Queue = Queue()
57
+
58
+ processes: List[NonInteruptableProcess] = []
59
+ for _ in range(process_no):
60
+ process = NonInteruptableProcess(
61
+ target=self.process_images,
62
+ args=(images_to_process, images_processed, stop_event),
63
+ )
64
+ processes.append(process)
65
+ process.start()
66
+
67
+ click.echo(f"Reading images to process from {self.source.absolute()}")
68
+ image_counter: int = 0
69
+ for image in list_images(self.source):
70
+ images_to_process.put(image)
71
+ image_counter += 1
72
+ click.echo(f"Found {image_counter} image files.")
73
+
74
+ converted: List[ProcessedImages] = []
75
+ skipped: List[ProcessedImages] = []
76
+ errored: List[ProcessedImages] = []
77
+ size_before = 0
78
+ size_after = 0
79
+
80
+ try:
81
+ items: List[ProcessedImages] = []
82
+ with click.progressbar(
83
+ items,
84
+ show_percent=True,
85
+ show_pos=True,
86
+ length=image_counter,
87
+ label="Processing images",
88
+ ) as bar:
89
+ processed_counter: int = 0
90
+ while True:
91
+ try:
92
+ image_processed: ProcessedImages = images_processed.get(timeout=5)
93
+ except Empty:
94
+ break
95
+ processed_counter += 1
96
+ bar.update(1)
97
+ size_before += image_processed.size_before
98
+ size_after += image_processed.size_after
99
+ if image_processed.processed:
100
+ converted.append(image_processed)
101
+ else:
102
+ skipped.append(image_processed)
103
+ if image_processed.error:
104
+ errored.append(image_processed)
105
+ if processed_counter >= image_counter:
106
+ break
107
+ except KeyboardInterrupt:
108
+ click.echo("Process stopped")
109
+ finally:
110
+ click.echo("Finished")
111
+ stop_event.set()
112
+ for process in processes:
113
+ process.join()
114
+ click.echo(f" {len(converted)} of images got converted and {len(skipped)} got skipped.")
115
+ click.echo(
116
+ f" Original size of images was {sizeof_fmt(size_before)} and got reduced to {sizeof_fmt(size_after)}"
117
+ )
118
+ if errored:
119
+ click.echo(f" There were {len(errored)} errors.")
120
+ for errored_image in errored:
121
+ click.echo(f" {errored_image.image_path} errored with f{errored_image.error}")
122
+
123
+ def process_images(self, to_process: Queue, processed: Queue, stop: Event) -> None:
124
+ """Process images, one at a time."""
125
+ while True:
126
+ if stop.is_set():
127
+ break
128
+ try:
129
+ image_file: pathlib.Path = to_process.get(timeout=3)
130
+ except Empty:
131
+ continue
132
+ image_file.relative_to(self.source)
133
+ destination = self.destination / image_file.relative_to(self.source)
134
+ try:
135
+ processed.put(self.resize_image(image_file, destination))
136
+ except OSError as ex:
137
+ size = image_file.stat().st_size
138
+ processed.put(ProcessedImages(image_file, 0, 0, size, size, True, ex))
139
+
140
+ def resize_image(self, image_file: pathlib.Path, destination: pathlib.Path) -> ProcessedImages:
141
+ """Resize image, return true if the resize had been performed."""
142
+ with Image.open(image_file) as image:
143
+ old_size = image_file.stat().st_size
144
+ if image.width <= self.max_size and image.height <= self.max_size:
145
+ return ProcessedImages(
146
+ destination,
147
+ image.width,
148
+ image.height,
149
+ old_size,
150
+ old_size,
151
+ False,
152
+ None,
153
+ )
154
+ if image.width > image.height:
155
+ height = int(image.height * self.max_size / image.width)
156
+ width = self.max_size
157
+ else:
158
+ width = int(image.width * self.max_size / image.height)
159
+ height = self.max_size
160
+ new_image = image.resize((int(width), int(height)), Image.Resampling.LANCZOS)
161
+ kwargs: Dict[str, Any] = {"quality": 75}
162
+ if exif := image.info.get("exif"):
163
+ kwargs["exif"] = exif
164
+ try:
165
+ new_image.save(destination, **kwargs)
166
+ new_size = destination.stat().st_size
167
+ return ProcessedImages(destination, width, height, old_size, new_size, True, None)
168
+ except ValueError:
169
+ return ProcessedImages(destination, width, height, old_size, old_size, False, None)
170
+ finally:
171
+ new_image.close()
@@ -0,0 +1,50 @@
1
+ """Statistic gathering methods."""
2
+
3
+ import pathlib
4
+ from collections import defaultdict
5
+ from queue import SimpleQueue
6
+ from typing import Dict, Generator
7
+
8
+ import click
9
+ from PIL import Image
10
+
11
+
12
+ def list_images(path: pathlib.Path) -> Generator[pathlib.Path, None, None]:
13
+ """List all images from path."""
14
+ directories: SimpleQueue[pathlib.Path] = SimpleQueue()
15
+ directories.put(path)
16
+ while not directories.empty():
17
+ current_directory = directories.get()
18
+ for children_path in current_directory.iterdir():
19
+ if children_path.is_dir():
20
+ directories.put(children_path)
21
+ else:
22
+ try:
23
+ with Image.open(children_path):
24
+ yield children_path
25
+ except IOError:
26
+ pass
27
+
28
+
29
+ def count_images(path):
30
+ """Count all images in a path."""
31
+ return sum(1 for _ in list_images(path))
32
+
33
+
34
+ def image_stats(images: Generator[pathlib.Path, None, None]) -> int:
35
+ """Actual print all images statistics."""
36
+ size_stat: Dict = defaultdict(int)
37
+ images_count = 0
38
+ for image_file in images:
39
+ with Image.open(image_file) as image:
40
+ if image.width > image.height:
41
+ size = (image.width, image.height)
42
+ else:
43
+ size = (image.height, image.width)
44
+ size_stat[size] += 1
45
+ images_count += 1
46
+ click.echo("Sizes:")
47
+ for size, count in size_stat.items():
48
+ click.echo(f" {size[0]:5}x{size[1]:<5}: {count:6}")
49
+
50
+ return images_count
@@ -0,0 +1,10 @@
1
+ """Additional tools to use."""
2
+
3
+
4
+ def sizeof_fmt(num, suffix="B"):
5
+ """Format sizes with proper unit."""
6
+ for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
7
+ if abs(num) < 1024.0:
8
+ return f"{num:3.1f}{unit}{suffix}"
9
+ num /= 1024.0
10
+ return f"{num:.1f}Yi{suffix}"