Skip to content

Find Font Awesome on the system, not only in the Python package - #60

Merged
gyli merged 8 commits into
masterfrom
feat/system-fontawesome
Sep 17, 2026
Merged

gyli merged 8 commits into
masterfrom
feat/system-fontawesome

Conversation

@gyli

@gyli gyli commented Sep 16, 2026

Copy link
Copy Markdown
Owner

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:

distribution package path
Fedora fontawesome-6-free-fonts, fontawesome-6-brands-fonts /usr/share/fonts/fontawesome
Arch otf-font-awesome /usr/share/fonts/OTF
Debian/Ubuntu fonts-font-awesome /usr/share/fonts/opentype/font-awesome

So finding them is easy — the existing style matching already recognises Font Awesome 6 Free-Solid-900.otf wherever it sits. PYWAFFLE_FONTAWESOME_DIR names 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:

glyph names: ['.notdef', 'space', 'exclamation', 'hashtag', 'dollar-sign', ... ]
U+F005 -> glyph 'star'      U+F5E4 -> glyph 'car-side'

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 solid entries in icons.json:

count
names recovered from the font 1,398
absent — all of them aliases 564
canonical icon names missing 0
resolving to a different code point 158

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 fontawesomefree uninstalled, against the same chart from the package:

system fonts   icons.json: None   mapping: {solid: 1398, brands: 543, regular: 214}
               codepoints used: ['0x2b50', '0xf206', '0xf5e4']   digest: 4a09cd68e3464497

package        icons.json: True   mapping: {solid: 1959, brands: 527, regular: 257}
               codepoints used: ['0xf005', '0xf206', '0xf5e4']   digest: 4a09cd68e3464497

RENDERED OUTPUT IDENTICAL: True

star resolves to U+2B50 from the fonts and U+F005 from the metadata, and the PNG is byte-identical.

Behaviour

icons.json is 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-stroke works, its alias adjust does 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.

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

gyli commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

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.

pywaffle.font_awesome_status()

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

Pointed at a system directory instead, it says so — including that aliases are gone, which is the one behavioural difference:

Font Awesome 6
  source:    PYWAFFLE_FONTAWESOME_DIR=/usr/share/fonts/fontawesome
  aliases:   no, names read from the fonts
  styles:
    solid    1,398 icons  Font Awesome 6 Free-Solid-900.otf

It never raises. A diagnostic that only works when nothing is wrong is no use, so with Font Awesome absent it reports the problem rather than throwing. The same fields are attributes (available, source, directory, version, fonts, icon_counts, aliases_available, problem) for checking in code.

Version comes from package metadata when the package is the source, otherwise from the font's own family name — Font Awesome 6 Free yields the major version — so a system font still reports something.

Every failure now ends with the install command

You were right that this was missing. The PYWAFFLE_FONTAWESOME_DIR-is-invalid error explained what was wrong with the directory but never mentioned that installing the package is the simplest way out. All three paths now do:

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"'.
@gyli
gyli merged commit 2919b22 into master Sep 17, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant