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 ff18b76..ec6ef2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,12 +13,21 @@ Fixes
* 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`
* Reject values that come to zero blocks when only one of `rows` and `columns` is given. The other dimension is derived from the block count, so it came out zero, the block size came out negative, and the figure had negative axis extents. Reachable from ordinary values, not just zeros: `rounding_rule='floor'` maps anything below 1 to zero blocks
+* 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
* Make `sort_values` case insensitive and reject unknown values, like every other string argument. `sort_values="DESC"` matched neither `True` nor `"desc"` and fell through to ascending order, the opposite of what was asked, with no error
+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 `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))
+
* 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)). The blank cells that `block_arranging_style='new-line'` pads a line with get no border, so a padded line still ends where its value ends
* 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/README.md b/README.md
index b48b289..6783fc9 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..9edfda6 100644
--- a/docs/font_awesome_integration.rst
+++ b/docs/font_awesome_integration.rst
@@ -1,11 +1,95 @@
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``.
+
+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 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.
+
+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
+---------------------------
+
+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 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
+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/docs/installation.rst b/docs/installation.rst
index 71f7564..63ab5fe 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -10,7 +10,19 @@ The last stable release is available on PyPI and can be installed with ``pip``::
* Python 3.9+
* Matplotlib 3.6+
-* `fontawesomefree `_, installed automatically, which
- provides the icons. See :doc:`font_awesome_integration`.
-All of these are installed by ``pip`` along with PyWaffle.
+Matplotlib is installed automatically with PyWaffle.
+
+.. 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.
+
+A copy of Font Awesome already installed on the system can be used instead of the Python package.
+See :doc:`font_awesome_integration`.
diff --git a/pyproject.toml b/pyproject.toml
index 3be0018..3558ae0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,9 +28,10 @@ keywords = [
# in place of the deprecated set_tight_layout. Without the floor, installing into an
# environment that already pins an older matplotlib leaves the pin in place and every figure
# then raises AttributeError.
-# 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>=3.6", "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>=3.6"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
diff --git a/pywaffle/__init__.py b/pywaffle/__init__.py
index f64410b..d6e43a4 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, reload_font_awesome
from .functional import waffle_chart
from .waffle import Waffle
-__all__ = ["Waffle", "waffle_chart", "__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 bf595f8..b69eb3d 100644
--- a/pywaffle/fontawesome_handler.py
+++ b/pywaffle/fontawesome_handler.py
@@ -3,9 +3,13 @@
import inspect
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
+from typing import Dict, Optional, Tuple
import matplotlib.font_manager as fm
from matplotlib.legend_handler import HandlerBase
@@ -18,35 +22,213 @@
}
+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"
+)
+
+
+#: 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"
+
+
+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:
- """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"
-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.
+ """
+ try:
+ if not directory.is_dir():
+ return {}
+ # 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.
+ return {}
+
return {
style: path
- for path in font_otf_path
+ for path in paths
for style, font_suffix in FA_STYLES.items()
if font_suffix.lower() in path.name.lower()
}
-def icon_mapping_builder() -> Dict[str, Dict[str, str]]:
+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.
"""
- Build the icon name to Unicode character mapping from the metadata shipped with the installed
- fontawesomefree package.
+ 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()))
- 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.
+
+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.
"""
- icons_json_path = fontawesome_package_path() / "metadata" / "icons.json"
- with open(icons_json_path, "r") as f:
+ override = configured_font_directory()
+ if override is not None:
+ yield override, False
+
+ try:
+ yield fontawesome_package_path() / "otfs", True
+ except ImportError:
+ pass
+
+ for directory in SYSTEM_FONT_DIRECTORIES:
+ yield pathlib.Path(directory), False
+
+
+@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.
+ """
+ override = configured_font_directory()
+
+ searched = []
+ for directory, _ in font_directory_candidates():
+ found = _styles_in(directory)
+ if found:
+ return found
+ searched.append(str(directory))
+
+ # 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.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"
+ "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
+ else "the .otf files there are not recognised: " + ", ".join(present)
+ )
+ raise ImportError(
+ f"{FONT_DIRECTORY_VARIABLE} is set to {directory}, but {detail}.\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.\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(
+ 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.
+ """
+ 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)
@@ -66,6 +248,57 @@ 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():
+ 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))
+ 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."""
@@ -113,8 +346,136 @@ 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()
+@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 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.
+
+ >>> from pywaffle import font_awesome_status
+ >>> 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:
+ 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 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"
+ 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."""
+ 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/pywaffle/waffle.py b/pywaffle/waffle.py
index f2fe92b..83ce12b 100644
--- a/pywaffle/waffle.py
+++ b/pywaffle/waffle.py
@@ -1006,7 +1006,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))
@@ -1026,6 +1027,47 @@ 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.
+ """
+ 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:
+ 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, 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(available):,} {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/requirements.txt b/requirements.txt
index 2b3c0dc..da8aade 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>=3.6
-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..ff17fca
--- /dev/null
+++ b/tests/test_optional_fontawesome.py
@@ -0,0 +1,146 @@
+#!/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 pathlib
+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_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")
+ message = str(caught.exception)
+ self.assertIn("PYWAFFLE_FONTAWESOME_DIR", 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."""
+ 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()
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index 96068fb..ffda213 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -150,23 +150,17 @@ def test_an_alias_never_shadows_a_canonical_name(self):
import json
import tempfile
from pathlib import Path
- from unittest import mock
- from pywaffle.fontawesome_handler import icon_mapping_builder
+ from pywaffle.fontawesome_handler import _mapping_from_metadata
metadata = {
"a": {"unicode": "f001", "styles": ["solid"], "aliases": {"names": ["b", "c"]}},
"b": {"unicode": "f002", "styles": ["solid"]},
}
with tempfile.TemporaryDirectory() as directory:
- package = Path(directory)
- (package / "metadata").mkdir()
- (package / "metadata" / "icons.json").write_text(json.dumps(metadata))
- with mock.patch(
- "pywaffle.fontawesome_handler.fontawesome_package_path",
- return_value=package,
- ):
- mapping = icon_mapping_builder()
+ icons_json = Path(directory) / "icons.json"
+ icons_json.write_text(json.dumps(metadata))
+ mapping = _mapping_from_metadata(icons_json)
# The real icon keeps its name, rather than being replaced by the other icon's alias
self.assertEqual(mapping["solid"]["b"], chr(0xF002))
diff --git a/tests/test_system_fontawesome.py b/tests/test_system_fontawesome.py
new file mode 100644
index 0000000..7b4664b
--- /dev/null
+++ b/tests/test_system_fontawesome.py
@@ -0,0 +1,584 @@
+#!/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 sys
+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.reload_font_awesome()
+
+
+@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.font_dir = pathlib.Path(tempfile.mkdtemp())
+ reset_caches()
+ for path in handler.font_file_finder().values():
+ shutil.copy(path, cls.font_dir)
+ reset_caches()
+
+ @classmethod
+ def tearDownClass(cls):
+ # 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)
+ 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(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."""
+ 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()
+
+
+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")
+
+
+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)
+
+
+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()
+
+
+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), {})