Skip to content
Draft
2 changes: 1 addition & 1 deletion lib/matplotlib/tests/test_polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def test_polar_units_2(fig_test, fig_ref):

ax = fig_ref.add_subplot(projection="polar")
ax.plot(np.deg2rad(xs), ys)
ax.xaxis.set_major_formatter(mpl.ticker.FuncFormatter("{:.12}".format))
ax.xaxis.set_major_formatter(mpl.ticker.StrMethodFormatter("{x:.12}"))
ax.set(xlabel="rad", ylabel="km")


Expand Down
25 changes: 25 additions & 0 deletions lib/matplotlib/tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,31 @@ def test_basic(self):
assert '00002' == tmp_form(2)


class TestFuncFormatter:
@pytest.mark.parametrize("func, args, expected",
[(lambda x: f"{x}!", [2], "2!"),
(lambda x, pos: f"{x}+{pos}!", [2, 3], "2+3!")])
def test_arguments(self, func, args, expected):
assert expected == mticker.FuncFormatter(func)(*args)

@pytest.mark.parametrize("func, args, expected",
[("{}!".format, [2], "2!"),
("{}+{}!".format, [2, 3], "2+3!")])
def test_builtins(self, func, args, expected):
with pytest.raises(UserWarning, match=r'not support format'):
assert expected == mticker.FuncFormatter(func)(*args)

def test_typerror(self):
with pytest.raises(TypeError, match=r'must take at most'):
mticker.FuncFormatter((lambda x, y, z: " "))

def test_update(self):
formatter = mticker.FuncFormatter(lambda x, pos: f"{x}+{pos}")
assert "1+2" == formatter(1, 2)
with pytest.raises(TypeError, match=r'must take at most'):
formatter.func = lambda x, pos, error: "!"


class TestStrMethodFormatter:
test_data = [
('{x:05d}', (2,), '00002'),
Expand Down
38 changes: 34 additions & 4 deletions lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"""

import itertools
import inspect
import logging
import locale
import math
Expand Down Expand Up @@ -377,9 +378,9 @@ class FuncFormatter(Formatter):
"""
Use a user-defined function for formatting.

The function should take in two inputs (a tick value ``x`` and a
position ``pos``), and return a string containing the corresponding
tick label.
The function can take in at most two inputs (a required tick value ``x``
and an optional position ``pos``), and must return a string containing
the corresponding tick label.
"""
def __init__(self, func):
self.func = func
Expand All @@ -390,7 +391,36 @@ def __call__(self, x, pos=None):

*x* and *pos* are passed through as-is.
"""
return self.func(x, pos)
if self._nargs == 1:
return self._func(x)
return self._func(x, pos)

@property
def func(self):
return self._func

@func.setter
def func(self, func):
try:
sig = inspect.signature(func)
except ValueError:
self._nargs = 2
cbook._warn_external("FuncFormatter may not support "
f"{func.__name__}. Please look at the "
"other formatters in `matplotlib.ticker`.")
else:
try:
sig.bind(None, None)
self._nargs = 2
except TypeError:
try:
sig.bind(None)
self._nargs = 1
except TypeError:
raise TypeError(f"{func.__name__} must take "
"at most 2 arguments: "
"x (required), pos (optional).")
Comment on lines +404 to +422
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm over-simplifying, but can we just try?

Suggested change
try:
sig = inspect.signature(func)
except ValueError:
self._nargs = 2
cbook._warn_external("FuncFormatter may not support "
f"{func.__name__}. Please look at the "
"other formatters in `matplotlib.ticker`.")
else:
try:
sig.bind(None, None)
self._nargs = 2
except TypeError:
try:
sig.bind(None)
self._nargs = 1
except TypeError:
raise TypeError(f"{func.__name__} must take "
"at most 2 arguments: "
"x (required), pos (optional).")
self._nargs = 2
# check whether func really suppors two args
try:
func(0, 0)
except TypeError as e:
# Only catch the specific error that a single-arg func would raise:
# TypeError: func() takes 1 positional argument but 2 were given
# every other error is not our business here and will raise on
# actual usage. Since the TypeError will be thrown before any
# internal errors in the function, this approach is a safe way to
# identify single-arg functions without introspection need.
if "1 positional argument" in str(e):
self._nargs = 1

The only risk I see is that python changes its exception message, but i) by testing only against "1 positional argument" were more resilent wrt minor changes, and ii) we should add a test that monitors the exception message thrown in such cases.

self._func = func


class FormatStrFormatter(Formatter):
Expand Down