From ebf82e41c91399024c3818242f67675dc4139ac2 Mon Sep 17 00:00:00 2001 From: Alexander Schlarb Date: Mon, 19 Jul 2021 14:25:40 +0200 Subject: [PATCH 1/6] Accept any path-like object on `Image.{open,save}` and `truetype`, not just `pathlib.Path` Also tries to fix some bugs where PIL will crash in case of binary paths, by using `os.fsdecode`. --- Tests/test_image.py | 39 +++++++++++++++++++++++++++++------- Tests/test_util.py | 31 ++++++++++++++++++++++++++++ src/PIL/Image.py | 14 ++++++------- src/PIL/ImageFont.py | 19 +++++++++--------- src/PIL/Jpeg2KImagePlugin.py | 2 +- src/PIL/_util.py | 5 +++-- 6 files changed, 84 insertions(+), 26 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 2d661a903..843921f5f 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1,5 +1,6 @@ import io import os +import pathlib import shutil import sys import tempfile @@ -21,6 +22,22 @@ from .helper import ( ) +class MyStrPathLike: + def __init__(self, path_str): + self.path_str = path_str + + def __fspath__(self): + return self.path_str + + +class MyBytesPathLike: + def __init__(self, path_str): + self.path_bytes = os.fsencode(path_str) + + def __fspath__(self): + return self.path_bytes + + class TestImage: def test_image_modes_success(self): for mode in [ @@ -138,22 +155,30 @@ class TestImage: with Image.open(io.StringIO()): pass - def test_pathlib(self, tmp_path): - from PIL.Image import Path - - with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: + @pytest.mark.parametrize( + "PathCls", + ( + pathlib.Path, + MyStrPathLike, # Returns `str` on `os.fspath` + MyBytesPathLike, # Returns `bytes` on `os.fspath` + str, + os.fsencode, # Converts path to `bytes` + ), + ) + def test_path_like(self, tmp_path, PathCls): + with Image.open(PathCls("Tests/images/multipage-mmap.tiff")) as im: assert im.mode == "P" assert im.size == (10, 10) - with Image.open(Path("Tests/images/hopper.jpg")) as im: + with Image.open(PathCls("Tests/images/hopper.jpg")) as im: assert im.mode == "RGB" assert im.size == (128, 128) for ext in (".jpg", ".jp2"): - temp_file = str(tmp_path / ("temp." + ext)) + temp_file = str((tmp_path / "temp").with_suffix(ext)) if os.path.exists(temp_file): os.remove(temp_file) - im.save(Path(temp_file)) + im.save(PathCls(temp_file)) def test_fp_name(self, tmp_path): temp_file = str(tmp_path / "temp.jpg") diff --git a/Tests/test_util.py b/Tests/test_util.py index b5bfca012..5c153947f 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -27,6 +27,21 @@ def test_path_obj_is_path(): assert it_is +def test_path_like_is_path(): + # Arrange + class PathThingy: + def __fspath__(self): + return "UNIMPORTANT" # pragma: no cover + + test_path = PathThingy() + + # Act + it_is = _util.isPath(test_path) + + # Assert + assert it_is + + def test_is_not_path(tmp_path): # Arrange with (tmp_path / "temp.ext").open("w") as fp: @@ -39,6 +54,22 @@ def test_is_not_path(tmp_path): assert not it_is_not +def test_path_like_file_is_not_path(tmp_path): + # Arrange + import io + + class PathAndFileThingy(io.TextIOWrapper): + def __fspath__(self): + return "UNIMPORTANT" # pragma: no cover + + with PathAndFileThingy(open(tmp_path / "temp.ext", "wb")) as test_obj: + # Act + it_is_not = _util.isPath(test_obj) + + # Assert + assert not it_is_not + + def test_is_directory(): # Arrange directory = "Tests" diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f53dbe016..158fc8e51 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2155,9 +2155,9 @@ class Image: def save(self, fp, format=None, **params): """ - Saves this image under the given filename. If no format is - specified, the format to use is determined from the filename - extension, if possible. + Saves this image under the given filepath. If no format is + specified, the format to use is determined from the filepath's + file extension, if possible. Keyword options can be used to provide additional instructions to the writer. If a writer doesn't recognise an option, it is @@ -2165,12 +2165,12 @@ class Image: :doc:`image format documentation <../handbook/image-file-formats>` for each writer. - You can use a file object instead of a filename. In this case, + You can use a file object instead of a filepath. In this case, you must always specify the format. The file object must implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filename (string), pathlib.Path object or file object. + :param fp: A filepath (str or bytes), path-like object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2209,7 +2209,7 @@ class Image: preinit() - ext = os.path.splitext(filename)[1].lower() + ext = os.fsdecode(os.path.splitext(filename)[1].lower()) if not format: if ext not in EXTENSION: @@ -2930,7 +2930,7 @@ def open(fp, mode="r", formats=None): :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filename (string), pathlib.Path object or a file object. + :param fp: A filepath (str or bytes), path-like object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, and be opened in binary mode. diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index e99ca21b2..f4f47ff11 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -199,6 +199,7 @@ class FreeTypeFont: if isPath(font): if sys.platform == "win32": + font = os.fspath(font) font_bytes_path = font if isinstance(font, bytes) else font.encode() try: font_bytes_path.decode("ascii") @@ -796,10 +797,10 @@ def load(filename): def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): - """ - Load a TrueType or OpenType font from a file or file-like object, + r""" + Load a TrueType or OpenType font from a filepath or file-like object, and create a font object. - This function loads a font object from the given file or file-like + This function loads a font object from the given filepath or file-like object, and creates a font object for a font of the given size. Pillow uses FreeType to open font files. If you are opening many fonts @@ -809,12 +810,12 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): This function requires the _imagingft service. - :param font: A filename or file-like object containing a TrueType font. - If the file is not found in this filename, the loader may also - search in other directories, such as the :file:`fonts/` + :param font: A filepath or file-like object containing a TrueType font. + If the file is not found at this filepath, the loader may also + search in other directories, such as the :file:`%WINDIR%\fonts\` directory on Windows or :file:`/Library/Fonts/`, - :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on - macOS. + :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` + on macOS. :param size: The requested size, in points. :param index: Which font face to load (default is first available face). @@ -856,7 +857,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): except OSError: if not isPath(font): raise - ttf_filename = os.path.basename(font) + ttf_filename = os.fsdecode(os.path.basename(font)) dirs = [] if sys.platform == "win32": diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 0904b241b..8e0ff0735 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -317,7 +317,7 @@ def _accept(prefix): def _save(im, fp, filename): - if filename.endswith(".j2k"): + if os.fsdecode(os.path.splitext(filename)[1]) == ".j2k": kind = "j2k" else: kind = "jp2" diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 0c5d3892e..7e9fa17fc 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,9 +1,10 @@ import os -from pathlib import Path +# Checks whether the given object is a string or path-like object that +# isn't also a file-like object def isPath(f): - return isinstance(f, (bytes, str, Path)) + return isinstance(f, (bytes, str, os.PathLike)) and not hasattr(f, "read") # Checks if an object is a string, and that it points to a directory. From 44c7fc9a232629bc8420b68256ea58922ea02e7e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 1 Aug 2021 14:22:00 +1000 Subject: [PATCH 2/6] Test passing Path to truetype --- Tests/test_imagefont.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 002641faa..0e1554463 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1,4 +1,5 @@ import copy +from pathlib import Path import os import re import shutil @@ -87,6 +88,7 @@ class TestImageFont: pytest.skip("Non-ASCII path could not be created") ImageFont.truetype(tempfile, FONT_SIZE) + ImageFont.truetype(Path(tempfile), FONT_SIZE) def test_unavailable_layout_engine(self): have_raqm = ImageFont.core.HAVE_RAQM From 39f3913b9d852662c38321366f383d2bdb5b2cea Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 1 Aug 2021 14:58:03 +1000 Subject: [PATCH 3/6] Convert font path on all platforms --- Tests/test_imagefont.py | 2 +- src/PIL/ImageFont.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 0e1554463..25966801f 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1,10 +1,10 @@ import copy -from pathlib import Path import os import re import shutil import sys from io import BytesIO +from pathlib import Path import pytest from packaging.version import parse as parse_version diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index f4f47ff11..dcc8d9b8e 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -198,8 +198,8 @@ class FreeTypeFont: ) if isPath(font): + font = os.fspath(font) if sys.platform == "win32": - font = os.fspath(font) font_bytes_path = font if isinstance(font, bytes) else font.encode() try: font_bytes_path.decode("ascii") From d788682afe41fd788b317abfb850ae2623d65700 Mon Sep 17 00:00:00 2001 From: Alexander Schlarb <246386+ntninja@users.noreply.github.com> Date: Wed, 18 Aug 2021 15:28:18 +0000 Subject: [PATCH 4/6] Use "file path" or "path" instead of "filepath" which isn't used much By @hugovk. Co-authored-by: Hugo van Kemenade --- src/PIL/Image.py | 10 +++++----- src/PIL/ImageFont.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 158fc8e51..a7742e7af 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2155,8 +2155,8 @@ class Image: def save(self, fp, format=None, **params): """ - Saves this image under the given filepath. If no format is - specified, the format to use is determined from the filepath's + Saves this image under the given file path. If no format is + specified, the format to use is determined from the path's file extension, if possible. Keyword options can be used to provide additional instructions @@ -2165,12 +2165,12 @@ class Image: :doc:`image format documentation <../handbook/image-file-formats>` for each writer. - You can use a file object instead of a filepath. In this case, + You can use a file object instead of a file path. In this case, you must always specify the format. The file object must implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filepath (str or bytes), path-like object or file object. + :param fp: A file path (str or bytes), path-like object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2930,7 +2930,7 @@ def open(fp, mode="r", formats=None): :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filepath (str or bytes), path-like object or a file object. + :param fp: A file path (str or bytes), path-like object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, and be opened in binary mode. diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index dcc8d9b8e..b6f4deaa3 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -798,9 +798,9 @@ def load(filename): def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): r""" - Load a TrueType or OpenType font from a filepath or file-like object, + Load a TrueType or OpenType font from a file path or file-like object, and create a font object. - This function loads a font object from the given filepath or file-like + This function loads a font object from the given file path or file-like object, and creates a font object for a font of the given size. Pillow uses FreeType to open font files. If you are opening many fonts @@ -810,8 +810,8 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): This function requires the _imagingft service. - :param font: A filepath or file-like object containing a TrueType font. - If the file is not found at this filepath, the loader may also + :param font: A file path or file-like object containing a TrueType font. + If the file is not found at this path, the loader may also search in other directories, such as the :file:`%WINDIR%\fonts\` directory on Windows or :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` From 9c5bad6f0ae4b466a6b162231bc9f50afc35dd5a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Sep 2021 10:15:57 +1000 Subject: [PATCH 5/6] Ensure filename is a string --- src/PIL/Image.py | 4 ++-- src/PIL/Jpeg2KImagePlugin.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a7742e7af..2c742a6b1 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2189,7 +2189,7 @@ class Image: filename = str(fp) open_fp = True elif isPath(fp): - filename = fp + filename = os.fsdecode(fp) open_fp = True elif fp == sys.stdout: try: @@ -2209,7 +2209,7 @@ class Image: preinit() - ext = os.fsdecode(os.path.splitext(filename)[1].lower()) + ext = os.path.splitext(filename)[1].lower() if not format: if ext not in EXTENSION: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 8e0ff0735..0904b241b 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -317,7 +317,7 @@ def _accept(prefix): def _save(im, fp, filename): - if os.fsdecode(os.path.splitext(filename)[1]) == ".j2k": + if filename.endswith(".j2k"): kind = "j2k" else: kind = "jp2" From 5bd776a62129e4f9ecba74651db7a436f92e170f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 17 Apr 2022 13:06:03 +1000 Subject: [PATCH 6/6] Updated function name --- Tests/test_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_util.py b/Tests/test_util.py index ab2a4d0c4..9582c560d 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -36,7 +36,7 @@ def test_path_like_is_path(): test_path = PathThingy() # Act - it_is = _util.isPath(test_path) + it_is = _util.is_path(test_path) # Assert assert it_is @@ -64,7 +64,7 @@ def test_path_like_file_is_not_path(tmp_path): with PathAndFileThingy(open(tmp_path / "temp.ext", "wb")) as test_obj: # Act - it_is_not = _util.isPath(test_obj) + it_is_not = _util.is_path(test_obj) # Assert assert not it_is_not