Find Font Awesome on the system, not only in the Python package - #60
Conversation
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.
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.
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.
…e 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.
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.
|
Added: users can now see which Font Awesome is in use, and no failure path is a dead end. Both from your feedback — fonts can come from three places, so which one is actually in use shouldn't be something a user has to deduce.
|
| situation | ends with |
|---|---|
| variable unset, nothing found anywhere | lists all 7 directories searched + pip install 'pywaffle[icons]' |
| variable set to an empty directory | names it, then how to fix or install the package |
| variable set to a Font Awesome 4 directory | names FontAwesome.otf, explains FA4 has no style split, then install |
208 tests, including that the status function never raises, that a failure still names the install command, and that it correctly distinguishes package / override / system as the source. Also verified in a real environment with Font Awesome uninstalled: 14 passed, 16 skipped, no failures.
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.
… 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.
Four textual conflicts, all with PRs merged into master since this branch was cut. pyproject.toml and requirements.txt, against #63 (dependency floors): keep both intents. matplotlib gets the >=3.6 floor from master, fontawesomefree moves to the "icons" extra as this branch intends. Master's note that "fontawesomefree stays a hard dependency for now" is dropped, since this branch is the change it was deferring. docs/installation.rst, against #66: take master's "Matplotlib 3.6+" and its pointer to the Font Awesome page, keep this branch's "Drawing with icons" section. Master's line saying the font package is "installed automatically" is dropped, because after this branch it is not. CHANGELOG.md: both sides appended an entry to the same list. Keep both. One conflict git could not see. #65 added a test that reached fontawesome_handler.fontawesome_package_path via mock to build a mapping from synthetic metadata. That merged cleanly and then failed, because this branch moved the two-pass alias build into _mapping_from_metadata(path). The test now calls that helper directly, which is both simpler and closer to what it is actually asserting. Verified on the merged result: 234 tests pass, black clean, sphinx emits only the five pre-existing warnings, the examples regenerate, and the built wheel records "Requires-Dist: matplotlib>=3.6" with fontawesomefree under 'extra == "icons"'.
Groundwork for #25. Stacked on #59 (making Font Awesome optional) — that had to come first, since a system copy is pointless while the Python package is a hard requirement.
The two problems
Distributions package Font Awesome as fonts, and they keep the upstream file names:
fontawesome-6-free-fonts,fontawesome-6-brands-fonts/usr/share/fonts/fontawesomeotf-font-awesome/usr/share/fonts/OTFfonts-font-awesome/usr/share/fonts/opentype/font-awesomeSo finding them is easy — the existing style matching already recognises
Font Awesome 6 Free-Solid-900.otfwherever it sits.PYWAFFLE_FONTAWESOME_DIRnames a directory explicitly; otherwise the package, then the system directories.Naming them was the real question. Distribution font packages do not ship Font Awesome's
icons.json, and that was the only source of the name→character mapping. This is exactly what I was going to ask @mattdm about.It turns out the fonts carry the names
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 — no
fonttools.What is lost, measured rather than assumed
Against the 1,959
solidentries inicons.json:Those 158 exist 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.
Verified end to end, not inferred
A chart drawn from a fonts-only directory with
fontawesomefreeuninstalled, against the same chart from the package:starresolves to U+2B50 from the fonts and U+F005 from the metadata, and the PNG is byte-identical.Behaviour
icons.jsonis still preferred when present, because it carries the aliases. The font-derived mapping is the fallback, and the documentation states both consequences plainly: aliases are unavailable (circle-half-strokeworks, its aliasadjustdoes not), and some icons resolve to a different code point for the same glyph.When nothing is found, the error lists every directory tried and names the environment variable, instead of only suggesting
pip install.#25 stays open
This removes the blocker but does not close the issue. What remains is for a distribution to actually package it that way, and for @mattdm to confirm the layout matches what Fedora ships. I will post these findings on the issue.
195 tests, including a case that builds a fonts-only directory, points the variable at it, and asserts no canonical name is lost and the rendering matches.