kellerlab-pre-commit-hooks 0.2.3__tar.gz → 0.3.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.
Files changed (16) hide show
  1. {kellerlab_pre_commit_hooks-0.2.3/src/kellerlab_pre_commit_hooks.egg-info → kellerlab_pre_commit_hooks-0.3.0}/PKG-INFO +4 -4
  2. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/README.md +3 -3
  3. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/pyproject.toml +1 -1
  4. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0/src/kellerlab_pre_commit_hooks.egg-info}/PKG-INFO +4 -4
  5. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/pre_commit_hooks/optimize_images.py +30 -11
  6. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/tests/test_optimize_images.py +131 -0
  7. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/LICENSE +0 -0
  8. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/setup.cfg +0 -0
  9. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/kellerlab_pre_commit_hooks.egg-info/SOURCES.txt +0 -0
  10. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/kellerlab_pre_commit_hooks.egg-info/dependency_links.txt +0 -0
  11. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/kellerlab_pre_commit_hooks.egg-info/entry_points.txt +0 -0
  12. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/kellerlab_pre_commit_hooks.egg-info/requires.txt +0 -0
  13. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/kellerlab_pre_commit_hooks.egg-info/top_level.txt +0 -0
  14. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/pre_commit_hooks/__init__.py +0 -0
  15. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/src/pre_commit_hooks/flatten_validate.py +0 -0
  16. {kellerlab_pre_commit_hooks-0.2.3 → kellerlab_pre_commit_hooks-0.3.0}/tests/test_flatten_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kellerlab-pre-commit-hooks
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Reusable pre-commit hooks for OpenSCAD projects
5
5
  Author-email: Patrick Pötz <kellervater@gmail.com>
6
6
  License-Expression: MIT
@@ -39,7 +39,7 @@ Two hooks for OpenSCAD-based 3D printing projects:
39
39
  | Hook | Description |
40
40
  |------|-------------|
41
41
  | `flatten-validate` | Runs `scadm flatten --all` and fails if flattened files have uncommitted changes |
42
- | `optimize-images` | Resizes oversized JPEGs, compresses them, and strips EXIF data (privacy) |
42
+ | `optimize-images` | Resizes oversized JPEGs/PNGs, compresses them, and strips metadata (privacy) |
43
43
 
44
44
  ## 🔧 Usage
45
45
 
@@ -72,7 +72,7 @@ Flattens OpenSCAD files via [scadm](https://pypi.org/project/scadm/) and validat
72
72
 
73
73
  ### `optimize-images`
74
74
 
75
- Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF metadata (removes GPS coordinates from real-life photos).
75
+ Resizes JPEGs and PNGs exceeding max width, compresses them, and strips metadata. EXIF data (including GPS coordinates) is removed from JPEGs; text metadata chunks are removed from PNGs.
76
76
 
77
77
  ```yaml
78
78
  - id: optimize-images
@@ -84,7 +84,7 @@ Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF
84
84
  | Arg | Default | Description |
85
85
  |-----|---------|-------------|
86
86
  | `--max-width` | `1920` | Maximum image width in pixels |
87
- | `--quality` | `85` | JPEG compression quality (1-95) |
87
+ | `--quality` | `85` | JPEG compression quality (1-95, ignored for PNG) |
88
88
 
89
89
  Both hooks **fail when files are modified**, printing instructions to `git add` the changes. Re-run `git commit` after staging.
90
90
 
@@ -11,7 +11,7 @@ Two hooks for OpenSCAD-based 3D printing projects:
11
11
  | Hook | Description |
12
12
  |------|-------------|
13
13
  | `flatten-validate` | Runs `scadm flatten --all` and fails if flattened files have uncommitted changes |
14
- | `optimize-images` | Resizes oversized JPEGs, compresses them, and strips EXIF data (privacy) |
14
+ | `optimize-images` | Resizes oversized JPEGs/PNGs, compresses them, and strips metadata (privacy) |
15
15
 
16
16
  ## 🔧 Usage
17
17
 
@@ -44,7 +44,7 @@ Flattens OpenSCAD files via [scadm](https://pypi.org/project/scadm/) and validat
44
44
 
45
45
  ### `optimize-images`
46
46
 
47
- Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF metadata (removes GPS coordinates from real-life photos).
47
+ Resizes JPEGs and PNGs exceeding max width, compresses them, and strips metadata. EXIF data (including GPS coordinates) is removed from JPEGs; text metadata chunks are removed from PNGs.
48
48
 
49
49
  ```yaml
50
50
  - id: optimize-images
@@ -56,7 +56,7 @@ Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF
56
56
  | Arg | Default | Description |
57
57
  |-----|---------|-------------|
58
58
  | `--max-width` | `1920` | Maximum image width in pixels |
59
- | `--quality` | `85` | JPEG compression quality (1-95) |
59
+ | `--quality` | `85` | JPEG compression quality (1-95, ignored for PNG) |
60
60
 
61
61
  Both hooks **fail when files are modified**, printing instructions to `git add` the changes. Re-run `git commit` after staging.
62
62
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kellerlab-pre-commit-hooks"
7
- version = "0.2.3"
7
+ version = "0.3.0"
8
8
  description = "Reusable pre-commit hooks for OpenSCAD projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kellerlab-pre-commit-hooks
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Reusable pre-commit hooks for OpenSCAD projects
5
5
  Author-email: Patrick Pötz <kellervater@gmail.com>
6
6
  License-Expression: MIT
@@ -39,7 +39,7 @@ Two hooks for OpenSCAD-based 3D printing projects:
39
39
  | Hook | Description |
40
40
  |------|-------------|
41
41
  | `flatten-validate` | Runs `scadm flatten --all` and fails if flattened files have uncommitted changes |
42
- | `optimize-images` | Resizes oversized JPEGs, compresses them, and strips EXIF data (privacy) |
42
+ | `optimize-images` | Resizes oversized JPEGs/PNGs, compresses them, and strips metadata (privacy) |
43
43
 
44
44
  ## 🔧 Usage
45
45
 
@@ -72,7 +72,7 @@ Flattens OpenSCAD files via [scadm](https://pypi.org/project/scadm/) and validat
72
72
 
73
73
  ### `optimize-images`
74
74
 
75
- Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF metadata (removes GPS coordinates from real-life photos).
75
+ Resizes JPEGs and PNGs exceeding max width, compresses them, and strips metadata. EXIF data (including GPS coordinates) is removed from JPEGs; text metadata chunks are removed from PNGs.
76
76
 
77
77
  ```yaml
78
78
  - id: optimize-images
@@ -84,7 +84,7 @@ Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF
84
84
  | Arg | Default | Description |
85
85
  |-----|---------|-------------|
86
86
  | `--max-width` | `1920` | Maximum image width in pixels |
87
- | `--quality` | `85` | JPEG compression quality (1-95) |
87
+ | `--quality` | `85` | JPEG compression quality (1-95, ignored for PNG) |
88
88
 
89
89
  Both hooks **fail when files are modified**, printing instructions to `git add` the changes. Re-run `git commit` after staging.
90
90
 
@@ -1,4 +1,4 @@
1
- """Pre-commit hook: resize and compress JPEG images."""
1
+ """Pre-commit hook: resize and compress JPEG and PNG images."""
2
2
 
3
3
  import argparse
4
4
  import shlex
@@ -8,24 +8,32 @@ from PIL import Image, ImageOps
8
8
 
9
9
 
10
10
  def _optimize_file(filepath, max_width, quality):
11
- """Resize, compress, and strip EXIF from a single JPEG file.
11
+ """Resize, compress, and strip metadata from a single image file.
12
+
13
+ Supports JPEG and PNG formats. JPEGs are compressed with the given quality
14
+ and have EXIF data stripped. PNGs are saved with Pillow's optimize flag
15
+ and have text metadata stripped.
12
16
 
13
17
  Args:
14
- filepath: Path to the JPEG file.
18
+ filepath: Path to the image file.
15
19
  max_width: Maximum allowed width in pixels.
16
- quality: JPEG compression quality (1-95).
20
+ quality: JPEG compression quality (1-95). Ignored for PNG.
17
21
 
18
22
  Returns:
19
23
  True if the file was modified, False otherwise.
20
24
  """
21
25
  with Image.open(filepath) as img:
22
- # Apply EXIF orientation before checking size
26
+ img_format = img.format # "JPEG" or "PNG"
27
+ if img_format not in ("JPEG", "PNG"):
28
+ msg = f"Unsupported format '{img_format}' for {filepath}"
29
+ raise ValueError(msg)
30
+ has_png_text = img_format == "PNG" and bool(getattr(img, "text", None))
23
31
  img = ImageOps.exif_transpose(img)
24
32
  width, height = img.size
25
33
  needs_resize = width > max_width
26
34
  has_exif = bool(img.info.get("exif"))
27
35
 
28
- if not needs_resize and not has_exif:
36
+ if not needs_resize and not has_exif and not has_png_text:
29
37
  return False
30
38
 
31
39
  if needs_resize:
@@ -34,17 +42,28 @@ def _optimize_file(filepath, max_width, quality):
34
42
  new_size = (max_width, new_height)
35
43
  img = img.resize(new_size, Image.LANCZOS)
36
44
 
37
- if img.mode in ("RGBA", "P"):
38
- img = img.convert("RGB")
39
- img.save(filepath, "JPEG", quality=quality, optimize=True)
45
+ if img_format == "PNG":
46
+ # Strip metadata by creating a clean image without materializing
47
+ # the pixel stream as a Python list.
48
+ clean = Image.new(img.mode, img.size)
49
+ if img.mode == "P":
50
+ palette = img.getpalette()
51
+ if palette is not None:
52
+ clean.putpalette(palette)
53
+ clean.frombytes(img.tobytes())
54
+ clean.save(filepath, "PNG", optimize=True)
55
+ else:
56
+ if img.mode in ("RGBA", "P"):
57
+ img = img.convert("RGB")
58
+ img.save(filepath, "JPEG", quality=quality, optimize=True)
40
59
 
41
60
  return True
42
61
 
43
62
 
44
63
  def main():
45
64
  """Entry point for the optimize-images pre-commit hook."""
46
- parser = argparse.ArgumentParser(description="Resize and compress JPEG images.")
47
- parser.add_argument("filenames", nargs="*", help="JPEG files to check.")
65
+ parser = argparse.ArgumentParser(description="Resize and compress images.")
66
+ parser.add_argument("filenames", nargs="*", help="Image files to check.")
48
67
  parser.add_argument(
49
68
  "--max-width",
50
69
  type=int,
@@ -3,6 +3,7 @@
3
3
  from unittest import mock
4
4
 
5
5
  from PIL import Image
6
+ from PIL.PngImagePlugin import PngInfo
6
7
 
7
8
  from pre_commit_hooks.optimize_images import main
8
9
 
@@ -27,6 +28,23 @@ def _create_jpeg(path, width, height, exif=False):
27
28
  img.save(str(path), "JPEG", quality=95)
28
29
 
29
30
 
31
+ def _create_png(path, width, height, text_meta=False):
32
+ """Create a test PNG file.
33
+
34
+ Args:
35
+ path: File path to write.
36
+ width: Image width in pixels.
37
+ height: Image height in pixels.
38
+ text_meta: If True, embed text metadata chunks.
39
+ """
40
+ img = Image.new("RGBA", (width, height), color="blue")
41
+ pnginfo = PngInfo()
42
+ if text_meta:
43
+ pnginfo.add_text("Software", "TestSuite")
44
+ pnginfo.add_text("Comment", "test metadata")
45
+ img.save(str(path), "PNG", pnginfo=pnginfo)
46
+
47
+
30
48
  class TestOptimizeImages:
31
49
  """Tests for the optimize-images hook."""
32
50
 
@@ -130,3 +148,116 @@ class TestOptimizeImages:
130
148
  result = main()
131
149
 
132
150
  assert result == 1
151
+
152
+ # --- PNG tests ---
153
+
154
+ def test_large_png_resized(self, tmp_path):
155
+ """PNGs wider than max-width are resized."""
156
+ img_path = tmp_path / "large.png"
157
+ _create_png(img_path, 3000, 2000)
158
+
159
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
160
+ result = main()
161
+
162
+ assert result == 1
163
+ with Image.open(img_path) as img:
164
+ assert img.size[0] == 1920
165
+ assert img.size[1] == 1280
166
+
167
+ def test_small_png_untouched(self, tmp_path):
168
+ """Small PNGs without metadata are not modified."""
169
+ img_path = tmp_path / "small.png"
170
+ _create_png(img_path, 800, 600)
171
+ original_size = img_path.stat().st_size
172
+
173
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
174
+ result = main()
175
+
176
+ assert result == 0
177
+ assert img_path.stat().st_size == original_size
178
+
179
+ def test_png_metadata_stripped(self, tmp_path):
180
+ """Text metadata is stripped from PNGs."""
181
+ img_path = tmp_path / "meta.png"
182
+ _create_png(img_path, 800, 600, text_meta=True)
183
+
184
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
185
+ result = main()
186
+
187
+ assert result == 1
188
+ with Image.open(img_path) as img:
189
+ assert not getattr(img, "text", None)
190
+
191
+ def test_png_preserves_transparency(self, tmp_path):
192
+ """PNGs retain their alpha channel after optimization."""
193
+ img_path = tmp_path / "alpha.png"
194
+ img = Image.new("RGBA", (3000, 2000), color=(0, 0, 255, 128))
195
+ img.save(str(img_path), "PNG")
196
+
197
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
198
+ result = main()
199
+
200
+ assert result == 1
201
+ with Image.open(img_path) as img:
202
+ assert img.mode == "RGBA"
203
+ assert img.size[0] == 1920
204
+
205
+ def test_mixed_jpeg_and_png(self, tmp_path):
206
+ """Both JPEG and PNG files are handled in a single run."""
207
+ jpg_path = tmp_path / "big.jpg"
208
+ png_path = tmp_path / "big.png"
209
+ _create_jpeg(jpg_path, 4000, 3000)
210
+ _create_png(png_path, 4000, 3000)
211
+
212
+ with mock.patch(
213
+ "sys.argv",
214
+ ["optimize-images", "--max-width=1920", str(jpg_path), str(png_path)],
215
+ ):
216
+ result = main()
217
+
218
+ assert result == 1
219
+ with Image.open(jpg_path) as img:
220
+ assert img.size[0] == 1920
221
+ with Image.open(png_path) as img:
222
+ assert img.size[0] == 1920
223
+
224
+ def test_paletted_png_resized(self, tmp_path):
225
+ """Paletted PNGs are resized and palette is preserved."""
226
+ img_path = tmp_path / "palette.png"
227
+ img = Image.new("P", (3000, 2000))
228
+ img.putpalette([i % 256 for i in range(768)])
229
+ img.save(str(img_path), "PNG")
230
+
231
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
232
+ result = main()
233
+
234
+ assert result == 1
235
+ with Image.open(img_path) as img:
236
+ assert img.mode == "P"
237
+ assert img.size[0] == 1920
238
+ assert img.getpalette() is not None
239
+
240
+ def test_paletted_png_with_transparency(self, tmp_path):
241
+ """Paletted PNGs with transparency are handled correctly."""
242
+ img_path = tmp_path / "palette_alpha.png"
243
+ img = Image.new("RGBA", (3000, 2000), color=(255, 0, 0, 128))
244
+ img = img.convert("P")
245
+ img.save(str(img_path), "PNG")
246
+
247
+ with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]):
248
+ result = main()
249
+
250
+ assert result == 1
251
+ with Image.open(img_path) as img:
252
+ assert img.size[0] == 1920
253
+
254
+ def test_unsupported_format_raises(self, tmp_path):
255
+ """Unsupported image formats cause a failure."""
256
+ img_path = tmp_path / "image.bmp"
257
+ img = Image.new("RGB", (800, 600), color="green")
258
+ img.save(str(img_path), "BMP")
259
+
260
+ with mock.patch("sys.argv", ["optimize-images", str(img_path)]):
261
+ result = main()
262
+
263
+ assert result == 1