From 431e195a90ea46d9b70d5a70813ad05db76b4dbe Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 14:57:04 -0400 Subject: [PATCH 1/7] Make Font Awesome an optional dependency pip install pywaffle no longer pulls in fontawesomefree. Icons come from pywaffle[icons]; everything else, including the characters parameter, works without it. This takes a font package out of the dependency graph of every project that uses PyWaffle without icons, which is most of them -- the majority of waffle charts are plain rectangles. It is also the prerequisite for letting a distribution use a system Font Awesome instead of a vendored copy, which is what #25 asks for and which stays open. Asking for icons without the extra used to surface as ModuleNotFoundError: No module named 'fontawesomefree' from several frames down. It now raises ImportError naming the command to run. The message lives in one constant so the instructions cannot drift between call sites, and a test asserts the raised message is that constant rather than merely similar to it. The handler module no longer resolves fonts at import. _parameter_validation imports it just to read FA_STYLES, long before any font is needed, so the font files, the icon mapping and the legend handlers are resolved on first access through a PEP 562 module __getattr__, each cached. Importing the module without the font package installed now works; only reaching for a font fails. The build job in CI proves both installation modes for both artifacts: a plain install must draw blocks and must refuse icons with a message mentioning pywaffle[icons], and an [icons] install must draw icons. That is a better check than the previous one, which only drew an icon chart. The test suite exercises icons across six files, so development needs the extra: requirements_dev.txt installs -e .[icons], and every CI job that runs the suite does the same. requirements.txt now lists only matplotlib, which is what a runtime install actually needs. Absence is covered by tests/test_optional_fontawesome.py, which simulates it by blocking the import, and I checked that simulation against a real environment without the package rather than trusting the mock -- that is how I found the icon tests needed to skip rather than fail there. --- .github/workflows/test.yml | 62 ++++++--- CHANGELOG.md | 4 + README.md | 8 ++ binder/requirements.txt | 3 +- .../examples/plot_with_characters_or_icons.md | 3 +- docs/font_awesome_integration.rst | 13 +- docs/installation.rst | 11 ++ pyproject.toml | 7 +- pywaffle/fontawesome_handler.py | 50 ++++++- requirements.txt | 3 +- requirements_dev.txt | 3 +- tests/test_optional_fontawesome.py | 128 ++++++++++++++++++ 12 files changed, 260 insertions(+), 35 deletions(-) create mode 100644 tests/test_optional_fontawesome.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4dbc696..41a933e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e . + pip install -e ".[icons]" # pandas is not a dependency, but the Series-input tests need it present to run pip install pytest pytest-cov pandas @@ -58,7 +58,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e . pytest pandas + - run: pip install -e ".[icons]" pytest pandas - name: The suite must pass normally env: @@ -91,7 +91,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e . pytest pandas + - run: pip install -e ".[icons]" pytest pandas - name: The suite must pass on the released matplotlib env: @@ -137,7 +137,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e . pytest pytest-mpl + - run: pip install -e ".[icons]" pytest pytest-mpl - name: Compare against baselines env: MPLBACKEND: Agg @@ -162,7 +162,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e . pandas nbconvert nbformat ipykernel + - run: pip install -e ".[icons]" pandas nbconvert nbformat ipykernel - name: Regenerate the documented examples env: @@ -217,27 +217,51 @@ jobs: - run: python -m build - run: twine check dist/* - - name: Install the sdist into a clean environment + # Font Awesome is an extra, so both installation modes are worth proving: the plain install + # must draw blocks and must refuse icons with a useful message, and the [icons] install must + # draw icons. + - name: Install each artifact plain, and check icons are refused helpfully run: | - python -m venv /tmp/sdist-env - /tmp/sdist-env/bin/pip install dist/*.tar.gz - MPLBACKEND=Agg /tmp/sdist-env/bin/python -c " + for artifact in dist/*.tar.gz dist/*.whl; do + rm -rf /tmp/plain-env + python -m venv /tmp/plain-env + /tmp/plain-env/bin/pip install --quiet "$artifact" + MPLBACKEND=Agg /tmp/plain-env/bin/python - "$artifact" <<'PY' + import sys + import matplotlib; matplotlib.use("Agg") import matplotlib.pyplot as plt from pywaffle import Waffle - plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons='star') - print('sdist OK') - " - - name: Install the wheel into a clean environment + figure = plt.figure(FigureClass=Waffle, rows=5, values=[10, 20]) + assert len(figure.axes[0].patches) == 30, "plain install cannot draw blocks" + + try: + plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + except ImportError as exc: + assert "pywaffle[icons]" in str(exc), f"unhelpful message: {exc}" + else: + raise AssertionError("icons should not work without the extra") + print(f"{sys.argv[1]}: blocks OK, icons refused with instructions") + PY + done + + - name: Install each artifact with [icons], and check icons draw run: | - python -m venv /tmp/wheel-env - /tmp/wheel-env/bin/pip install dist/*.whl - MPLBACKEND=Agg /tmp/wheel-env/bin/python -c " + for artifact in dist/*.tar.gz dist/*.whl; do + rm -rf /tmp/icons-env + python -m venv /tmp/icons-env + /tmp/icons-env/bin/pip install --quiet "$artifact[icons]" + MPLBACKEND=Agg /tmp/icons-env/bin/python - "$artifact" <<'PY' + import sys + import matplotlib; matplotlib.use("Agg") import matplotlib.pyplot as plt from pywaffle import Waffle - plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons='star') - print('wheel OK') - " + + figure = plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + assert len(figure.axes[0].texts) == 30, "icons did not draw" + print(f"{sys.argv[1]}[icons]: icons OK") + PY + done - uses: actions/upload-artifact@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4a6fc..b4f8ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ Fixes * Reject unknown `block_arranging_style`, which was previously accepted and silently drawn as `normal` * Raise `ValueError` rather than `KeyError` or `AttributeError` for invalid `starting_location`, `rounding_rule` and `icon_style`, and accept `icon_style` lists in any case +Breaking + +* **Font Awesome is now an optional dependency.** `pip install pywaffle` no longer pulls in `fontawesomefree`; install `pywaffle[icons]` to draw with `icons`. Everything else, including `characters`, works without it. Asking for `icons` without the extra raises `ImportError` naming the command to run, rather than a bare `ModuleNotFoundError`. This removes a font package from the dependency graph of every project that uses PyWaffle without icons, and is a step towards letting distributions use a system Font Awesome ([#25](https://github.com/gyli/PyWaffle/issues/25)) + New * Add `rounding_rule="float"`, which draws partial blocks instead of rounding values ([#26](https://github.com/gyli/PyWaffle/issues/26)). A category that ends part way through a block fills only that fraction of it, and a block containing a boundary between two categories is split between their colors. The block count then depends only on the total of the values, so two datasets with the same total produce charts of the same size - which rounding did not guarantee diff --git a/README.md b/README.md index 21fbe1e..5065a18 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,18 @@ Documentation: [http://pywaffle.readthedocs.io/](http://pywaffle.readthedocs.io/ pip install pywaffle ``` +To draw [pictogram charts](https://pywaffle.readthedocs.io/en/latest/examples/plot_with_characters_or_icons.html) +with Font Awesome icons, install the optional extra: + +```shell +pip install "pywaffle[icons]" +``` + ## Requirements * Python 3.9+ * Matplotlib +* Font Awesome, optional, for `icons` only — `pip install "pywaffle[icons]"` ## Quickstart diff --git a/binder/requirements.txt b/binder/requirements.txt index 80a19b4..a7b0f55 100644 --- a/binder/requirements.txt +++ b/binder/requirements.txt @@ -1,4 +1,5 @@ # Binder environment for demo.ipynb. # Installing the repository itself means the online demo runs THIS code, not the last PyPI release. --e . +# The demo draws a pictogram chart, so it needs the icons extra. +-e .[icons] pandas diff --git a/docs/examples/plot_with_characters_or_icons.md b/docs/examples/plot_with_characters_or_icons.md index 9c34225..e42a769 100644 --- a/docs/examples/plot_with_characters_or_icons.md +++ b/docs/examples/plot_with_characters_or_icons.md @@ -39,7 +39,8 @@ See [issue #17](https://github.com/gyli/PyWaffle/issues/17) for the original rep Waffle Chart with icons is also known as Pictogram Chart. -PyWaffle supports plotting with icons through [Font Awesome](https://fontawesome.com/). See page [Font Awesome Integration](font_awesome_integration.html) for how Font Awesome is integrated into PyWaffle. +PyWaffle supports plotting with icons through [Font Awesome](https://fontawesome.com/), which is an +optional dependency — install it with `pip install "pywaffle[icons]"`. See page [Font Awesome Integration](font_awesome_integration.html) for how Font Awesome is integrated into PyWaffle. For searching available icon name in Font Awesome, please visit [https://fontawesome.com/search](https://fontawesome.com/search). diff --git a/docs/font_awesome_integration.rst b/docs/font_awesome_integration.rst index e2b7f8c..647ddce 100644 --- a/docs/font_awesome_integration.rst +++ b/docs/font_awesome_integration.rst @@ -1,11 +1,18 @@ Font Awesome Integration ======================== -PyWaffle installs `Font Awesome -`_ free version automatically as a dependent package. -The package it is trying to install is the latest version of `fontawesomefree +Icons come from the free version of `Font Awesome +`_, packaged for Python as `fontawesomefree `_. +It is an **optional** dependency, so install it alongside PyWaffle when you want icons:: + + $ pip install "pywaffle[icons]" + +Nothing else needs it. Rectangle blocks, and the ``characters`` parameter, work without it, and +asking for ``icons`` when it is absent raises ``ImportError`` naming the command to run rather than +a bare ``ModuleNotFoundError``. + Upgrading or downgrading Font Awesome ------------------------------------- diff --git a/docs/installation.rst b/docs/installation.rst index bcf02f5..e7cf132 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -10,3 +10,14 @@ The last stable release is available on PyPI and can be installed with ``pip``:: * Python 3.9+ * Matplotlib + +.. rubric:: Drawing with icons + +Icons come from `Font Awesome `_, which is an **optional** dependency. +Install it alongside PyWaffle if you want pictogram charts:: + + $ pip install "pywaffle[icons]" + +Everything except the ``icons`` parameter works without it, including ``characters``, which uses +an ordinary font. Passing ``icons`` without the extra raises ``ImportError`` with the command to +run. diff --git a/pyproject.toml b/pyproject.toml index e859757..862e86c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,10 @@ keywords = [ "pie plot", "data visualization", ] -# fontawesomefree stays a hard dependency for now; making it an extra is a breaking -# change and is tracked separately. The "icons" extra is an additive alias. -dependencies = ["matplotlib", "fontawesomefree"] +# Font Awesome is only needed to draw with icons, so it is an extra rather than a hard +# dependency: pip install "pywaffle[icons]". Requesting icons without it raises ImportError +# with installation instructions. +dependencies = ["matplotlib"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index bf595f8..e740bba 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -4,6 +4,7 @@ import inspect import json import pathlib +from functools import lru_cache from collections import defaultdict from typing import Dict @@ -18,14 +19,31 @@ } +MISSING_FONT_AWESOME = ( + "Drawing with icons requires Font Awesome, which is an optional dependency of PyWaffle.\n" + "Install it with:\n" + " pip install 'pywaffle[icons]'\n" + "or, if you manage the font package yourself:\n" + " pip install fontawesomefree" +) + + def fontawesome_package_path() -> pathlib.Path: - """Path to the static asset directory of the installed fontawesomefree package.""" - import fontawesomefree + """Path to the static asset directory of the installed fontawesomefree package. + + Raises ImportError with installation instructions when the optional font package is absent, + rather than letting a bare ModuleNotFoundError surface from several frames down. + """ + try: + import fontawesomefree + except ImportError as exc: + raise ImportError(MISSING_FONT_AWESOME) from exc package_path = pathlib.Path(inspect.getsourcefile(fontawesomefree)) return package_path.parent / "static/fontawesomefree" +@lru_cache(maxsize=None) def font_file_finder() -> Dict[str, pathlib.Path]: """Map each Font Awesome style to the .otf file that provides it.""" font_otf_path = (fontawesome_package_path() / "otfs").glob("*.otf") @@ -37,6 +55,7 @@ def font_file_finder() -> Dict[str, pathlib.Path]: } +@lru_cache(maxsize=None) def icon_mapping_builder() -> Dict[str, Dict[str, str]]: """ Build the icon name to Unicode character mapping from the metadata shipped with the installed @@ -113,8 +132,27 @@ def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, return [annotation] -fontawesome_files = font_file_finder() -icons = icon_mapping_builder() -legend_handler_style_mapping = { - v: TextLegendHandler(font_file=fontawesome_files[k]) for k, v in legend_style_class_mapping.items() +@lru_cache(maxsize=None) +def _legend_handlers() -> Dict: + """Map each legend handle class to a handler that draws it in the right font.""" + files = font_file_finder() + return {v: TextLegendHandler(font_file=files[k]) for k, v in legend_style_class_mapping.items()} + + +#: Resolved on first use rather than at import, so that importing this module -- which +#: _parameter_validation does simply to read FA_STYLES -- does not require the optional font +#: package. Anything that actually needs a font raises ImportError with install instructions. +_LAZY = { + "fontawesome_files": font_file_finder, + "icons": icon_mapping_builder, + "legend_handler_style_mapping": _legend_handlers, } + + +def __getattr__(name: str): + """Resolve the font-backed module attributes on first access (PEP 562).""" + if name in _LAZY: + value = _LAZY[name]() + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/requirements.txt b/requirements.txt index 27221fe..f7f218d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +# Runtime dependencies. Font Awesome is optional and lives in the "icons" extra: +# pip install "pywaffle[icons]" matplotlib -fontawesomefree diff --git a/requirements_dev.txt b/requirements_dev.txt index 5742b7a..2ef2656 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,5 @@ --r requirements.txt +# Development environment. The test suite exercises icons throughout, so it needs the extra. +-e .[icons] black build pandas diff --git a/tests/test_optional_fontawesome.py b/tests/test_optional_fontawesome.py new file mode 100644 index 0000000..b0a9a6c --- /dev/null +++ b/tests/test_optional_fontawesome.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*-coding: utf-8 -*- +"""Font Awesome is an optional dependency, so the package has to behave without it.""" + +import builtins +import importlib.util +import sys +import unittest +from unittest import mock + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt + +from pywaffle.fontawesome_handler import MISSING_FONT_AWESOME +from pywaffle.waffle import Waffle + + +def without_fontawesome(): + """Make importing fontawesomefree fail, as it would for someone who did not install the extra. + + The real import is cached in sys.modules and the resolved fonts are cached by lru_cache, so + both have to be cleared for the absence to be visible. + """ + real_import = builtins.__import__ + + def refuse(name, *args, **kwargs): + if name == "fontawesomefree": + raise ImportError("No module named 'fontawesomefree'") + return real_import(name, *args, **kwargs) + + return mock.patch.object(builtins, "__import__", refuse) + + +class TestWithoutFontAwesome(unittest.TestCase): + """What a user who ran plain `pip install pywaffle` sees.""" + + def setUp(self): + from pywaffle import fontawesome_handler + + self.handler = fontawesome_handler + # Clear anything a previous test resolved, and restore it afterwards + self.cached = {k: fontawesome_handler.__dict__.pop(k, None) for k in fontawesome_handler._LAZY} + fontawesome_handler.font_file_finder.cache_clear() + fontawesome_handler.icon_mapping_builder.cache_clear() + self.saved_module = sys.modules.pop("fontawesomefree", None) + + def tearDown(self): + for name, value in self.cached.items(): + if value is not None: + self.handler.__dict__[name] = value + if self.saved_module is not None: + sys.modules["fontawesomefree"] = self.saved_module + plt.close("all") + + def test_rectangle_charts_still_work(self): + """The common case does not involve icons and must not require the font package.""" + with without_fontawesome(): + fig = plt.figure(FigureClass=Waffle, rows=5, values=[10, 20]) + self.assertEqual(len(fig.axes[0].patches), 30) + + def test_characters_still_work(self): + """Characters use the system font, not Font Awesome.""" + with without_fontawesome(): + fig = plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], characters="*") + self.assertEqual(len(fig.axes[0].texts), 30) + + def test_icons_raise_importerror_with_instructions(self): + """A bare ModuleNotFoundError tells the user nothing about what to install.""" + with without_fontawesome(): + with self.assertRaises(ImportError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + message = str(caught.exception) + self.assertIn("pywaffle[icons]", message) + self.assertIn("fontawesomefree", message) + + def test_the_message_is_the_shared_one(self): + """One message, so the install instructions cannot drift between call sites.""" + with without_fontawesome(): + with self.assertRaises(ImportError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + self.assertEqual(str(caught.exception), MISSING_FONT_AWESOME) + + def test_the_handler_module_still_imports(self): + """_parameter_validation imports it just to read FA_STYLES, before any font is needed.""" + with without_fontawesome(): + from pywaffle.fontawesome_handler import FA_STYLES + + self.assertEqual(set(FA_STYLES), {"brands", "solid", "regular"}) + + +HAS_FONT_AWESOME = importlib.util.find_spec("fontawesomefree") is not None + + +@unittest.skipIf(not HAS_FONT_AWESOME, "the icons extra is not installed") +class TestWithFontAwesome(unittest.TestCase): + """With the extra installed, nothing about icons changes.""" + + @staticmethod + def tearDown(): + """Close the figures each test leaves behind.""" + plt.close("all") + + def test_icons_draw(self): + """The whole point of the extra.""" + fig = plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + self.assertEqual(len(fig.axes[0].texts), 30) + + def test_lazy_attributes_resolve(self): + """The module attributes are resolved on first access rather than at import.""" + from pywaffle import fontawesome_handler + + self.assertGreater(len(fontawesome_handler.icons["solid"]), 1000) + self.assertEqual(set(fontawesome_handler.fontawesome_files), {"brands", "solid", "regular"}) + self.assertTrue(fontawesome_handler.legend_handler_style_mapping) + + def test_unknown_attribute_still_raises_attributeerror(self): + """The lazy hook must not swallow genuine typos.""" + from pywaffle import fontawesome_handler + + with self.assertRaises(AttributeError): + fontawesome_handler.no_such_attribute + + +if __name__ == "__main__": + unittest.main() From 5aebb4d8392fe1ef2e15d4fb0d960ff1c73657e2 Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:09:10 -0400 Subject: [PATCH 2/7] Find Font Awesome on the system, not only in the Python package Groundwork for #25, which asks for a system-packaged Font Awesome. Distributions package Font Awesome as fonts -- Fedora's fontawesome-6-free-fonts, Arch's otf-font-awesome, Debian's fonts-font-awesome -- and they keep the upstream file names, so the existing style matching already recognises them. PYWAFFLE_FONTAWESOME_DIR names a directory of .otf files to use. Failing that, the fontawesomefree package, and failing that the usual system font directories. When nothing is found the error lists every directory tried and names the variable, rather than only saying to pip install something. The harder half is the names. Distribution font packages do not ship Font Awesome's icons.json, and until now that file was the only source of the icon name to character mapping. It turns out the fonts carry the names themselves: Font Awesome stores each icon's name as its glyph name, so inverting the character map recovers them. matplotlib's own FreeType binding reads that, so this needs no dependency beyond matplotlib. Measured what is lost rather than assuming. Against the 1,959 solid entries in icons.json, the font-derived mapping has 1,398 names, and every one of the 564 absent names is an alias -- no canonical icon name is missing. A further 158 names resolve to a different code point, because Font Awesome maps both a private-use code point and the matching real Unicode one to the same glyph; all 158 reach the same glyph, so nothing renders differently. Checked that end to end rather than inferring it: a chart drawn from a fonts-only directory with fontawesomefree uninstalled produces a byte-identical PNG to the same chart drawn from the package, even though star resolves to U+2B50 there and U+F005 here. icons.json is still preferred when present, since it carries the aliases. --- CHANGELOG.md | 2 + docs/font_awesome_integration.rst | 28 +++++ pywaffle/fontawesome_handler.py | 144 ++++++++++++++++++++++--- tests/test_optional_fontawesome.py | 19 +++- tests/test_system_fontawesome.py | 168 +++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 tests/test_system_fontawesome.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f8ea4..16d3b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Breaking New +* Font Awesome can now come from the system rather than the Python package. `PYWAFFLE_FONTAWESOME_DIR` points at a directory of `.otf` files, and the usual system font directories are searched as a fallback, so a distribution's font package works on its own. Distribution packages ship fonts without Font Awesome's `icons.json`, so in that case the icon names are recovered from the fonts themselves - every canonical name is available, though aliases are not ([#25](https://github.com/gyli/PyWaffle/issues/25)) + * Add `rounding_rule="float"`, which draws partial blocks instead of rounding values ([#26](https://github.com/gyli/PyWaffle/issues/26)). A category that ends part way through a block fills only that fraction of it, and a block containing a boundary between two categories is split between their colors. The block count then depends only on the total of the values, so two datasets with the same total produce charts of the same size - which rounding did not guarantee * Add `background_color`, which fills the space behind the blocks including the gaps between them, and `block_edge_color` / `block_edge_width`, which draw a border around each block ([#37](https://github.com/gyli/PyWaffle/issues/37)) * Add `show_values` and `value_format`, which append each category's value or its percentage of the total to its legend label. This is the f-string the documentation has always told people to write by hand: `labels=[f"{k} ({v}%)" for k, v in data.items()]` diff --git a/docs/font_awesome_integration.rst b/docs/font_awesome_integration.rst index 647ddce..a7e4c43 100644 --- a/docs/font_awesome_integration.rst +++ b/docs/font_awesome_integration.rst @@ -13,6 +13,34 @@ Nothing else needs it. Rectangle blocks, and the ``characters`` parameter, work asking for ``icons`` when it is absent raises ``ImportError`` naming the command to run rather than a bare ``ModuleNotFoundError``. +Using a system Font Awesome +--------------------------- + +PyWaffle does not need the Python package specifically -- it needs the fonts. Set +:code:`PYWAFFLE_FONTAWESOME_DIR` to a directory of Font Awesome ``.otf`` files and they are used +instead:: + + $ export PYWAFFLE_FONTAWESOME_DIR=/usr/share/fonts/fontawesome + +If neither the environment variable nor the Python package provides the fonts, the usual system +font directories are searched, so a distribution's font package is often enough on its own: + +* Fedora, ``fontawesome-6-free-fonts`` and ``fontawesome-6-brands-fonts`` +* Arch, ``otf-font-awesome`` in :code:`/usr/share/fonts/OTF` +* Debian and Ubuntu, ``fonts-font-awesome`` + +Distribution packages ship the fonts without Font Awesome's ``icons.json``, so in that case the +icon names are recovered from the fonts themselves -- Font Awesome stores each icon's name as its +glyph name, so the character map gives every name back. Two consequences worth knowing: + +* **Aliases are unavailable.** They exist only in ``icons.json``, so ``circle-half-stroke`` works + while its alias ``adjust`` does not. +* Some icons resolve to a different code point, because Font Awesome maps both a private-use code + point and the matching real Unicode one to the same glyph. The chart is unchanged; only the + character behind it differs. + +Everything else is identical, including which icons exist. + Upgrading or downgrading Font Awesome ------------------------------------- diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index e740bba..b110985 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -3,6 +3,7 @@ import inspect import json +import os import pathlib from functools import lru_cache from collections import defaultdict @@ -28,6 +29,23 @@ ) +#: Environment variable naming a directory of Font Awesome .otf files to use instead of the +#: fontawesomefree package. Set it to use a system-provided Font Awesome. +FONT_DIRECTORY_VARIABLE = "PYWAFFLE_FONTAWESOME_DIR" + +#: Where distributions put Font Awesome. Searched only when the environment variable is unset and +#: the fontawesomefree package is not installed. +SYSTEM_FONT_DIRECTORIES = ( + "/usr/share/fonts/fontawesome", # Fedora, fontawesome-fonts + "/usr/share/fonts/OTF", # Arch, otf-font-awesome + "/usr/share/fonts/opentype/font-awesome", # Debian and Ubuntu + "/usr/share/fonts/truetype/font-awesome", + "/usr/local/share/fonts", # manual installs + "/opt/homebrew/share/fonts", # Homebrew on Apple silicon + "/usr/local/share/fonts/otf", +) + + def fontawesome_package_path() -> pathlib.Path: """Path to the static asset directory of the installed fontawesomefree package. @@ -43,29 +61,88 @@ def fontawesome_package_path() -> pathlib.Path: return package_path.parent / "static/fontawesomefree" -@lru_cache(maxsize=None) -def font_file_finder() -> Dict[str, pathlib.Path]: - """Map each Font Awesome style to the .otf file that provides it.""" - font_otf_path = (fontawesome_package_path() / "otfs").glob("*.otf") +def _styles_in(directory: pathlib.Path) -> Dict[str, pathlib.Path]: + """Match the .otf files in one directory to the Font Awesome styles they provide. + + Distributions keep the upstream file names -- "Font Awesome 6 Free-Solid-900.otf" and the + like -- so the same suffix match works for a system directory as for the Python package. + """ + if not directory.is_dir(): + return {} return { style: path - for path in font_otf_path + for path in sorted(directory.glob("*.otf")) for style, font_suffix in FA_STYLES.items() if font_suffix.lower() in path.name.lower() } -@lru_cache(maxsize=None) -def icon_mapping_builder() -> Dict[str, Dict[str, str]]: +def font_directory_candidates(): + """Directories to search for Font Awesome, most specific first. + + An explicit setting wins, then the Python package, then the places distributions install it. + Yields (path, is_package) so the caller can tell whether icons.json sits alongside. """ - Build the icon name to Unicode character mapping from the metadata shipped with the installed - fontawesomefree package. + override = os.environ.get(FONT_DIRECTORY_VARIABLE) + if override: + yield pathlib.Path(override), False + + try: + yield fontawesome_package_path() / "otfs", True + except ImportError: + pass + + for directory in SYSTEM_FONT_DIRECTORIES: + yield pathlib.Path(directory), False + - Reading it at runtime keeps the mapping in sync with whichever Font Awesome version is installed. - Generating it at install time does not work, because a wheel install never runs setup.py. +@lru_cache(maxsize=None) +def font_file_finder() -> Dict[str, pathlib.Path]: + """Map each Font Awesome style to the .otf file that provides it. + + Prefers an explicitly configured directory, then the fontawesomefree package, then the system + font directories, so a distribution can supply the fonts without the Python package. + """ + searched = [] + for directory, _ in font_directory_candidates(): + found = _styles_in(directory) + if found: + return found + searched.append(str(directory)) + + raise ImportError( + MISSING_FONT_AWESOME + + "\n\nNo Font Awesome .otf files were found in:\n " + + "\n ".join(searched or ["(nowhere searched)"]) + + f"\n\nSet {FONT_DIRECTORY_VARIABLE} to a directory of Font Awesome .otf files to use " + "a system copy." + ) + + +def _metadata_file() -> pathlib.Path: + """Path to Font Awesome's icons.json, if whatever is providing the fonts also provides it. + + The Python package ships it. Distribution font packages generally do not -- they package + fonts, not the web tooling -- so this can legitimately find nothing. """ - icons_json_path = fontawesome_package_path() / "metadata" / "icons.json" - with open(icons_json_path, "r") as f: + for directory, is_package in font_directory_candidates(): + if not _styles_in(directory): + continue + candidates = [directory.parent / "metadata" / "icons.json"] if is_package else [] + candidates += [directory / "icons.json", directory / "metadata" / "icons.json"] + for candidate in candidates: + if candidate.is_file(): + return candidate + break + return None + + +def _mapping_from_metadata(path: pathlib.Path) -> Dict[str, Dict[str, str]]: + """Build the name to character mapping from Font Awesome's own metadata. + + This is the better source: it carries the aliases, which the fonts do not. + """ + with open(path, "r") as f: icons_metadata = json.load(f) mapping: Dict[str, Dict[str, str]] = defaultdict(dict) @@ -85,6 +162,47 @@ def icon_mapping_builder() -> Dict[str, Dict[str, str]]: return dict(mapping) +def _mapping_from_fonts() -> Dict[str, Dict[str, str]]: + """Build the name to character mapping out of the font files themselves. + + Font Awesome stores real icon names as glyph names, so the character map inverted gives every + canonical name without any metadata file. Read through matplotlib's own FreeType binding, so + this needs no dependency beyond matplotlib. + + Two differences from the metadata, both checked rather than assumed: + + * Aliases are absent. They exist only in icons.json, so ``adjust`` will not resolve while + ``circle-half-stroke`` will. + * Where a glyph has several code points -- Font Awesome maps both its private-use code point + and the matching real Unicode one -- this may pick the other one. It renders the same glyph, + because both code points map to it. + """ + from matplotlib.ft2font import FT2Font + + mapping: Dict[str, Dict[str, str]] = defaultdict(dict) + for style, path in font_file_finder().items(): + face = FT2Font(str(path)) + for code_point, glyph_index in face.get_charmap().items(): + name = face.get_glyph_name(glyph_index) + if name: + mapping[style].setdefault(name, chr(code_point)) + return dict(mapping) + + +@lru_cache(maxsize=None) +def icon_mapping_builder() -> Dict[str, Dict[str, str]]: + """Map each style's icon names to the characters that draw them. + + Prefers Font Awesome's own metadata, which includes aliases. Falls back to reading the fonts, + so a system Font Awesome works even though distributions ship fonts without icons.json. + + Built at runtime either way, so the names always match the fonts actually being drawn from. + Generating it at install time did not work, because a wheel install never runs setup.py. + """ + metadata = _metadata_file() + return _mapping_from_metadata(metadata) if metadata else _mapping_from_fonts() + + class TextLegendBase: """A legend entry that is a glyph rather than a colour swatch.""" diff --git a/tests/test_optional_fontawesome.py b/tests/test_optional_fontawesome.py index b0a9a6c..e3de688 100644 --- a/tests/test_optional_fontawesome.py +++ b/tests/test_optional_fontawesome.py @@ -76,12 +76,25 @@ def test_icons_raise_importerror_with_instructions(self): self.assertIn("pywaffle[icons]", message) self.assertIn("fontawesomefree", message) - def test_the_message_is_the_shared_one(self): - """One message, so the install instructions cannot drift between call sites.""" + def test_the_message_starts_with_the_shared_instructions(self): + """One source for the install instructions, so they cannot drift between call sites. + + The message continues with the directories that were searched, which is why this is a + prefix check rather than an equality one. + """ + with without_fontawesome(): + with self.assertRaises(ImportError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") + self.assertTrue(str(caught.exception).startswith(MISSING_FONT_AWESOME)) + + def test_the_message_names_where_it_looked(self): + """Someone with a system Font Awesome needs to know which directories were tried.""" with without_fontawesome(): with self.assertRaises(ImportError) as caught: plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") - self.assertEqual(str(caught.exception), MISSING_FONT_AWESOME) + message = str(caught.exception) + self.assertIn("PYWAFFLE_FONTAWESOME_DIR", message) + self.assertIn("/usr/share/fonts", message) def test_the_handler_module_still_imports(self): """_parameter_validation imports it just to read FA_STYLES, before any font is needed.""" diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py new file mode 100644 index 0000000..6ccebfb --- /dev/null +++ b/tests/test_system_fontawesome.py @@ -0,0 +1,168 @@ +#!/usr/bin/python +# -*-coding: utf-8 -*- +"""Font Awesome supplied by the system rather than by the Python package. + +Distributions package Font Awesome as fonts -- Fedora's fontawesome-6-free-fonts, Arch's +otf-font-awesome, Debian's fonts-font-awesome -- without the icons.json that the Python package +ships. So the names have to be recoverable from the fonts alone. +""" + +import importlib.util +import os +import pathlib +import shutil +import tempfile +import unittest + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt + +from pywaffle import fontawesome_handler as handler +from pywaffle.waffle import Waffle + +HAS_FONT_AWESOME = importlib.util.find_spec("fontawesomefree") is not None + + +def reset_caches(): + """Clear the resolved fonts and mapping, which are cached for the life of the process.""" + handler.font_file_finder.cache_clear() + handler.icon_mapping_builder.cache_clear() + for name in handler._LAZY: + handler.__dict__.pop(name, None) + + +@unittest.skipIf(not HAS_FONT_AWESOME, "needs a Font Awesome to copy into a fake system directory") +class TestSystemFontDirectory(unittest.TestCase): + """A directory of .otf files, with no metadata beside them.""" + + @classmethod + def setUpClass(cls): + """Build a directory holding only fonts, the way a distribution package does.""" + cls._tmp = tempfile.TemporaryDirectory() + cls.font_dir = pathlib.Path(cls._tmp.name) + reset_caches() + for path in handler.font_file_finder().values(): + shutil.copy(path, cls.font_dir) + reset_caches() + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + reset_caches() + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self.font_dir) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + plt.close("all") + + def test_the_override_is_used(self): + """The environment variable beats the installed package.""" + for path in handler.font_file_finder().values(): + self.assertEqual(path.parent, self.font_dir) + + def test_no_metadata_is_found_beside_the_fonts(self): + """The premise of this whole test case: distributions ship fonts without icons.json.""" + self.assertIsNone(handler._metadata_file()) + + def test_names_are_recovered_from_the_fonts(self): + """Font Awesome stores real icon names as glyph names, so the fonts alone are enough.""" + mapping = handler.icon_mapping_builder() + self.assertEqual(set(mapping), {"solid", "regular", "brands"}) + for name in ("star", "heart", "car-side", "bicycle"): + self.assertIn(name, mapping["solid"], f"{name} should be recoverable from the font") + self.assertIn("bluesky", mapping["brands"]) + + def test_icons_draw(self): + """The point of the exercise.""" + fig = plt.figure(FigureClass=Waffle, rows=5, columns=10, values=[30, 20], icons="star") + self.assertEqual(len(fig.axes[0].texts), 50) + + def test_every_canonical_name_survives(self): + """Only aliases are lost; no real icon name should be missing.""" + from_fonts = handler._mapping_from_fonts() + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + reset_caches() + from_metadata = handler.icon_mapping_builder() + + import json + + metadata = json.load(open(handler._metadata_file())) + for style in ("solid", "brands", "regular"): + canonical = {n for n, m in metadata.items() if style in m["styles"]} + missing = canonical - set(from_fonts[style]) + with self.subTest(style=style): + self.assertEqual(sorted(missing), [], f"{len(missing)} canonical names lost") + self.assertLess(len(from_fonts[style]), len(from_metadata[style]) + 400) + + def test_rendering_matches_the_packaged_fonts(self): + """A chart drawn from system fonts must be the same chart. + + Some names resolve to a different code point -- Font Awesome maps both a private-use and a + real Unicode code point to the same glyph -- so this compares what is drawn, not the + characters chosen. + """ + + def positions_and_glyph_widths(): + fig = plt.figure( + FigureClass=Waffle, + rows=5, + columns=10, + values=[30, 12, 8], + icons=["star", "car-side", "bicycle"], + figsize=(6, 3), + dpi=100, + ) + fig.canvas.draw() + out = [ + (round(t.get_position()[0], 6), round(t.get_position()[1], 6), round(t.get_window_extent().width, 4)) + for t in fig.axes[0].texts + ] + plt.close(fig) + return out + + from_system = positions_and_glyph_widths() + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + reset_caches() + from_package = positions_and_glyph_widths() + + self.assertEqual(from_system, from_package) + + +class TestDiscoveryOrder(unittest.TestCase): + """Where PyWaffle looks, and in what order.""" + + @staticmethod + def tearDown(): + """Restore the caches other tests rely on.""" + reset_caches() + + def test_system_directories_are_searched_last(self): + """An explicit setting beats the package, which beats the system.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = "/nonexistent-on-purpose" + try: + candidates = [str(d) for d, _ in handler.font_directory_candidates()] + finally: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + self.assertEqual(candidates[0], "/nonexistent-on-purpose") + self.assertTrue(any("/usr/share/fonts" in c for c in candidates)) + self.assertLess(candidates.index("/nonexistent-on-purpose"), len(candidates) - 1) + + def test_the_package_is_flagged_as_the_package(self): + """Only the package has icons.json one level up, so the caller has to be able to tell.""" + flags = [is_package for _, is_package in handler.font_directory_candidates()] + self.assertEqual(flags.count(True), 1 if HAS_FONT_AWESOME else 0) + + +if __name__ == "__main__": + unittest.main() From afb62e493b78b0b3849999e46e2757019fb1c4c2 Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:11:52 -0400 Subject: [PATCH 3/7] Make the system-font tests pass on Windows Three failures on the Windows runner, all in the tests rather than the library. The two path assertions compared against Unix-shaped strings, but pathlib renders /usr/share/fonts with backslashes on Windows, so the substring was never going to be there. They now build the expected value the same way the code does, and compare paths as paths. The third was a PermissionError deleting the temporary font directory: Windows will not unlink a file that is still open, and FreeType holds each font open for the life of the face object. The caches are cleared before the directory is removed, and the removal ignores errors. Worth noting the library itself was fine on Windows throughout -- font discovery already used pathlib, which is why only the assertions broke. --- tests/test_optional_fontawesome.py | 7 ++++++- tests/test_system_fontawesome.py | 14 ++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_optional_fontawesome.py b/tests/test_optional_fontawesome.py index e3de688..ff17fca 100644 --- a/tests/test_optional_fontawesome.py +++ b/tests/test_optional_fontawesome.py @@ -4,6 +4,7 @@ import builtins import importlib.util +import pathlib import sys import unittest from unittest import mock @@ -94,7 +95,11 @@ def test_the_message_names_where_it_looked(self): plt.figure(FigureClass=Waffle, rows=5, values=[10, 20], icons="star") message = str(caught.exception) self.assertIn("PYWAFFLE_FONTAWESOME_DIR", message) - self.assertIn("/usr/share/fonts", message) + # The paths are rendered with the platform separator, so compare against what this + # platform would actually print rather than against a Unix-shaped string. + from pywaffle.fontawesome_handler import SYSTEM_FONT_DIRECTORIES + + self.assertIn(str(pathlib.Path(SYSTEM_FONT_DIRECTORIES[0])), message) def test_the_handler_module_still_imports(self): """_parameter_validation imports it just to read FA_STYLES, before any font is needed.""" diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py index 6ccebfb..e777a50 100644 --- a/tests/test_system_fontawesome.py +++ b/tests/test_system_fontawesome.py @@ -41,8 +41,7 @@ class TestSystemFontDirectory(unittest.TestCase): @classmethod def setUpClass(cls): """Build a directory holding only fonts, the way a distribution package does.""" - cls._tmp = tempfile.TemporaryDirectory() - cls.font_dir = pathlib.Path(cls._tmp.name) + cls.font_dir = pathlib.Path(tempfile.mkdtemp()) reset_caches() for path in handler.font_file_finder().values(): shutil.copy(path, cls.font_dir) @@ -50,8 +49,10 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls._tmp.cleanup() + # Clear the caches first, so nothing is still holding a font open. Windows refuses to + # delete an open file, and FreeType keeps the handle for the life of the face object. reset_caches() + shutil.rmtree(cls.font_dir, ignore_errors=True) def setUp(self): self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) @@ -154,9 +155,10 @@ def test_system_directories_are_searched_last(self): candidates = [str(d) for d, _ in handler.font_directory_candidates()] finally: os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) - self.assertEqual(candidates[0], "/nonexistent-on-purpose") - self.assertTrue(any("/usr/share/fonts" in c for c in candidates)) - self.assertLess(candidates.index("/nonexistent-on-purpose"), len(candidates) - 1) + self.assertEqual(pathlib.Path(candidates[0]), pathlib.Path("/nonexistent-on-purpose")) + system = [pathlib.Path(d) for d in handler.SYSTEM_FONT_DIRECTORIES] + self.assertTrue(any(pathlib.Path(c) in system for c in candidates)) + self.assertLess(candidates.index(candidates[0]), len(candidates) - 1) def test_the_package_is_flagged_as_the_package(self): """Only the package has icons.json one level up, so the caller has to be able to tell.""" From eb061fa46e857371bd81aa9c6c8d380446967b4f Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:22:04 -0400 Subject: [PATCH 4/7] Explain icon lookups instead of raising KeyError, and never ignore the font directory Two gaps found while considering whether Font Awesome needs a version check. It does not -- the mapping is built from whatever font is present, so the names that exist are by construction the names that font has -- but that answer only holds because the failure modes around it are clear, and two were not. An unknown icon name raised a bare KeyError with the name in it. Which names exist depends on the installed Font Awesome version and on the style, so that left no way to tell a typo from an icon that moved styles or was added after the version installed. It now raises ValueError, like every other argument error, and says which of the three cases it is: the icon is in another style and here is what to pass, or it looks like a near miss and here is the nearest name, or it is absent and names change between versions. PYWAFFLE_FONTAWESOME_DIR was silently ignored when it held no recognisable fonts -- discovery simply carried on to the Python package, so someone pointing at a system font directory could believe it was in use when it was not. Setting it explicitly now means it is used or the failure is reported, naming the files that were there and what was expected. That surfaces Font Awesome 4 usefully. It ships a single FontAwesome.otf with no separate solid, regular and brands styles, so it cannot work here, and Fedora's fontawesome-fonts package is still 4.7.0. The error says so rather than reporting that no fonts were found while the user is looking at one. Checked that Font Awesome 5 naming works unchanged, which is the evidence for not adding a version check: the style suffixes are the same from 5 through 7. --- CHANGELOG.md | 1 + pywaffle/fontawesome_handler.py | 17 +++++++ pywaffle/waffle.py | 34 ++++++++++++- tests/test_system_fontawesome.py | 82 ++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d3b81..a8c1bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Fixes * Reject a negative or non-integer `rows` / `columns`, a non-positive `block_aspect_ratio`, a negative `interval_ratio_x` / `interval_ratio_y`, an unknown `plot_anchor`, and non-numeric `values` elements. `rows=-5` previously drew an empty chart with no error, and `plot_anchor='XX'` was accepted because matplotlib's `set_anchor` does not validate it either * Refuse to draw a chart of more than `MAX_BLOCKS` (10,000,000) blocks. Values that were meant to be scaled previously turned into minutes of drawing rather than an error; the limit can be raised with `pywaffle.waffle.MAX_BLOCKS` * Reject negative `values` and a `values` sum of zero up front, instead of silently drawing a wrong chart or raising `ZeroDivisionError` +* An unknown icon name now raises `ValueError` explaining itself rather than a bare `KeyError`. If the icon exists in another style it says which and what to pass; if it looks like a typo it suggests the nearest name; otherwise it notes that names change between Font Awesome versions * Reject unknown `block_arranging_style`, which was previously accepted and silently drawn as `normal` * Raise `ValueError` rather than `KeyError` or `AttributeError` for invalid `starting_location`, `rounding_rule` and `icon_style`, and accept `icon_style` lists in any case diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index b110985..7f809b5 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -103,6 +103,8 @@ def font_file_finder() -> Dict[str, pathlib.Path]: Prefers an explicitly configured directory, then the fontawesomefree package, then the system font directories, so a distribution can supply the fonts without the Python package. """ + override = os.environ.get(FONT_DIRECTORY_VARIABLE) + searched = [] for directory, _ in font_directory_candidates(): found = _styles_in(directory) @@ -110,6 +112,21 @@ def font_file_finder() -> Dict[str, pathlib.Path]: return found searched.append(str(directory)) + # Falling back past an explicit setting would hide the fact that it did not work + if override and str(directory) == str(pathlib.Path(override)): + present = sorted(path.name for path in directory.glob("*.otf")) if directory.is_dir() else [] + detail = ( + "it contains no Font Awesome .otf files" + if not present + else "the .otf files there are not recognised: " + ", ".join(present) + ) + raise ImportError( + f"{FONT_DIRECTORY_VARIABLE} is set to {directory}, but {detail}.\n" + f"Expected file names ending in: " + ", ".join(sorted(FA_STYLES.values())) + ".\n" + "Font Awesome 4 is not supported: it ships a single FontAwesome.otf with no " + "separate solid, regular and brands styles." + ) + raise ImportError( MISSING_FONT_AWESOME + "\n\nNo Font Awesome .otf files were found in:\n " diff --git a/pywaffle/waffle.py b/pywaffle/waffle.py index 2e5bb35..189ac71 100644 --- a/pywaffle/waffle.py +++ b/pywaffle/waffle.py @@ -963,7 +963,8 @@ def _resolve_glyphs(self, par: Dict, ax: Axes, block_x_length: float): # Replace icon name with Unicode symbols in parameter icons par["icons"] = [ - icons[icon_style][icon_name] for icon_name, icon_style in zip(par["icons"], par["icon_style"]) + self._resolve_icon(icons, icon_name, icon_style) + for icon_name, icon_style in zip(par["icons"], par["icon_style"]) ] return fm.FontProperties(size=par["font_size"] or self._block_font_size(ax, block_x_length)) @@ -983,6 +984,37 @@ def _resolve_glyphs(self, par: Dict, ax: Axes, block_x_length: float): return None + @staticmethod + def _resolve_icon(icons: Dict, name: str, style: str) -> str: + """Look up one icon name, and say something useful when it is not there. + + Which names exist depends on the Font Awesome version installed and on the style, so a bare + KeyError leaves the user unable to tell a typo from an icon that was renamed, moved between + styles, or added after their version. + """ + try: + return icons[style][name] + except KeyError: + pass + + elsewhere = sorted(other for other in icons if other != style and name in icons[other]) + if elsewhere: + raise ValueError( + f"Icon {name!r} is not in the {style!r} style, but it is in " + f"{', '.join(repr(s) for s in elsewhere)}. Pass icon_style={elsewhere[0]!r}." + ) + + import difflib + + close = difflib.get_close_matches(name, icons[style], n=3, cutoff=0.7) + suggestion = f" Did you mean {', '.join(repr(c) for c in close)}?" if close else "" + raise ValueError( + f"Icon {name!r} was not found in the {style!r} style of the installed Font Awesome, " + f"which has {len(icons[style]):,} {style} icons.{suggestion} " + "Names change between Font Awesome versions; check the icon exists in the version you " + "have installed." + ) + def _draw_blocks( self, ax: Axes, diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py index e777a50..8bac64a 100644 --- a/tests/test_system_fontawesome.py +++ b/tests/test_system_fontawesome.py @@ -168,3 +168,85 @@ def test_the_package_is_flagged_as_the_package(self): if __name__ == "__main__": unittest.main() + + +class TestExplicitDirectoryIsNotIgnored(unittest.TestCase): + """An explicitly configured directory that yields nothing must say so. + + Falling through to the Python package would leave someone believing their system font was in + use when it was not. + """ + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + plt.close("all") + + def test_an_empty_directory_is_an_error(self): + """Not a silent fallback to whatever else happens to be installed.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + with self.assertRaisesRegex(ImportError, "contains no Font Awesome"): + handler.font_file_finder() + + def test_unrecognised_fonts_are_named(self): + """Font Awesome 4 ships one FontAwesome.otf with no style split, and Fedora packages it.""" + (self._tmp / "FontAwesome.otf").write_bytes(b"not really a font") + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + with self.assertRaises(ImportError) as caught: + handler.font_file_finder() + message = str(caught.exception) + self.assertIn("FontAwesome.otf", message) + self.assertIn("Font Awesome 4 is not supported", message) + + def test_a_missing_directory_is_an_error(self): + """A typo in the variable should not quietly do nothing.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp / "nope") + with self.assertRaisesRegex(ImportError, "contains no Font Awesome"): + handler.font_file_finder() + + +@unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome installed") +class TestUnknownIconNames(unittest.TestCase): + """Which names exist depends on the installed Font Awesome version and on the style.""" + + @staticmethod + def tearDown(): + """Close the figures each test leaves behind.""" + plt.close("all") + + def test_an_icon_in_another_style_says_which(self): + """bluesky is a brands icon, and the default style is solid.""" + with self.assertRaises(ValueError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="bluesky") + message = str(caught.exception) + self.assertIn("'brands'", message) + self.assertIn("icon_style='brands'", message) + + def test_a_near_miss_is_suggested(self): + """A typo should not read the same as an icon that does not exist.""" + with self.assertRaises(ValueError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="strr") + self.assertIn("Did you mean", str(caught.exception)) + self.assertIn("'star'", str(caught.exception)) + + def test_an_unknown_name_mentions_the_installed_version(self): + """The usual cause is an icon added after the Font Awesome the user has.""" + with self.assertRaises(ValueError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="definitely-not-an-icon") + message = str(caught.exception) + self.assertIn("installed Font Awesome", message) + self.assertIn("Font Awesome versions", message) + + def test_it_is_a_valueerror_like_every_other_argument_error(self): + """A bare KeyError is not catchable alongside the rest of the argument validation.""" + with self.assertRaises(ValueError): + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="definitely-not-an-icon") From 4e9a5232c9c96512f258a988af7c45064a96c2a8 Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:33:49 -0400 Subject: [PATCH 5/7] Report which Font Awesome is in use, and never leave a dead end Fonts can now come from three places, so which one is in use should not be something a user has to work out. pywaffle.font_awesome_status() reports the source, the directory, the version, the icon count per style, and whether aliases are available: Font Awesome 6.6.0 source: fontawesomefree package directory: .../fontawesomefree/static/fontawesomefree/otfs aliases: yes, from icons.json styles: brands 527 icons Font Awesome 6 Brands-Regular-400.otf regular 257 icons Font Awesome 6 Free-Regular-400.otf solid 1,959 icons Font Awesome 6 Free-Solid-900.otf It never raises. A diagnostic that only works when nothing is wrong is no use, so when Font Awesome cannot be found it reports the problem instead, and the same structured fields are available as attributes for checking in code. The version comes from the package metadata when the package is the source, and otherwise from the font's own family name -- "Font Awesome 6 Free" gives the major version -- so a system font still reports something. The remaining dead end is closed too. A PYWAFFLE_FONTAWESOME_DIR holding no usable fonts explained what was wrong with the directory but never mentioned that installing the package is the simplest way out. Every path that fails now ends with the install command, whether the variable is unset, set to an empty directory, or set to a Font Awesome 4 directory. --- CHANGELOG.md | 1 + docs/font_awesome_integration.rst | 36 ++++++++++++ pywaffle/__init__.py | 3 +- pywaffle/fontawesome_handler.py | 92 ++++++++++++++++++++++++++++++- tests/test_system_fontawesome.py | 71 ++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c1bca..53c58d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Breaking New +* Add `pywaffle.font_awesome_status()`, which reports which Font Awesome is in use, where it came from, its version, how many icons each style has, and whether aliases are available. It never raises: when Font Awesome cannot be found it reports every directory searched and how to install it * Font Awesome can now come from the system rather than the Python package. `PYWAFFLE_FONTAWESOME_DIR` points at a directory of `.otf` files, and the usual system font directories are searched as a fallback, so a distribution's font package works on its own. Distribution packages ship fonts without Font Awesome's `icons.json`, so in that case the icon names are recovered from the fonts themselves - every canonical name is available, though aliases are not ([#25](https://github.com/gyli/PyWaffle/issues/25)) * Add `rounding_rule="float"`, which draws partial blocks instead of rounding values ([#26](https://github.com/gyli/PyWaffle/issues/26)). A category that ends part way through a block fills only that fraction of it, and a block containing a boundary between two categories is split between their colors. The block count then depends only on the total of the values, so two datasets with the same total produce charts of the same size - which rounding did not guarantee diff --git a/docs/font_awesome_integration.rst b/docs/font_awesome_integration.rst index a7e4c43..846465c 100644 --- a/docs/font_awesome_integration.rst +++ b/docs/font_awesome_integration.rst @@ -13,6 +13,42 @@ Nothing else needs it. Rectangle blocks, and the ``characters`` parameter, work asking for ``icons`` when it is absent raises ``ImportError`` naming the command to run rather than a bare ``ModuleNotFoundError``. +Which Font Awesome is in use +---------------------------- + +PyWaffle can take its fonts from three places, so it can tell you which one it settled on:: + + >>> from pywaffle import font_awesome_status + >>> print(font_awesome_status()) + Font Awesome 6.6.0 + source: fontawesomefree package + directory: .../site-packages/fontawesomefree/static/fontawesomefree/otfs + aliases: yes, from icons.json + styles: + brands 527 icons Font Awesome 6 Brands-Regular-400.otf + regular 257 icons Font Awesome 6 Free-Regular-400.otf + solid 1,959 icons Font Awesome 6 Free-Solid-900.otf + +It never raises. When Font Awesome cannot be found it reports every directory that was searched +and how to install it, which is the case where knowing what PyWaffle looked at matters most. + +The returned :code:`FontAwesomeStatus` also carries the same information as attributes -- +:code:`available`, :code:`source`, :code:`directory`, :code:`version`, :code:`fonts`, +:code:`icon_counts`, :code:`aliases_available` and :code:`problem` -- for checking in code. + +Where the fonts come from +------------------------- + +In order: + +1. :code:`PYWAFFLE_FONTAWESOME_DIR`, if set. If it is set but holds no usable fonts this is an + error rather than a silent fall-through, since an ignored setting is worse than a refusal. +2. The :code:`fontawesomefree` package, from :code:`pip install "pywaffle[icons]"`. +3. The system font directories listed below. + +If none of them provide the fonts, asking for :code:`icons` raises :code:`ImportError` naming +every directory tried and the command to install the package. + Using a system Font Awesome --------------------------- diff --git a/pywaffle/__init__.py b/pywaffle/__init__.py index f64410b..bb55f4d 100644 --- a/pywaffle/__init__.py +++ b/pywaffle/__init__.py @@ -2,7 +2,8 @@ # -*-coding: utf-8 -*- from ._version import __version__ +from .fontawesome_handler import font_awesome_status from .functional import waffle_chart from .waffle import Waffle -__all__ = ["Waffle", "waffle_chart", "__version__"] +__all__ = ["Waffle", "waffle_chart", "font_awesome_status", "__version__"] diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index 7f809b5..aac21b7 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -5,9 +5,10 @@ import json import os import pathlib +from dataclasses import dataclass from functools import lru_cache from collections import defaultdict -from typing import Dict +from typing import Dict, Optional import matplotlib.font_manager as fm from matplotlib.legend_handler import HandlerBase @@ -122,9 +123,12 @@ def font_file_finder() -> Dict[str, pathlib.Path]: ) raise ImportError( f"{FONT_DIRECTORY_VARIABLE} is set to {directory}, but {detail}.\n" - f"Expected file names ending in: " + ", ".join(sorted(FA_STYLES.values())) + ".\n" + "Expected file names ending in: " + ", ".join(sorted(FA_STYLES.values())) + ".\n" "Font Awesome 4 is not supported: it ships a single FontAwesome.otf with no " - "separate solid, regular and brands styles." + "separate solid, regular and brands styles.\n\n" + f"Point {FONT_DIRECTORY_VARIABLE} at a directory holding those files, unset it to " + "fall back to the Python package and the system font directories, or install the " + "package:\n pip install 'pywaffle[icons]'" ) raise ImportError( @@ -267,6 +271,88 @@ def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, return [annotation] +@dataclass(frozen=True) +class FontAwesomeStatus: + """Which Font Awesome PyWaffle is using, and where it came from.""" + + available: bool + source: str + directory: Optional[pathlib.Path] = None + version: Optional[str] = None + fonts: Optional[Dict[str, pathlib.Path]] = None + icon_counts: Optional[Dict[str, int]] = None + aliases_available: bool = False + problem: Optional[str] = None + + def __str__(self) -> str: + if not self.available: + return f"Font Awesome: not available\n source: {self.source}\n problem: {self.problem}" + + lines = [ + f"Font Awesome {self.version or '(unknown version)'}", + f" source: {self.source}", + f" directory: {self.directory}", + f" aliases: {'yes, from icons.json' if self.aliases_available else 'no, names read from the fonts'}", + " styles:", + ] + for style in sorted(self.fonts or {}): + count = (self.icon_counts or {}).get(style, 0) + lines.append(f" {style:8s} {count:>5,} icons {self.fonts[style].name}") + return "\n".join(lines) + + +def font_awesome_status() -> FontAwesomeStatus: + """Report which Font Awesome is in use, so it is never a guess. + + Never raises. When Font Awesome cannot be found it reports why, which is the case where + knowing what PyWaffle looked at matters most. + + >>> from pywaffle import font_awesome_status + >>> print(font_awesome_status()) + """ + override = os.environ.get(FONT_DIRECTORY_VARIABLE) + try: + fonts = font_file_finder() + except ImportError as exc: + source = f"{FONT_DIRECTORY_VARIABLE}={override}" if override else "not found" + return FontAwesomeStatus(available=False, source=source, problem=str(exc)) + + directory = next(iter(fonts.values())).parent + if override and directory == pathlib.Path(override): + source = f"{FONT_DIRECTORY_VARIABLE}={override}" + elif any(directory == candidate for candidate, is_package in font_directory_candidates() if is_package): + source = "fontawesomefree package" + else: + source = "system font directory" + + version = None + if source == "fontawesomefree package": + try: + from importlib.metadata import version as _version + + version = _version("fontawesomefree") + except Exception: # pragma: no cover - metadata is normally present + version = None + if version is None: + # The family name carries the major version, e.g. "Font Awesome 6 Free" + from matplotlib.ft2font import FT2Font + + families = {FT2Font(str(path)).family_name for path in fonts.values()} + majors = {name.split()[2] for name in families if len(name.split()) > 2 and name.split()[2].isdigit()} + version = majors.pop() if len(majors) == 1 else None + + mapping = icon_mapping_builder() + return FontAwesomeStatus( + available=True, + source=source, + directory=directory, + version=version, + fonts=dict(fonts), + icon_counts={style: len(names) for style, names in mapping.items()}, + aliases_available=_metadata_file() is not None, + ) + + @lru_cache(maxsize=None) def _legend_handlers() -> Dict: """Map each legend handle class to a handler that draws it in the right font.""" diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py index 8bac64a..f10371e 100644 --- a/tests/test_system_fontawesome.py +++ b/tests/test_system_fontawesome.py @@ -250,3 +250,74 @@ def test_it_is_a_valueerror_like_every_other_argument_error(self): """A bare KeyError is not catchable alongside the rest of the argument validation.""" with self.assertRaises(ValueError): plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="definitely-not-an-icon") + + +class TestFontAwesomeStatus(unittest.TestCase): + """Which font is in use should never be a guess.""" + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + + def test_it_never_raises(self): + """It is a diagnostic, so it has to work in exactly the situations that are broken.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp / "nowhere") + status = handler.font_awesome_status() + self.assertFalse(status.available) + self.assertIsNotNone(status.problem) + + def test_a_failure_still_says_how_to_install(self): + """The whole point of reporting a problem is telling the user what to do about it.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + self.assertIn("pip install 'pywaffle[icons]'", handler.font_awesome_status().problem) + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome installed") + def test_it_names_the_package_as_the_source(self): + """The common case: the extra is installed and nothing is overridden.""" + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + reset_caches() + status = handler.font_awesome_status() + self.assertTrue(status.available) + self.assertEqual(status.source, "fontawesomefree package") + self.assertTrue(status.aliases_available, "the package ships icons.json") + self.assertRegex(status.version or "", r"^\d+\.") + self.assertEqual(set(status.fonts), {"solid", "regular", "brands"}) + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome to copy") + def test_it_names_an_overridden_directory_and_the_missing_aliases(self): + """Someone using a system font needs to know the aliases are unavailable.""" + for path in handler.font_file_finder().values(): + shutil.copy(path, self._tmp) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + reset_caches() + status = handler.font_awesome_status() + self.assertTrue(status.available) + self.assertIn(handler.FONT_DIRECTORY_VARIABLE, status.source) + self.assertEqual(status.directory, self._tmp) + self.assertFalse(status.aliases_available, "no icons.json beside the fonts") + self.assertEqual(status.version, "6", "major version read from the font family name") + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome installed") + def test_the_report_reads_as_a_report(self): + """It is printed by people diagnosing a problem, so the text matters.""" + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + reset_caches() + text = str(handler.font_awesome_status()) + for expected in ("Font Awesome", "source:", "directory:", "aliases:", "solid"): + self.assertIn(expected, text) + + def test_it_is_exported_from_the_package(self): + """Discoverable as pywaffle.font_awesome_status, not buried in a submodule.""" + import pywaffle + + self.assertIn("font_awesome_status", pywaffle.__all__) + self.assertIs(pywaffle.font_awesome_status, handler.font_awesome_status) From 392bae5a132ea9ffed664d5338ce907a8d10ddd4 Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:44:02 -0400 Subject: [PATCH 6/7] Harden the font handling against anything a user can put in front of it Reviewed the font discovery as an outside reader would, treating PYWAFFLE_FONTAWESOME_DIR and the contents of whatever it points at as untrusted input. Four things escaped as errors from inside the library rather than as the ValueError or ImportError the rest of the package raises. A partial font set raised KeyError with the style name in it. Distributions split the styles across packages -- Fedora ships free and brands separately -- so having solid but not brands is ordinary, and icon_style is validated against the three styles that exist in general rather than the ones actually installed. Asking for a missing style now says which styles the installed fonts do provide, and points at font_awesome_status(). A path too long for the filesystem escaped as OSError from os.listdir, and an unreadable directory would have done the same. Directory inspection now treats any OSError as "no fonts here", except for an explicitly configured directory, where it is reported with the reason. A file that is not really a font escaped as RuntimeError from FreeType, naming a line in ft2font.cpp. It now names the file and says it may be truncated or not a font, since the user chose the directory it came from. font_awesome_status() raised in both of those cases, which defeats the point of a diagnostic: it is what someone reaches for when things are already broken. It now reports any failure rather than raising it. Also: the variable is stripped and ~ expanded, so a value set in code behaves like one a shell would have expanded, and a blank value counts as unset rather than as a directory named "". Separately, the resolution is cached for the life of the process, so changing the variable after the first chart silently did nothing. That is reasonable but was undocumented and had no way out. reload_font_awesome() clears it, and the documentation says when it is needed. Verified with fourteen hostile values -- empty, whitespace, nonexistent, a file rather than a directory, unreadable, relative, unexpanded tilde, non-ASCII, over-long, corrupt fonts, empty, partial, Font Awesome 4 layout, and valid. Every one now yields ImportError or ValueError, none escape. The 395-configuration rendering fingerprint is unchanged except for the single configuration whose bare KeyError became the explanatory ValueError. --- CHANGELOG.md | 1 + docs/font_awesome_integration.rst | 5 + pywaffle/__init__.py | 4 +- pywaffle/fontawesome_handler.py | 86 ++++++++++++-- pywaffle/waffle.py | 22 +++- tests/test_system_fontawesome.py | 179 +++++++++++++++++++++++++++++- 6 files changed, 274 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c58d2..b3dfadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Breaking New +* Add `pywaffle.reload_font_awesome()`, which forgets the resolved fonts so a changed `PYWAFFLE_FONTAWESOME_DIR` takes effect without restarting * Add `pywaffle.font_awesome_status()`, which reports which Font Awesome is in use, where it came from, its version, how many icons each style has, and whether aliases are available. It never raises: when Font Awesome cannot be found it reports every directory searched and how to install it * Font Awesome can now come from the system rather than the Python package. `PYWAFFLE_FONTAWESOME_DIR` points at a directory of `.otf` files, and the usual system font directories are searched as a fallback, so a distribution's font package works on its own. Distribution packages ship fonts without Font Awesome's `icons.json`, so in that case the icon names are recovered from the fonts themselves - every canonical name is available, though aliases are not ([#25](https://github.com/gyli/PyWaffle/issues/25)) diff --git a/docs/font_awesome_integration.rst b/docs/font_awesome_integration.rst index 846465c..c4c8a28 100644 --- a/docs/font_awesome_integration.rst +++ b/docs/font_awesome_integration.rst @@ -32,6 +32,11 @@ PyWaffle can take its fonts from three places, so it can tell you which one it s It never raises. When Font Awesome cannot be found it reports every directory that was searched and how to install it, which is the case where knowing what PyWaffle looked at matters most. +The fonts are resolved once and cached for the life of the process, so changing +:code:`PYWAFFLE_FONTAWESOME_DIR` after a chart has been drawn has no effect until you call +:code:`pywaffle.reload_font_awesome()`. That mostly matters in a notebook, where the process +outlives the experiment. + The returned :code:`FontAwesomeStatus` also carries the same information as attributes -- :code:`available`, :code:`source`, :code:`directory`, :code:`version`, :code:`fonts`, :code:`icon_counts`, :code:`aliases_available` and :code:`problem` -- for checking in code. diff --git a/pywaffle/__init__.py b/pywaffle/__init__.py index bb55f4d..d6e43a4 100644 --- a/pywaffle/__init__.py +++ b/pywaffle/__init__.py @@ -2,8 +2,8 @@ # -*-coding: utf-8 -*- from ._version import __version__ -from .fontawesome_handler import font_awesome_status +from .fontawesome_handler import font_awesome_status, reload_font_awesome from .functional import waffle_chart from .waffle import Waffle -__all__ = ["Waffle", "waffle_chart", "font_awesome_status", "__version__"] +__all__ = ["Waffle", "waffle_chart", "font_awesome_status", "reload_font_awesome", "__version__"] diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index aac21b7..7d2a61a 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -68,25 +68,44 @@ def _styles_in(directory: pathlib.Path) -> Dict[str, pathlib.Path]: Distributions keep the upstream file names -- "Font Awesome 6 Free-Solid-900.otf" and the like -- so the same suffix match works for a system directory as for the Python package. """ - if not directory.is_dir(): + try: + if not directory.is_dir(): + return {} + paths = sorted(directory.glob("*.otf")) + except OSError: + # An unreadable directory, a path too long for the filesystem, a broken symlink: all mean + # "no fonts here", and none of them should escape as an OSError from a chart call. return {} + return { style: path - for path in sorted(directory.glob("*.otf")) + for path in paths for style, font_suffix in FA_STYLES.items() if font_suffix.lower() in path.name.lower() } +def configured_font_directory() -> Optional[pathlib.Path]: + """The directory named by the environment variable, or None when it is not usefully set. + + Whitespace is stripped and ``~`` expanded, so a value set programmatically behaves the same as + one a shell would have expanded. An empty or blank value counts as unset. + """ + raw = os.environ.get(FONT_DIRECTORY_VARIABLE) + if raw is None or not raw.strip(): + return None + return pathlib.Path(os.path.expanduser(raw.strip())) + + def font_directory_candidates(): """Directories to search for Font Awesome, most specific first. An explicit setting wins, then the Python package, then the places distributions install it. Yields (path, is_package) so the caller can tell whether icons.json sits alongside. """ - override = os.environ.get(FONT_DIRECTORY_VARIABLE) - if override: - yield pathlib.Path(override), False + override = configured_font_directory() + if override is not None: + yield override, False try: yield fontawesome_package_path() / "otfs", True @@ -104,7 +123,7 @@ def font_file_finder() -> Dict[str, pathlib.Path]: Prefers an explicitly configured directory, then the fontawesomefree package, then the system font directories, so a distribution can supply the fonts without the Python package. """ - override = os.environ.get(FONT_DIRECTORY_VARIABLE) + override = configured_font_directory() searched = [] for directory, _ in font_directory_candidates(): @@ -114,8 +133,16 @@ def font_file_finder() -> Dict[str, pathlib.Path]: searched.append(str(directory)) # Falling back past an explicit setting would hide the fact that it did not work - if override and str(directory) == str(pathlib.Path(override)): - present = sorted(path.name for path in directory.glob("*.otf")) if directory.is_dir() else [] + if override is not None and directory == override: + try: + present = sorted(p.name for p in directory.glob("*.otf")) if directory.is_dir() else [] + except OSError as exc: + raise ImportError( + f"{FONT_DIRECTORY_VARIABLE} is set to {directory}, which cannot be read: {exc}.\n" + "Point it at a readable directory of Font Awesome .otf files, unset it to fall " + "back to the Python package and the system font directories, or install the " + "package:\n pip install 'pywaffle[icons]'" + ) from exc detail = ( "it contains no Font Awesome .otf files" if not present @@ -202,8 +229,18 @@ def _mapping_from_fonts() -> Dict[str, Dict[str, str]]: mapping: Dict[str, Dict[str, str]] = defaultdict(dict) for style, path in font_file_finder().items(): - face = FT2Font(str(path)) - for code_point, glyph_index in face.get_charmap().items(): + try: + face = FT2Font(str(path)) + charmap = face.get_charmap() + except Exception as exc: + # FreeType raises RuntimeError for anything it cannot parse. Name the file, since the + # user chose the directory it came from. + raise ValueError( + f"Could not read the Font Awesome {style} font at {path}: {exc}. " + "The file may be truncated or not a font." + ) from exc + + for code_point, glyph_index in charmap.items(): name = face.get_glyph_name(glyph_index) if name: mapping[style].setdefault(name, chr(code_point)) @@ -301,9 +338,26 @@ def __str__(self) -> str: return "\n".join(lines) +def reload_font_awesome() -> None: + """Forget which fonts were resolved, so they are looked up again on next use. + + The fonts and the icon mapping are resolved once and cached for the life of the process, so + changing PYWAFFLE_FONTAWESOME_DIR after a chart has been drawn has no effect until this is + called. Mostly useful in a notebook, where the process outlives the experiment. + """ + font_file_finder.cache_clear() + icon_mapping_builder.cache_clear() + _legend_handlers.cache_clear() + for name in _LAZY: + globals().pop(name, None) + + def font_awesome_status() -> FontAwesomeStatus: """Report which Font Awesome is in use, so it is never a guess. + Reflects what is currently resolved. The fonts are cached for the life of the process, so if + PYWAFFLE_FONTAWESOME_DIR has changed since the first chart, call reload_font_awesome() first. + Never raises. When Font Awesome cannot be found it reports why, which is the case where knowing what PyWaffle looked at matters most. @@ -311,6 +365,16 @@ def font_awesome_status() -> FontAwesomeStatus: >>> print(font_awesome_status()) """ override = os.environ.get(FONT_DIRECTORY_VARIABLE) + source = f"{FONT_DIRECTORY_VARIABLE}={override}" if override else "not found" + try: + return _describe_font_awesome() + except Exception as exc: # noqa: BLE001 - a diagnostic that raises is no diagnostic + return FontAwesomeStatus(available=False, source=source, problem=f"{type(exc).__name__}: {exc}") + + +def _describe_font_awesome() -> FontAwesomeStatus: + """Gather the report. Wrapped by font_awesome_status, which turns any failure into a report.""" + override = configured_font_directory() try: fonts = font_file_finder() except ImportError as exc: @@ -318,7 +382,7 @@ def font_awesome_status() -> FontAwesomeStatus: return FontAwesomeStatus(available=False, source=source, problem=str(exc)) directory = next(iter(fonts.values())).parent - if override and directory == pathlib.Path(override): + if override is not None and directory == override: source = f"{FONT_DIRECTORY_VARIABLE}={override}" elif any(directory == candidate for candidate, is_package in font_directory_candidates() if is_package): source = "fontawesomefree package" diff --git a/pywaffle/waffle.py b/pywaffle/waffle.py index 189ac71..3d68945 100644 --- a/pywaffle/waffle.py +++ b/pywaffle/waffle.py @@ -992,10 +992,20 @@ def _resolve_icon(icons: Dict, name: str, style: str) -> str: KeyError leaves the user unable to tell a typo from an icon that was renamed, moved between styles, or added after their version. """ - try: - return icons[style][name] - except KeyError: - pass + available = icons.get(style) + if available is not None and name in available: + return available[name] + + if available is None: + # The installed Font Awesome does not provide this style at all. Distributions split + # the styles across packages -- Fedora ships free and brands separately -- so having + # some but not others is normal rather than exotic. + present = ", ".join(repr(s) for s in sorted(icons)) or "none" + raise ValueError( + f"Font Awesome style {style!r} is not available. The fonts found provide: {present}. " + "Install the missing style, or see pywaffle.font_awesome_status() for which fonts " + "are in use and where they came from." + ) elsewhere = sorted(other for other in icons if other != style and name in icons[other]) if elsewhere: @@ -1006,11 +1016,11 @@ def _resolve_icon(icons: Dict, name: str, style: str) -> str: import difflib - close = difflib.get_close_matches(name, icons[style], n=3, cutoff=0.7) + close = difflib.get_close_matches(name, available, n=3, cutoff=0.7) suggestion = f" Did you mean {', '.join(repr(c) for c in close)}?" if close else "" raise ValueError( f"Icon {name!r} was not found in the {style!r} style of the installed Font Awesome, " - f"which has {len(icons[style]):,} {style} icons.{suggestion} " + f"which has {len(available):,} {style} icons.{suggestion} " "Names change between Font Awesome versions; check the icon exists in the version you " "have installed." ) diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py index f10371e..e30f7bc 100644 --- a/tests/test_system_fontawesome.py +++ b/tests/test_system_fontawesome.py @@ -28,10 +28,7 @@ def reset_caches(): """Clear the resolved fonts and mapping, which are cached for the life of the process.""" - handler.font_file_finder.cache_clear() - handler.icon_mapping_builder.cache_clear() - for name in handler._LAZY: - handler.__dict__.pop(name, None) + handler.reload_font_awesome() @unittest.skipIf(not HAS_FONT_AWESOME, "needs a Font Awesome to copy into a fake system directory") @@ -321,3 +318,177 @@ def test_it_is_exported_from_the_package(self): self.assertIn("font_awesome_status", pywaffle.__all__) self.assertIs(pywaffle.font_awesome_status, handler.font_awesome_status) + + +class TestHostileFontDirectories(unittest.TestCase): + """PYWAFFLE_FONTAWESOME_DIR is user input, so nothing put in it may escape as a raw error. + + Every failure here should be ImportError or ValueError -- the two the rest of the package + raises -- never OSError, RuntimeError or KeyError from somewhere deeper. + """ + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + plt.close("all") + + def _draw(self): + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="star") + + def test_a_path_too_long_for_the_filesystem(self): + """os.listdir raises OSError for these; it must not reach the caller.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = "/tmp/" + "a" * 300 + with self.assertRaises(ImportError): + self._draw() + + def test_a_file_where_a_directory_was_expected(self): + """Pointing at a font file rather than its directory is an easy mistake.""" + target = self._tmp / "afile" + target.write_text("x") + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(target) + with self.assertRaises(ImportError): + self._draw() + + def test_a_blank_value_counts_as_unset(self): + """An empty or whitespace variable should not be treated as a directory named ''.""" + for blank in ("", " ", "\t"): + with self.subTest(value=repr(blank)): + os.environ[handler.FONT_DIRECTORY_VARIABLE] = blank + reset_caches() + self.assertIsNone(handler.configured_font_directory()) + + def test_a_tilde_is_expanded(self): + """A value set in code has not been through a shell.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = "~/some-fonts" + self.assertEqual(handler.configured_font_directory(), pathlib.Path(os.path.expanduser("~/some-fonts"))) + + def test_surrounding_whitespace_is_stripped(self): + """Copy-pasted values often carry a trailing newline or space.""" + os.environ[handler.FONT_DIRECTORY_VARIABLE] = f" {self._tmp} " + self.assertEqual(handler.configured_font_directory(), self._tmp) + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs a real font to sit beside the corrupt one") + def test_a_file_that_is_not_really_a_font(self): + """FreeType raises RuntimeError, which says nothing about which file was at fault.""" + for path in handler.font_file_finder().values(): + shutil.copy(path, self._tmp) + # Truncate one of them, keeping the name that makes it a recognised style + solid = next(p for p in self._tmp.glob("*.otf") if "Solid" in p.name) + solid.write_bytes(b"not a font") + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + reset_caches() + + with self.assertRaises(ValueError) as caught: + handler._mapping_from_fonts() + self.assertIn(solid.name, str(caught.exception)) + + def test_status_never_raises_whatever_the_variable_holds(self): + """It is the tool people reach for when something is wrong.""" + for value in ("", " ", "/tmp/" + "a" * 300, str(self._tmp), "~/nope", "relative/path"): + with self.subTest(value=value[:30]): + os.environ[handler.FONT_DIRECTORY_VARIABLE] = value + reset_caches() + status = handler.font_awesome_status() + self.assertIsInstance(status.available, bool) + self.assertIsInstance(str(status), str) + + +@unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome to build a partial set from") +class TestPartialFontSet(unittest.TestCase): + """Distributions split the styles across packages, so having only some is normal. + + Fedora ships fontawesome-6-free-fonts and fontawesome-6-brands-fonts separately. + """ + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + solid = next(p for p in handler.font_file_finder().values() if "Solid" in p.name) + shutil.copy(solid, self._tmp) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + plt.close("all") + + def test_the_available_style_still_works(self): + """A partial set is not a broken set.""" + fig = plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="star") + self.assertEqual(len(fig.axes[0].texts), 10) + + def test_a_missing_style_says_which_styles_exist(self): + """This used to be a bare KeyError naming only the style.""" + with self.assertRaises(ValueError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="bluesky", icon_style="brands") + message = str(caught.exception) + self.assertIn("'brands' is not available", message) + self.assertIn("'solid'", message) + self.assertIn("font_awesome_status", message) + + def test_a_missing_style_with_an_unknown_icon_also_explains_itself(self): + """The style check has to come first, or the icon lookup raises KeyError on the style.""" + with self.assertRaises(ValueError) as caught: + plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="nope", icon_style="regular") + self.assertIn("not available", str(caught.exception)) + + +class TestReloading(unittest.TestCase): + """The fonts are resolved once, so changing the variable later needs an explicit reload.""" + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + plt.close("all") + + def test_it_is_exported(self): + """Someone hitting the caching needs to be able to find the way out.""" + import pywaffle + + self.assertIn("reload_font_awesome", pywaffle.__all__) + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome installed") + def test_a_reload_picks_up_a_changed_directory(self): + """Setting the variable in a notebook after drawing once should be recoverable.""" + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + reset_caches() + self.assertEqual(handler.font_awesome_status().source, "fontawesomefree package") + + for path in handler.font_file_finder().values(): + shutil.copy(path, self._tmp) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + + # Still the package: the resolution is cached + self.assertEqual(handler.font_awesome_status().source, "fontawesomefree package") + + handler.reload_font_awesome() + self.assertIn(str(self._tmp), handler.font_awesome_status().source) + + def test_reloading_with_nothing_resolved_yet_is_harmless(self): + """It should be safe to call at any time, including before the first chart.""" + handler.reload_font_awesome() + handler.reload_font_awesome() From e21a150d4a7a39fce23d4d679152a4f784c371a5 Mon Sep 17 00:00:00 2001 From: Guangyang Li Date: Wed, 16 Sep 2026 15:47:40 -0400 Subject: [PATCH 7/7] Look in the right places on macOS and Windows, and match .OTF as well as .otf Checking the behaviour rather than only the error handling turned up two ways this worked on Linux and quietly did nothing elsewhere. The system font directories were Linux paths only. On macOS not one of the seven exists, while the three places macOS actually keeps fonts were absent from the list, and Homebrew casks install into one of them. Windows had no entry at all. The list is chosen per platform now: Library/Fonts and the Homebrew prefixes on macOS, the system and per-user Fonts directories on Windows, and on Linux the previous set plus /usr/share/fonts itself and the two per-user locations. The consequence of the old list was not an error. Discovery simply found nothing and reported that Font Awesome was not installed, which is indistinguishable from it genuinely not being installed. Separately, glob("*.otf") is case sensitive whatever the filesystem, so a file named .OTF was invisible on every platform, not only on case-sensitive ones. I had assumed macOS would match it and checked: it does not. Matching is by suffix now, lowercased. The tests assert the expected behaviour per platform rather than just the absence of errors: that the list contains somewhere this operating system could plausibly keep fonts, that every entry is absolute, that a directory of unrelated fonts is not mistaken for Font Awesome, and that both .otf and .OTF resolve and draw. They run on all three platforms in the existing matrix. --- docs/font_awesome_integration.rst | 20 ++++--- pywaffle/fontawesome_handler.py | 66 ++++++++++++++++++----- tests/test_system_fontawesome.py | 90 +++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 20 deletions(-) diff --git a/docs/font_awesome_integration.rst b/docs/font_awesome_integration.rst index c4c8a28..9edfda6 100644 --- a/docs/font_awesome_integration.rst +++ b/docs/font_awesome_integration.rst @@ -63,12 +63,20 @@ instead:: $ export PYWAFFLE_FONTAWESOME_DIR=/usr/share/fonts/fontawesome -If neither the environment variable nor the Python package provides the fonts, the usual system -font directories are searched, so a distribution's font package is often enough on its own: - -* Fedora, ``fontawesome-6-free-fonts`` and ``fontawesome-6-brands-fonts`` -* Arch, ``otf-font-awesome`` in :code:`/usr/share/fonts/OTF` -* Debian and Ubuntu, ``fonts-font-awesome`` +If neither the environment variable nor the Python package provides the fonts, the font +directories of the platform you are on are searched, so an operating system font package is often +enough on its own: + +* Linux: :code:`/usr/share/fonts` and its Font Awesome subdirectories, plus + :code:`~/.local/share/fonts` and :code:`~/.fonts`. Fedora packages + ``fontawesome-6-free-fonts`` and ``fontawesome-6-brands-fonts``; Arch ``otf-font-awesome``; + Debian and Ubuntu ``fonts-font-awesome``. +* macOS: :code:`~/Library/Fonts`, where Homebrew casks install, plus :code:`/Library/Fonts` and + :code:`/System/Library/Fonts`. +* Windows: the system :code:`Fonts` directory, plus the per-user one under + :code:`%LOCALAPPDATA%`. + +File names are matched case insensitively, so :code:`.OTF` works as well as :code:`.otf`. Distribution packages ship the fonts without Font Awesome's ``icons.json``, so in that case the icon names are recovered from the fonts themselves -- Font Awesome stores each icon's name as its diff --git a/pywaffle/fontawesome_handler.py b/pywaffle/fontawesome_handler.py index 7d2a61a..b69eb3d 100644 --- a/pywaffle/fontawesome_handler.py +++ b/pywaffle/fontawesome_handler.py @@ -5,10 +5,11 @@ import json import os import pathlib +import sys from dataclasses import dataclass from functools import lru_cache from collections import defaultdict -from typing import Dict, Optional +from typing import Dict, Optional, Tuple import matplotlib.font_manager as fm from matplotlib.legend_handler import HandlerBase @@ -34,17 +35,48 @@ #: fontawesomefree package. Set it to use a system-provided Font Awesome. FONT_DIRECTORY_VARIABLE = "PYWAFFLE_FONTAWESOME_DIR" -#: Where distributions put Font Awesome. Searched only when the environment variable is unset and -#: the fontawesomefree package is not installed. -SYSTEM_FONT_DIRECTORIES = ( - "/usr/share/fonts/fontawesome", # Fedora, fontawesome-fonts - "/usr/share/fonts/OTF", # Arch, otf-font-awesome - "/usr/share/fonts/opentype/font-awesome", # Debian and Ubuntu - "/usr/share/fonts/truetype/font-awesome", - "/usr/local/share/fonts", # manual installs - "/opt/homebrew/share/fonts", # Homebrew on Apple silicon - "/usr/local/share/fonts/otf", -) + +def _system_font_directories() -> Tuple[str, ...]: + """Where this platform keeps fonts, searched when nothing else supplies them. + + Listing only the Linux paths would make the fallback silently useless on macOS and Windows, + where none of them exist. + """ + home = pathlib.Path.home() + + if sys.platform == "darwin": + return ( + str(home / "Library/Fonts"), # where Homebrew casks install + "/Library/Fonts", + "/System/Library/Fonts", + "/opt/homebrew/share/fonts", # Homebrew on Apple silicon + "/usr/local/share/fonts", # Homebrew on Intel + ) + + if sys.platform == "win32": + local = os.environ.get("LOCALAPPDATA") + windir = os.environ.get("WINDIR", r"C:\Windows") + directories = [str(pathlib.Path(windir) / "Fonts")] + if local: + # Per-user font installs, the default since Windows 10 + directories.append(str(pathlib.Path(local) / "Microsoft/Windows/Fonts")) + return tuple(directories) + + return ( + "/usr/share/fonts/fontawesome", # Fedora, fontawesome-fonts + "/usr/share/fonts/OTF", # Arch, otf-font-awesome + "/usr/share/fonts/opentype/font-awesome", # Debian and Ubuntu + "/usr/share/fonts/truetype/font-awesome", + "/usr/share/fonts", # the parent, for layouts not listed above + str(home / ".local/share/fonts"), # per-user installs + str(home / ".fonts"), # the older per-user location + "/usr/local/share/fonts", + ) + + +#: Where this platform keeps fonts. Searched only when the environment variable is unset and the +#: fontawesomefree package is not installed. +SYSTEM_FONT_DIRECTORIES = _system_font_directories() def fontawesome_package_path() -> pathlib.Path: @@ -71,7 +103,9 @@ def _styles_in(directory: pathlib.Path) -> Dict[str, pathlib.Path]: try: if not directory.is_dir(): return {} - paths = sorted(directory.glob("*.otf")) + # glob("*.otf") is case sensitive whatever the filesystem, so an .OTF file would be + # invisible on every platform. Filter by suffix instead. + paths = sorted(p for p in directory.iterdir() if p.suffix.lower() == ".otf") except OSError: # An unreadable directory, a path too long for the filesystem, a broken symlink: all mean # "no fonts here", and none of them should escape as an OSError from a chart call. @@ -135,7 +169,11 @@ def font_file_finder() -> Dict[str, pathlib.Path]: # Falling back past an explicit setting would hide the fact that it did not work if override is not None and directory == override: try: - present = sorted(p.name for p in directory.glob("*.otf")) if directory.is_dir() else [] + present = ( + sorted(p.name for p in directory.iterdir() if p.suffix.lower() == ".otf") + if directory.is_dir() + else [] + ) except OSError as exc: raise ImportError( f"{FONT_DIRECTORY_VARIABLE} is set to {directory}, which cannot be read: {exc}.\n" diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py index e30f7bc..7b4664b 100644 --- a/tests/test_system_fontawesome.py +++ b/tests/test_system_fontawesome.py @@ -11,6 +11,7 @@ import os import pathlib import shutil +import sys import tempfile import unittest @@ -492,3 +493,92 @@ def test_reloading_with_nothing_resolved_yet_is_harmless(self): """It should be safe to call at any time, including before the first chart.""" handler.reload_font_awesome() handler.reload_font_awesome() + + +class TestPlatformBehaviour(unittest.TestCase): + """The search list has to be meaningful on the platform actually running. + + A Linux-only list makes the fallback silently useless on macOS and Windows, which looks + identical to Font Awesome not being installed. + """ + + def test_the_directories_suit_this_platform(self): + """Every entry should be somewhere this operating system could plausibly keep fonts.""" + directories = handler.SYSTEM_FONT_DIRECTORIES + self.assertTrue(directories, "no system font directories for this platform") + + if sys.platform == "darwin": + expected = "Library/Fonts" + elif sys.platform == "win32": + expected = "Fonts" + else: + expected = "/usr/share/fonts" + self.assertTrue( + any(expected in d for d in directories), + f"no {expected!r} entry for {sys.platform}: {directories}", + ) + + @unittest.skipIf(sys.platform not in ("darwin", "win32"), "checks the non-Linux platforms") + def test_linux_only_paths_are_not_the_whole_list(self): + """The regression this guards: shipping only /usr/share/fonts everywhere.""" + non_linux = [d for d in handler.SYSTEM_FONT_DIRECTORIES if not d.startswith("/usr/share/fonts")] + self.assertTrue(non_linux, "every entry is a Linux path on a non-Linux platform") + + def test_the_entries_are_absolute(self): + """A relative entry would resolve against the working directory, which is nobody's fonts.""" + for directory in handler.SYSTEM_FONT_DIRECTORIES: + with self.subTest(directory=directory): + self.assertTrue(pathlib.Path(directory).is_absolute()) + + def test_discovery_survives_directories_that_do_not_exist(self): + """Most of the list will be absent on any given machine, which is normal.""" + candidates = list(handler.font_directory_candidates()) + self.assertTrue(candidates) + # Must not raise merely from listing them + for directory, _ in candidates: + handler._styles_in(directory) + + +class TestFontFileNaming(unittest.TestCase): + """Font file names vary in case between distributions and manual installs.""" + + def setUp(self): + self._saved = os.environ.get(handler.FONT_DIRECTORY_VARIABLE) + self._tmp = pathlib.Path(tempfile.mkdtemp()) + reset_caches() + + def tearDown(self): + if self._saved is None: + os.environ.pop(handler.FONT_DIRECTORY_VARIABLE, None) + else: + os.environ[handler.FONT_DIRECTORY_VARIABLE] = self._saved + reset_caches() + shutil.rmtree(self._tmp, ignore_errors=True) + plt.close("all") + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome to copy") + def test_an_uppercase_extension_is_found(self): + """glob("*.otf") is case sensitive whatever the filesystem, so .OTF was invisible.""" + source = next(p for p in handler.font_file_finder().values() if "Solid" in p.name) + shutil.copy(source, self._tmp / (source.stem + ".OTF")) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + reset_caches() + + self.assertIn("solid", handler.font_file_finder()) + fig = plt.figure(FigureClass=Waffle, rows=5, values=[10], icons="star") + self.assertEqual(len(fig.axes[0].texts), 10) + + @unittest.skipIf(not HAS_FONT_AWESOME, "needs Font Awesome to copy") + def test_the_style_suffix_match_ignores_case(self): + """Some packagers lowercase the whole file name.""" + source = next(p for p in handler.font_file_finder().values() if "Solid" in p.name) + shutil.copy(source, self._tmp / source.name.lower()) + os.environ[handler.FONT_DIRECTORY_VARIABLE] = str(self._tmp) + reset_caches() + self.assertIn("solid", handler.font_file_finder()) + + def test_a_directory_of_unrelated_fonts_is_not_mistaken_for_font_awesome(self): + """System font directories hold hundreds of fonts; only Font Awesome should match.""" + for name in ("Arial.otf", "DejaVuSans.otf", "SomeOther-Regular-400.otf"): + (self._tmp / name).write_bytes(b"x") + self.assertEqual(handler._styles_in(self._tmp), {})