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 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")

View File

@ -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"

View File

@ -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.

View File

@ -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":

View File

@ -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"

View File

@ -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.