From 18ffa029b95d747ece48ec0a53c1340f84858b74 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 25 Jul 2025 01:20:05 -0400 Subject: [PATCH] Add os.PathLike support to FT2Font constructor, and FontManager Since we pass the filename to `io.open`, we can accept everything it can. Also, fix the return value of `FT2Font.fname`, which could be `bytes` if that was initially provided. --- lib/matplotlib/font_manager.py | 6 ++--- lib/matplotlib/font_manager.pyi | 14 +++++----- lib/matplotlib/ft2font.pyi | 5 ++-- lib/matplotlib/tests/test_font_manager.py | 33 ++++++++++++++++++----- lib/matplotlib/tests/test_ft2font.py | 22 +++++++++++++++ src/ft2font_wrapper.cpp | 13 +++++---- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 79e088b85998..47339d4491dd 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1611,10 +1611,10 @@ def get_font(font_filepaths, hinting_factor=None): Parameters ---------- - font_filepaths : Iterable[str, Path, bytes], str, Path, bytes + font_filepaths : Iterable[str, bytes, os.PathLike], str, bytes, os.PathLike Relative or absolute paths to the font files to be used. - If a single string, bytes, or `pathlib.Path`, then it will be treated + If a single string, bytes, or `os.PathLike`, then it will be treated as a list with that entry only. If more than one filepath is passed, then the returned FT2Font object @@ -1626,7 +1626,7 @@ def get_font(font_filepaths, hinting_factor=None): `.ft2font.FT2Font` """ - if isinstance(font_filepaths, (str, Path, bytes)): + if isinstance(font_filepaths, (str, bytes, os.PathLike)): paths = (_cached_realpath(font_filepaths),) else: paths = tuple(_cached_realpath(fname) for fname in font_filepaths) diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index e865f67384cd..f5e3910e5f63 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -24,7 +24,7 @@ def list_fonts(directory: str, extensions: Iterable[str]) -> list[str]: ... def win32FontDirectory() -> str: ... def _get_fontconfig_fonts() -> list[Path]: ... def findSystemFonts( - fontpaths: Iterable[str | os.PathLike | Path] | None = ..., fontext: str = ... + fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ... ) -> list[str]: ... @dataclass class FontEntry: @@ -50,7 +50,7 @@ class FontProperties: weight: int | str | None = ..., stretch: int | str | None = ..., size: float | str | None = ..., - fname: str | os.PathLike | Path | None = ..., + fname: str | os.PathLike | None = ..., math_fontfamily: str | None = ..., ) -> None: ... def __hash__(self) -> int: ... @@ -72,7 +72,7 @@ class FontProperties: def set_weight(self, weight: int | str | None) -> None: ... def set_stretch(self, stretch: int | str | None) -> None: ... def set_size(self, size: float | str | None) -> None: ... - def set_file(self, file: str | os.PathLike | Path | None) -> None: ... + def set_file(self, file: str | os.PathLike | None) -> None: ... def set_fontconfig_pattern(self, pattern: str) -> None: ... def get_math_fontfamily(self) -> str: ... def set_math_fontfamily(self, fontfamily: str | None) -> None: ... @@ -83,8 +83,8 @@ class FontProperties: set_slant = set_style get_size_in_points = get_size -def json_dump(data: FontManager, filename: str | Path | os.PathLike) -> None: ... -def json_load(filename: str | Path | os.PathLike) -> FontManager: ... +def json_dump(data: FontManager, filename: str | os.PathLike) -> None: ... +def json_load(filename: str | os.PathLike) -> FontManager: ... class FontManager: __version__: str @@ -93,7 +93,7 @@ class FontManager: afmlist: list[FontEntry] ttflist: list[FontEntry] def __init__(self, size: float | None = ..., weight: str = ...) -> None: ... - def addfont(self, path: str | Path | os.PathLike) -> None: ... + def addfont(self, path: str | os.PathLike) -> None: ... @property def defaultFont(self) -> dict[str, str]: ... def get_default_weight(self) -> str: ... @@ -120,7 +120,7 @@ class FontManager: def is_opentype_cff_font(filename: str) -> bool: ... def get_font( - font_filepaths: Iterable[str | Path | bytes] | str | Path | bytes, + font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike, hinting_factor: int | None = ..., ) -> ft2font.FT2Font: ... diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 55c076bb68b6..98b4b1f7cc4d 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -1,4 +1,5 @@ from enum import Enum, Flag +from os import PathLike import sys from typing import BinaryIO, Literal, NewType, TypeAlias, TypedDict, cast, final, overload from typing_extensions import Buffer # < Py 3.12 @@ -194,7 +195,7 @@ class _SfntPcltDict(TypedDict): class FT2Font(Buffer): def __init__( self, - filename: str | BinaryIO, + filename: str | bytes | PathLike | BinaryIO, hinting_factor: int = ..., *, _fallback_list: list[FT2Font] | None = ..., @@ -256,7 +257,7 @@ class FT2Font(Buffer): @property def family_name(self) -> str: ... @property - def fname(self) -> str: ... + def fname(self) -> str | bytes: ... @property def height(self) -> int: ... @property diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index b15647644e04..d51eb8d9837f 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -1,4 +1,4 @@ -from io import BytesIO, StringIO +from io import BytesIO import gc import multiprocessing import os @@ -137,6 +137,32 @@ def test_find_noto(): fig.savefig(BytesIO(), format=fmt) +def test_find_valid(): + class PathLikeClass: + def __init__(self, filename): + self.filename = filename + + def __fspath__(self): + return self.filename + + file_str = findfont('DejaVu Sans') + file_bytes = os.fsencode(file_str) + + font = get_font(file_str) + assert font.fname == file_str + font = get_font(file_bytes) + assert font.fname == file_bytes + font = get_font(PathLikeClass(file_str)) + assert font.fname == file_str + font = get_font(PathLikeClass(file_bytes)) + assert font.fname == file_bytes + + # Note, fallbacks are not currently accessible. + font = get_font([file_str, file_bytes, + PathLikeClass(file_str), PathLikeClass(file_bytes)]) + assert font.fname == file_str + + def test_find_invalid(tmp_path): with pytest.raises(FileNotFoundError): @@ -148,11 +174,6 @@ def test_find_invalid(tmp_path): with pytest.raises(FileNotFoundError): get_font(bytes(tmp_path / 'non-existent-font-name.ttf')) - # Not really public, but get_font doesn't expose non-filename constructor. - from matplotlib.ft2font import FT2Font - with pytest.raises(TypeError, match='font file or a binary-mode file'): - FT2Font(StringIO()) # type: ignore[arg-type] - @pytest.mark.skipif(sys.platform != 'linux' or not has_fclist, reason='only Linux with fontconfig installed') diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 70e611e17bcc..e78a3894076a 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -1,5 +1,6 @@ import itertools import io +import os from pathlib import Path from typing import cast @@ -134,6 +135,27 @@ def test_ft2font_stix_bold_attrs(): assert font.bbox == (4, -355, 1185, 2095) +def test_ft2font_valid_args(): + class PathLikeClass: + def __init__(self, filename): + self.filename = filename + + def __fspath__(self): + return self.filename + + file_str = fm.findfont('DejaVu Sans') + file_bytes = os.fsencode(file_str) + + font = ft2font.FT2Font(file_str) + assert font.fname == file_str + font = ft2font.FT2Font(file_bytes) + assert font.fname == file_bytes + font = ft2font.FT2Font(PathLikeClass(file_str)) + assert font.fname == file_str + font = ft2font.FT2Font(PathLikeClass(file_bytes)) + assert font.fname == file_bytes + + def test_ft2font_invalid_args(tmp_path): # filename argument. with pytest.raises(TypeError, match='to a font file or a binary-mode file object'): diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 65fcb4b7e013..3471203311b3 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -424,7 +424,7 @@ close_file_callback(FT_Stream stream) const char *PyFT2Font_init__doc__ = R"""( Parameters ---------- - filename : str or file-like + filename : str, bytes, os.PathLike, or io.BinaryIO The source of the font data in a format (ttf or ttc) that FreeType can read. hinting_factor : int, optional @@ -488,7 +488,10 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, open_args.flags = FT_OPEN_STREAM; open_args.stream = &self->stream; - if (py::isinstance(filename) || py::isinstance(filename)) { + auto PathLike = py::module_::import("os").attr("PathLike"); + if (py::isinstance(filename) || py::isinstance(filename) || + py::isinstance(filename, PathLike)) + { self->py_file = py::module_::import("io").attr("open")(filename, "rb"); self->stream.close = &close_file_callback; } else { @@ -511,13 +514,13 @@ PyFT2Font_init(py::object filename, long hinting_factor = 8, return self; } -static py::str +static py::object PyFT2Font_fname(PyFT2Font *self) { - if (self->stream.close) { // Called passed a filename to the constructor. + if (self->stream.close) { // User passed a filename to the constructor. return self->py_file.attr("name"); } else { - return py::cast(self->py_file); + return self->py_file; } }