Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Allow FuncFormatter to take functions with only one field #17288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

story645
Copy link
Member

@story645 story645 commented May 1, 2020

PR Summary

Jumping off of #16715, the functions used to format ticks often only take the x value and position is left as none. This is also motivated by #17286, because optimally the cursor formatting should be the same as the tick formatting. This PR would hopefully simplify using FuncFormatter by letting users pass in a function that only takes an x value and not having to bother with a pos value they don't use. I also added tests for FuncFormatter

It's admittedly sorta hackier than I like but builtins like "{}".format can't be inspected. Also any advice on cleaning up the commits would be helpful.

I'll update examples, whats_new, and api_changes if there's consensus on this going in.

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way

@anntzer
Copy link
Contributor

anntzer commented May 1, 2020

You can parse the format string using https://docs.python.org/3/library/string.html#string.Formatter.parse.

@story645
Copy link
Member Author

story645 commented May 1, 2020

@anntzer um, not sure how to apply your suggestion. Is it for testing?
The problem I'm having is when the user passes in 1"{} hi!".format, inspect.signature("{} hi!".format)` throws an error so I can't use it.

@anntzer
Copy link
Contributor

anntzer commented May 1, 2020

My point is that by passing the user-provided string to Formatter.parse (and some additional machinery around it) you can determine how many/what arguments it takes.

self.func = func
if not isinstance(func, types.BuiltinFunctionType):
nargs = len(inspect.signature(func).parameters)
elif (func.__name__ == 'format'):
Copy link
Contributor

Choose a reason for hiding this comment

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

isinstance(getattr(func, "__self__", None), str) and getattr(func, "__name__", "") == "format" just to be safe...

Copy link
Member Author

@story645 story645 May 1, 2020

Choose a reason for hiding this comment

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

Little bit unclear here. Is the benefit that this will end up falling through to the warning if either case isn't met rather than triggering an error in the loop or if statement?

Copy link
Contributor

Choose a reason for hiding this comment

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

sometimes func won't have a name attribute, but will be otherwise fine, so you don't want to error in that case. sometimes some other object will have a method called format, in which case you don't want to go to this branch.

Copy link
Member Author

@story645 story645 May 3, 2020

Choose a reason for hiding this comment

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

but I only end up in this branch if it's a builtin, and I don't think there is any other builtin named format.
https://docs.python.org/3/library/functions.html
also I think functions always have names = even lambdas return as the name?

"and may not work as expected")

if nargs == 1:
self.func = lambda x, pos: func(x)
Copy link
Contributor

Choose a reason for hiding this comment

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

that won't be picklable; you need to store nargs as a private attribute and switch over it in __call__.

Copy link
Member Author

Choose a reason for hiding this comment

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

will do but slightly confused why - are there more tests I should be writing?

Copy link
Contributor

Choose a reason for hiding this comment

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

depends on how much work you want to do, heh.

Copy link
Member Author

Choose a reason for hiding this comment

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

🤷 if it's important will do, but would like to know why.

"""
def __init__(self, func):
self.func = func

if not isinstance(func, types.BuiltinFunctionType):
Copy link
Contributor

Choose a reason for hiding this comment

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

func is part of the public API, which means it can be changed at runtime. So you can't count on the number of arguments being the same when this is actually called. func probably needs to be changed into a property which recalculates the number of arguments in the setter.

"""
def __init__(self, func):
self.func = func

if not isinstance(func, types.BuiltinFunctionType):
self.nargs = len(inspect.signature(func).parameters)
elif (isinstance(getattr(func, "__self__"), str) and
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this needed at all? '{x}'.format(x='eggs', bar='spam') works fine, so you can just always pass both arguments.

Copy link
Member Author

@story645 story645 May 3, 2020

Choose a reason for hiding this comment

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

inspect.signature crashes on builtins, so I need to special case .format (used in lots of the polar examples, my guess is still floating in the wild too.) You're right that I probably don't need to count the arguments sent to format, but since FuncFormat only operates on two it's probably worth letting users know that. Should it be a warning instead?

self.nargs = 2
cbook._warn_external(f"{func.__name__} is not supported "
"and may not work as expected")
if self.nargs not in [1, 2]:
Copy link
Contributor

Choose a reason for hiding this comment

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

I think best practices are to always use a set for inclusion testing (in {1, 2}). The performance benefit isn't a big deal here, but again it is a good habit to follow.

Copy link
Member

Choose a reason for hiding this comment

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

This is micro-optimization on the nanosecond level, and for the present case not even a benefit

In [2]: %timeit a in [1, 2]
63 ns ± 9.71 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [3]: %timeit a in {1, 2}
73.4 ns ± 8.28 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [4]: %timeit a in (1, 2)
61.3 ns ± 7.24 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

I think the list is slightly more readable because it's used more often, and because sets and dicts use curly braces so you have to look a bit closer to realize this is a set.

@tacaswell
Copy link
Member

The coverage change in the test suite is due to azure not uploading artifacts on PRs.

@story645
Copy link
Member Author

story645 commented May 5, 2020

@tacaswell is there anything for me to do on this PR or does a fix need to go in?

@efiring
Copy link
Member

efiring commented May 7, 2020

I don't understand all this yet, but I can't help but think there must be a much simpler way to achieve the goal. Use 2 very simple classes, one being the present one, and the second being a single-argument version? Or add a single kwarg to the present constructor to signal 1 argument or 2? Regarding the concern that func can be changed at runtime, that has always been true, and it has always been the responsibility of the person making that change to substitute a new func with the same signature.

@story645
Copy link
Member Author

story645 commented May 7, 2020

Use 2 very simple classes, one being the present one, and the second being a single-argument version?

#16715 is a move to hide the machinery all-together. We could keep the existing system which is basically that the input to something like FuncFormatter is lambda x, _: "{}" but it's confusing to have to include an unused variable and that being documented kind of awkwardly was what motivated this PR in the first place.

I'm against having two classes that practically speaking do the same thing as I think that's going to just be "I have no idea which one to use ever". The complexity here is because Python doesn't support c-style overloading and I think the current usual approach to dealing with it is the switch like dispatch system that this PR is doing. I can agree that maybe this PR doesn't need the string.format parsing but also see how that could be useful to users.

Or add a single kwarg to the present constructor to signal 1 argument or 2?

I'm against the user telling us how many arguments are in the function as they're providing us that information in the function itself. I can maybe see making it an optional argument from an optimization point of view, but the expensive type checking doesn't happen in the __call__ method where optimization would be most useful.

Regarding the concern that func can be changed at runtime, that has always been true, and it has always been the responsibility of the person making that change to substitute a new func with the same signature.

But I think them substituting with a new signature so long as it was valid would have still worked as there was no check against it?

@efiring
Copy link
Member

efiring commented May 7, 2020

I'm not convinced; I would still argue we should be looking for simpler code that is good enough.

a SimpleFuncFormatter could be a trivial subclass of FuncFormatter.

Or one could put a try-except in the FuncFormatter.__call__. Try calling func with a single argument. If it fails, try with 2 arguments. The overhead would be negligible, especially in the normal case where only 1 argument is used.

@story645
Copy link
Member Author

story645 commented May 7, 2020

two objects
This PR was motivated by #16715 (automagic formatting) and my starting to hack on #17286 (xy_data _cursor) and realizing that the latter would mostly be a one argument affair but also that folks might want to reuse formatters & so both solutions would need switching logic. At the point the code needs to get repeated, I thought it cleaner to put it in one place.

While SimpleFuncFormatter might be a trivial class, education users to find it-much less use it, would be immensely more overhead than FuncFormatter suddenly just works a little bit easier.

try/catch
Try catch is less robust than argument counting because it can inherently also catch other types of errors and a robust try catch solution would end up probably being as many lines of code as the if based solution. And I'm not sure the optimization gains are worth it (I personally feel try/catch statements tend to obscure the control flow) because again the argument counting only happens on instantiation/setting and not within the __call__.

@jklymak
Copy link
Member

jklymak commented May 7, 2020

I've spent 10 minutes with this, and don't quite understand what it gets us. If it just makes it so the user doesn't have to define a pos parameter in their function, then I'm not convinced its worth it. Does it have some other benefit?

If it doesn't have other benefits, then I'd rather we just explained clearly what pos is, and then the user won't be bitter about having to type it.

@story645
Copy link
Member Author

story645 commented May 7, 2020

Yes the benefit is that the user doesn't have to type 'pos' but like even explaining 'pos' doesn't help the matter since technically the formatter doesn't need pos; the call signature is __call__(x, pos=None) but with the assumption that func always takes a minimum of two arguments. Which from a user perspective is - I have to pass in an argument I don't have to use? 😕

The side benefit is by explicitly checking the function signature we can flag and warn users if they're passing in a function that may not work the way they expect it to & we can write a clearer message for functions that straight up won't work (functions with more than two required arguments).

@efiring
Copy link
Member

efiring commented May 7, 2020

Granted, there are advantages in catching problems with the func at formatter instantiation, but I don't think those advantages are compelling.

def __call__(loc, pos=None):
    try:
        return self.func(loc)
    except TypeError:
        return self.func(loc, pos)

That's pretty simple, it adds only 3 lines to FuncFormatter, and I think it will do the job. I don't see that the try/except is obscuring anything. Do we really need validation of func? I don't think so. Using FuncFormatter directly is a semi-expert operation, and the suggestion above makes it considerably easier to use. It's not a foot-cannon.

@story645
Copy link
Member Author

story645 commented May 7, 2020

Granted, there are advantages in catching problems with the func at formatter instantiation, but I don't think those advantages are compelling.

There have been a bunch of PRs lately where we've been giving people clearer/more instructive error messages and warnings. Catching at instantiation would be in-line with that.

ETA: Also, if it's in the __call__ block, the function needs to be tried on every call rather than only on setting so I don't think it ends up more efficient than a value comparison.

@efiring
Copy link
Member

efiring commented May 7, 2020 via email

@story645
Copy link
Member Author

story645 commented May 8, 2020

FuncFormatter as I suggest makes it very simple and straightforward to
understand and to use.

FuncFormatter has the exact same public API regardless of which approach we go with.

@efiring
Copy link
Member

efiring commented May 8, 2020

Agreed, and I favor the approach with simpler code. But maybe I am missing something. Let's discuss it on the Monday call.

@story645
Copy link
Member Author

story645 commented May 8, 2020

Agree on call on Monday, but the problem with the try catch block is that code like this which is kind of reasonable for folks to write by mistake will get caught by the except:

def myformat(x):
    return f"{x}"+x

So instead of seeing this message:

TypeError                                 Traceback (most recent call last)
<ipython-input-40-10c8bb7d6128> in <module>
----> 1 myformat(1)

<ipython-input-39-00008efbf479> in myformat(x)
      1 def myformat(x):
----> 2     return f"{x}"+x

TypeError: can only concatenate str (not "int") to str

users will get this:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-41-fed007dcbe0d> in <module>
      1 try:
----> 2     myformat(1)
      3 except TypeError:

<ipython-input-39-00008efbf479> in myformat(x)
      1 def myformat(x):
----> 2     return f"{x}"+x

TypeError: can only concatenate str (not "int") to str

During handling of the above exception, another exception occurred:

TypeError                                 Traceback (most recent call last)
<ipython-input-41-fed007dcbe0d> in <module>
      2     myformat(1)
      3 except TypeError:
----> 4     myformat(1,None)
      5 

TypeError: myformat() takes 1 positional argument but 2 were given

which yes the first one is still there but we've introduced a second level of broken which I think will make debugging harder/more confusing.

@tacaswell
Copy link
Member

tacaswell commented May 11, 2020

Can we use this pattern in the property?

try:
    sig = inspect.signature(func)
except ValueError:
    self._nargs = 2
    pass    # built in, just keep going
else:
    try:
         sig.bind(None, None)
         self._nargs = 2
    except TypeError:
         try:
             sig.bind(None)
             self._nargs = 1
         except TypeError:
              raise
self._func = func

@tacaswell
Copy link
Member

We settled on

  • keeping the property
  • doing as early as possible error checking
  • dropping attempting to parse the format string

@QuLogic QuLogic modified the milestones: v3.4.0, v3.5.0 Jan 21, 2021
@jklymak jklymak marked this pull request as draft May 8, 2021 20:38
@QuLogic QuLogic modified the milestones: v3.5.0, v3.6.0 Aug 23, 2021
@timhoffm timhoffm modified the milestones: v3.6.0, unassigned May 1, 2022
@story645 story645 modified the milestones: unassigned, needs sorting Oct 6, 2022
@github-actions
Copy link

Since this Pull Request has not been updated in 60 days, it has been marked "inactive." This does not mean that it will be closed, though it may be moved to a "Draft" state. This helps maintainers prioritize their reviewing efforts. You can pick the PR back up anytime - please ping us if you need a review or guidance to move the PR forward! If you do not plan on continuing the work, please let us know so that we can either find someone to take the PR over, or close it.

@github-actions github-actions bot added the status: inactive Marked by the “Stale” Github Action label Jul 28, 2023
Comment on lines +404 to +422
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).")
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.

@github-actions github-actions bot removed the status: inactive Marked by the “Stale” Github Action label Jul 31, 2023
@github-actions
Copy link

github-actions bot commented Oct 2, 2023

Since this Pull Request has not been updated in 60 days, it has been marked "inactive." This does not mean that it will be closed, though it may be moved to a "Draft" state. This helps maintainers prioritize their reviewing efforts. You can pick the PR back up anytime - please ping us if you need a review or guidance to move the PR forward! If you do not plan on continuing the work, please let us know so that we can either find someone to take the PR over, or close it.

@github-actions github-actions bot added the status: inactive Marked by the “Stale” Github Action label Oct 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants