diff --git a/Tests/test_image.py b/Tests/test_image.py index d42fb9f1d..2827b445e 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 [ @@ -149,22 +166,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_imagefont.py b/Tests/test_imagefont.py index 0e1d1e637..958289499 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -4,6 +4,7 @@ import re import shutil import sys from io import BytesIO +from pathlib import Path import pytest from packaging.version import parse as parse_version @@ -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 _render(self, font): txt = "Hello World!" diff --git a/Tests/test_util.py b/Tests/test_util.py index 9efbdd1f3..9582c560d 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.is_path(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.is_path(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 99c7ba0d1..7455d7194 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2182,9 +2182,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 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 to the writer. If a writer doesn't recognise an option, it is @@ -2192,12 +2192,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 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 filename (string), pathlib.Path 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 @@ -2216,7 +2216,7 @@ class Image: filename = str(fp) open_fp = True elif is_path(fp): - filename = fp + filename = os.fsdecode(fp) open_fp = True elif fp == sys.stdout: try: @@ -2992,7 +2992,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 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 86a8ad5af..e915325e0 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -202,6 +202,7 @@ class FreeTypeFont: ) if is_path(font): + font = os.fspath(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -818,10 +819,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 file path 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 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 @@ -831,12 +832,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 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/` on - macOS. + :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` + on macOS. :param size: The requested size, in pixels. :param index: Which font face to load (default is first available face). @@ -878,7 +879,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): except OSError: if not is_path(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/_util.py b/src/PIL/_util.py index ba27b7e49..8eaaf9ae0 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,9 +1,12 @@ import os -from pathlib import Path def is_path(f): - return isinstance(f, (bytes, str, Path)) + """ + Checks whether the given object is a string or path-like object that + isn't also a file-like object + """ + return isinstance(f, (bytes, str, os.PathLike)) and not hasattr(f, "read") def is_directory(f):