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`.
This commit is contained in:
Alexander Schlarb 2021-07-19 14:25:40 +02:00
parent f518149a1b
commit ebf82e41c9
6 changed files with 84 additions and 26 deletions

View File

@ -1,5 +1,6 @@
import io import io
import os import os
import pathlib
import shutil import shutil
import sys import sys
import tempfile 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: class TestImage:
def test_image_modes_success(self): def test_image_modes_success(self):
for mode in [ for mode in [
@ -138,22 +155,30 @@ class TestImage:
with Image.open(io.StringIO()): with Image.open(io.StringIO()):
pass pass
def test_pathlib(self, tmp_path): @pytest.mark.parametrize(
from PIL.Image import Path "PathCls",
(
with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: 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.mode == "P"
assert im.size == (10, 10) 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.mode == "RGB"
assert im.size == (128, 128) assert im.size == (128, 128)
for ext in (".jpg", ".jp2"): 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): if os.path.exists(temp_file):
os.remove(temp_file) os.remove(temp_file)
im.save(Path(temp_file)) im.save(PathCls(temp_file))
def test_fp_name(self, tmp_path): def test_fp_name(self, tmp_path):
temp_file = str(tmp_path / "temp.jpg") temp_file = str(tmp_path / "temp.jpg")

View File

@ -27,6 +27,21 @@ def test_path_obj_is_path():
assert it_is 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): def test_is_not_path(tmp_path):
# Arrange # Arrange
with (tmp_path / "temp.ext").open("w") as fp: 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 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(): def test_is_directory():
# Arrange # Arrange
directory = "Tests" directory = "Tests"

View File

@ -2155,9 +2155,9 @@ class Image:
def save(self, fp, format=None, **params): def save(self, fp, format=None, **params):
""" """
Saves this image under the given filename. If no format is Saves this image under the given filepath. If no format is
specified, the format to use is determined from the filename specified, the format to use is determined from the filepath's
extension, if possible. file extension, if possible.
Keyword options can be used to provide additional instructions Keyword options can be used to provide additional instructions
to the writer. If a writer doesn't recognise an option, it is to the writer. If a writer doesn't recognise an option, it is
@ -2165,12 +2165,12 @@ class Image:
:doc:`image format documentation :doc:`image format documentation
<../handbook/image-file-formats>` for each writer. <../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 you must always specify the format. The file object must
implement the ``seek``, ``tell``, and ``write`` implement the ``seek``, ``tell``, and ``write``
methods, and be opened in binary mode. 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 :param format: Optional format override. If omitted, the
format to use is determined from the filename extension. format to use is determined from the filename extension.
If a file object was used instead of a filename, this If a file object was used instead of a filename, this
@ -2209,7 +2209,7 @@ class Image:
preinit() preinit()
ext = os.path.splitext(filename)[1].lower() ext = os.fsdecode(os.path.splitext(filename)[1].lower())
if not format: if not format:
if ext not in EXTENSION: 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:meth:`~PIL.Image.Image.load` method). See
:py:func:`~PIL.Image.new`. See :ref:`file-handling`. :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``, The file object must implement ``file.read``,
``file.seek``, and ``file.tell`` methods, ``file.seek``, and ``file.tell`` methods,
and be opened in binary mode. and be opened in binary mode.

View File

@ -199,6 +199,7 @@ class FreeTypeFont:
if isPath(font): if isPath(font):
if sys.platform == "win32": if sys.platform == "win32":
font = os.fspath(font)
font_bytes_path = font if isinstance(font, bytes) else font.encode() font_bytes_path = font if isinstance(font, bytes) else font.encode()
try: try:
font_bytes_path.decode("ascii") font_bytes_path.decode("ascii")
@ -796,10 +797,10 @@ def load(filename):
def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): def truetype(font=None, size=10, index=0, encoding="", layout_engine=None):
""" r"""
Load a TrueType or OpenType font from a file or file-like object, Load a TrueType or OpenType font from a filepath or file-like object,
and create a font 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. 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 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. This function requires the _imagingft service.
:param font: A filename or file-like object containing a TrueType font. :param font: A filepath or file-like object containing a TrueType font.
If the file is not found in this filename, the loader may also If the file is not found at this filepath, the loader may also
search in other directories, such as the :file:`fonts/` search in other directories, such as the :file:`%WINDIR%\fonts\`
directory on Windows or :file:`/Library/Fonts/`, directory on Windows or :file:`/Library/Fonts/`,
:file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/`
macOS. on macOS.
:param size: The requested size, in points. :param size: The requested size, in points.
:param index: Which font face to load (default is first available face). :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: except OSError:
if not isPath(font): if not isPath(font):
raise raise
ttf_filename = os.path.basename(font) ttf_filename = os.fsdecode(os.path.basename(font))
dirs = [] dirs = []
if sys.platform == "win32": if sys.platform == "win32":

View File

@ -317,7 +317,7 @@ def _accept(prefix):
def _save(im, fp, filename): def _save(im, fp, filename):
if filename.endswith(".j2k"): if os.fsdecode(os.path.splitext(filename)[1]) == ".j2k":
kind = "j2k" kind = "j2k"
else: else:
kind = "jp2" kind = "jp2"

View File

@ -1,9 +1,10 @@
import os 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): 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. # Checks if an object is a string, and that it points to a directory.