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.