Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 43 additions & 19 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()]`
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion binder/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion docs/examples/plot_with_characters_or_icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
90 changes: 87 additions & 3 deletions docs/font_awesome_integration.rst
Original file line number Diff line number Diff line change
@@ -1,11 +1,95 @@
Font Awesome Integration
========================

PyWaffle installs `Font Awesome
<https://fontawesome.com/>`_ 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
<https://fontawesome.com/>`_, packaged for Python as `fontawesomefree
<https://pypi.org/project/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
-------------------------------------

Expand Down
18 changes: 15 additions & 3 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/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 <https://fontawesome.com/>`_, 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`.
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion pywaffle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"]
Loading
Loading