diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5814888..96c1a8a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.13" cache: "pip" cache-dependency-path: "**/pyproject.toml" @@ -35,7 +35,7 @@ jobs: # https://docs.pypi.org/trusted-publishers/using-a-publisher/ pypi-publish: needs: build - environment: 'publish' + environment: "publish" name: ⬆️ Upload release to PyPI runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c00d88f..13334b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ env: PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_PYTHON_VERSION_WARNING: "1" COVERAGE_CORE: sysmon # Only supported on Python 3.12+, ignore on older versions - PYTHON_LATEST: "3.12" + PYTHON_LATEST: "3.13" jobs: lint: @@ -31,11 +31,11 @@ jobs: needs: lint strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install optimizers - run: | + run: | sudo apt-get install -y jpegoptim pngquant gifsicle optipng libjpeg-progs webp - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3c926a..bee04aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,27 @@ -default_language_version: - python: python3 +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +# Don't touch our sample test images +exclude: "^tests/images/" repos: - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.10.1 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: black - + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.2' + rev: "v0.7.1" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + - repo: https://github.com/pycontribs/mirrors-prettier + rev: v3.3.3 + hooks: + - id: prettier + types_or: [json, yaml, markdown, bash, editorconfig, toml] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index c592bb7..5ed2b4d 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ build: python: "3.12" sphinx: - configuration: docs/conf.py + configuration: docs/conf.py python: install: diff --git a/README.md b/README.md index abafe6e..eb685a0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It converts the image between the libraries when necessary. Willow currently has basic resize and crop operations, face and feature detection and animated GIF support. New operations and library integrations can also be [easily implemented](https://willow.wagtail.org/latest/guide/extend.html). -The library is written in pure Python and supports versions 3.8 3.9, 3.10, 3.11 and 3.12. +The library is written in pure Python and supports versions 3.9, 3.10, 3.11, 3.12, and 3.13. ## Examples @@ -40,7 +40,6 @@ This will open the image file with Pillow or Wand (if Pillow is unavailable). It will then resize it to 100x100 pixels and save it back out as a PNG file. - ### Detecting faces ```python @@ -87,6 +86,6 @@ As neither Pillow nor Wand support detecting faces, Willow would automatically c \* Always returns `False` -\** Always returns `1` +\*\* Always returns `1` ⁺ Requires the [pillow-heif](https://pypi.org/project/pillow-heif/) library diff --git a/docs/changelog.rst b/docs/changelog.rst index 5bccc5e..536e04a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,9 @@ Unreleased ---------- - Improve type handling when running optimisers (Jake Howard) +- Add support for Pillow 11 (Storm Heg) +- Add support for Python 3.13 (Storm Heg) +- Drop support for Python 3.8 (Storm Heg) 1.8.0 (2024-01-17) ------------------ diff --git a/docs/installation.rst b/docs/installation.rst index 3759b48..261e491 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,7 +1,7 @@ Installation ============ -Willow supports Python 3.8+. It is a pure Python library with no hard +Willow supports Python 3.9+. It is a pure Python library with no hard dependencies so it doesn't require a C compiler for a basic installation. Installation using ``pip`` diff --git a/pyproject.toml b/pyproject.toml index 8cb5f1d..f5e6b98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,22 +16,22 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dynamic = ["version"] # will read __version__ from willow/__init__.py -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "filetype>=1.0.10,!=1.1.0", "defusedxml>=0.7,<1.0", ] [project.optional-dependencies] -pillow = ["Pillow>=9.1.0,<11.0.0"] +pillow = ["Pillow>=9.1.0,<12.0.0"] wand = ["Wand>=0.6,<1.0"] heif = [ "pillow-heif>=0.10.0,<1.0.0; python_version < '3.12'", @@ -75,7 +75,6 @@ exclude = [ "tests/", "CHANGELOG.txt", "Dockerfile.py3", - "ruff.toml", "runtests.py", ] @@ -115,3 +114,18 @@ exclude_lines = [ # Nor complain about type checking "if TYPE_CHECKING:", ] + +[tool.ruff] +target-version = "py39" # minimum target version + +# E501: Line too long +lint.ignore = ["E501"] +lint.select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "T20", # flake8-print + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "UP", # pyupgrade +] diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 2bd2a45..0000000 --- a/ruff.toml +++ /dev/null @@ -1,14 +0,0 @@ -# E501: Line too long -ignore = ["E501"] - -target-version = "py38" # minimum target version - -select = [ - "E", # pycodestyle errors - "F", # pyflakes - "I", # isort - "T20", # flake8-print - "BLE", # flake8-blind-except - "C4", # flake8-comprehensions - "UP", # pyupgrade -] diff --git a/tests/test_image.py b/tests/test_image.py index 194c9b4..4e967fc 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -326,8 +326,9 @@ def test_optimize_with_an_actual_file( # let's preserve the original file by mocking the open call with it so we don't end up changing it. with open("tests/images/people.jpg", "rb") as f: original_value = f.read() - with open("tests/images/people.jpg", "wb") as f, mock.patch( - "builtins.open", mock.mock_open(read_data=original_value) + with ( + open("tests/images/people.jpg", "wb") as f, + mock.patch("builtins.open", mock.mock_open(read_data=original_value)), ): self.image.optimize(f, "jpeg") mock_process.assert_called_with("tests/images/people.jpg") diff --git a/tests/test_registry.py b/tests/test_registry.py index b8e8bdb..dbc543b 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -227,9 +227,9 @@ def test_get_converter(self): def test_converter(image): pass - self.registry._registered_converters[ - self.TestImage, self.AnotherTestImage - ] = test_converter + self.registry._registered_converters[self.TestImage, self.AnotherTestImage] = ( + test_converter + ) self.assertEqual( test_converter, @@ -252,15 +252,15 @@ def test_converter_2(image): def test_converter_3(image): return image - self.registry._registered_converters[ - self.TestImage, self.AnotherTestImage - ] = test_converter + self.registry._registered_converters[self.TestImage, self.AnotherTestImage] = ( + test_converter + ) self.registry._registered_converters[ self.TestImage, self.UnregisteredTestImage ] = test_converter_2 - self.registry._registered_converters[ - self.AnotherTestImage, self.TestImage - ] = test_converter_3 + self.registry._registered_converters[self.AnotherTestImage, self.TestImage] = ( + test_converter_3 + ) result = list(self.registry.get_converters_from(self.TestImage)) self.assertIn((test_converter, self.AnotherTestImage), result) diff --git a/willow/image.py b/willow/image.py index c3f3f2c..67d42fd 100644 --- a/willow/image.py +++ b/willow/image.py @@ -128,7 +128,7 @@ def save( "avif", "ico", ]: - raise ValueError("Unknown image format: %s" % image_format) + raise ValueError(f"Unknown image format: {image_format}") operation_name = "save_as_" + image_format return getattr(self, operation_name)(output, apply_optimizers=apply_optimizers) diff --git a/willow/optimizers/base.py b/willow/optimizers/base.py index 676292d..e1cae40 100644 --- a/willow/optimizers/base.py +++ b/willow/optimizers/base.py @@ -1,6 +1,6 @@ import logging import subprocess -from typing import ClassVar, List +from typing import ClassVar logger = logging.getLogger("willow") @@ -17,7 +17,7 @@ def applies_to(cls, image_format: str) -> bool: return image_format.lower() == cls.image_format.lower() @classmethod - def get_check_library_arguments(cls) -> List[str]: + def get_check_library_arguments(cls) -> list[str]: """ Return a list of arguments to check if the library exists. @@ -35,7 +35,7 @@ def check_library(cls) -> bool: return False @classmethod - def get_command_arguments(cls, file_path: str) -> List[str]: + def get_command_arguments(cls, file_path: str) -> list[str]: """Return a list of arguments for the given optimizer library.""" return [] diff --git a/willow/optimizers/cwebp.py b/willow/optimizers/cwebp.py index 67079a1..10c5a6c 100644 --- a/willow/optimizers/cwebp.py +++ b/willow/optimizers/cwebp.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from .base import OptimizerBase @@ -12,14 +12,14 @@ class Cwebp(OptimizerBase): image_format: ClassVar[str] = "webp" @classmethod - def get_check_library_arguments(cls) -> List[str]: + def get_check_library_arguments(cls) -> list[str]: # running just cwebp gives basic infor and returns a zero exit code return [] @classmethod def get_command_arguments( cls, file_path: str, progressive: bool = False - ) -> List[str]: + ) -> list[str]: return [ "-m", "6", # inspect all encoding possibilities for best file size diff --git a/willow/optimizers/gifsicle.py b/willow/optimizers/gifsicle.py index f1217ca..c0a6d50 100644 --- a/willow/optimizers/gifsicle.py +++ b/willow/optimizers/gifsicle.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from .base import OptimizerBase @@ -12,7 +12,7 @@ class Gifsicle(OptimizerBase): image_format: ClassVar[str] = "gif" @classmethod - def get_command_arguments(cls, file_path: str) -> List[str]: + def get_command_arguments(cls, file_path: str) -> list[str]: return [ "-b", # required parameter for the package "-O3", # slowest, but produces best results diff --git a/willow/optimizers/jpegoptim.py b/willow/optimizers/jpegoptim.py index 6531a53..f4dfbe1 100644 --- a/willow/optimizers/jpegoptim.py +++ b/willow/optimizers/jpegoptim.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from .base import OptimizerBase @@ -12,7 +12,7 @@ class Jpegoptim(OptimizerBase): image_format: ClassVar[str] = "jpeg" @classmethod - def get_command_arguments(cls, file_path: str) -> List[str]: + def get_command_arguments(cls, file_path: str) -> list[str]: return [ "--strip-all", # strip out all text information like comments and EXIF data "--max=85", # set maximum quality diff --git a/willow/optimizers/optipng.py b/willow/optimizers/optipng.py index c9ee497..26e9735 100644 --- a/willow/optimizers/optipng.py +++ b/willow/optimizers/optipng.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from .base import OptimizerBase @@ -12,7 +12,7 @@ class Optipng(OptimizerBase): image_format: ClassVar[str] = "png" @classmethod - def get_command_arguments(cls, file_path: str) -> List[str]: + def get_command_arguments(cls, file_path: str) -> list[str]: return [ "-quiet", "-o2", # optimization level 2 (out of 7) diff --git a/willow/optimizers/pngquant.py b/willow/optimizers/pngquant.py index e6db64f..64fc450 100644 --- a/willow/optimizers/pngquant.py +++ b/willow/optimizers/pngquant.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from .base import OptimizerBase @@ -14,7 +14,7 @@ class Pngquant(OptimizerBase): @classmethod def get_command_arguments( cls, file_path: str, progressive: bool = False - ) -> List[str]: + ) -> list[str]: return [ "--force", # allow overwriting existing files "--strip", # remove optional metadata diff --git a/willow/registry.py b/willow/registry.py index 24573fb..756846d 100644 --- a/willow/registry.py +++ b/willow/registry.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING if TYPE_CHECKING: from .optimizers import OptimizerBase @@ -38,7 +38,7 @@ def __init__(self): self._registered_operations = defaultdict(dict) self._registered_converters = {} self._registered_converter_costs = {} - self._registered_optimizers: List["OptimizerBase"] = [] + self._registered_optimizers: list[OptimizerBase] = [] def register_operation(self, image_class, operation_name, func): self._registered_operations[image_class][operation_name] = func @@ -171,9 +171,7 @@ def get_image_classes(self, with_operation=None, available=None): raise UnavailableOperationError( "\n".join( [ - "The operation '{}' is available in the following image classes but they all raised errors:".format( - with_operation - ) + f"The operation '{with_operation}' is available in the following image classes but they all raised errors:" ] + [ "{image_class_name}: {error_message}".format( @@ -193,7 +191,7 @@ def get_image_classes(self, with_operation=None, available=None): else: return image_classes - def get_optimizers_for_format(self, image_format: str) -> List["OptimizerBase"]: + def get_optimizers_for_format(self, image_format: str) -> list["OptimizerBase"]: optimizers = [] for optimizer in self._registered_optimizers: if optimizer.applies_to(image_format):