-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Declare property aliases in a single place #9475
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
Conversation
0e2bfee
to
5a2abf0
Compare
5a2abf0
to
48c9d21
Compare
cc7b870
to
5d04619
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like nice cleanup, but I'd go with
def try_make_alias(local_d, old_name, new_name):
try:
old_func = local_d[old_name]
except KeyError:
return False
@functools.wraps(old_func) # sets __signature__ and __wrapped__
def method(*args, **kwargs):
return old_func(*args, **kwargs)
method.__name__ = new_name
method.__doc__ = "alias for `{}`".format(old_name) # backticks for a sphinx link
local_d[new_name] = method
return True
for prop, aliases in alias_d.items():
found_prop = any(
try_make_alias(local_d, prefix + prop, prefix + alias)
for prefix in ["get_", "set_"]
)
if not found_prop:
raise ValueError("property {} does not exist".format(prop))
lib/matplotlib/cbook/__init__.py
Outdated
exists = True | ||
for alias in aliases: | ||
method = make_alias(prefix + prop) | ||
method.__name__ = str(prefix + alias) # Py2 compat. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this line should be handled by make_alias(orig_name, alias_name)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd rather make make_alias not know what the name of the resulting function is. Really its only goal is to make a closure over the name (so I'm even going to move the __doc__
out, in fact).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should close over the value, not the name
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you explain what the py2 compat issue is? Seems that this doesn't matter for anything other than printing the __name__
, which py3 does too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
py2 does not want unicode strings as function names, which we do end up passing because of the unicode_literals future alias. There's a similar issue with the first argument to subprocess on Windows + Py2, again we would pass in unicode strings because of unicode_literals, so we have to wrap them in a call to str().
lib/matplotlib/cbook/__init__.py
Outdated
def method(self, *args, **kwargs): | ||
return getattr(self, name)(*args, **kwargs) | ||
method.__doc__ = "alias for {}".format(name) | ||
return method |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not do the getattr
call outside of method
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So that patching the base method gets propagated to the alias method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see. That makes sense to me now
lib/matplotlib/cbook/__init__.py
Outdated
method = make_alias(prefix + prop) | ||
method.__name__ = str(prefix + alias) # Py2 compat. | ||
local_d[prefix + alias] = method | ||
if not exists: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is more explicit than the any(...)
you propose.
lib/matplotlib/cbook/__init__.py
Outdated
for alias in aliases: | ||
method = make_alias(prefix + prop) | ||
method.__name__ = str(prefix + alias) # Py2 compat. | ||
method.__doc__ = "alias for `{}`".format(prefix + prop) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose setting __signature__
through functools.wraps
also does the wrong thing for overriden properties...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm mostly too lazy to add a dep on funcsigs for this PR (I actually do add such a dependency for the pyplot PR, so that'd also mean merge conflicts and sadness).
A novel idea for future: have a global |
Alright, here's the solution that preserves class AliasedMethod(object):
def __init__(self, base_name):
self.base_name = base_name
def __get__(self, instance, cls):
base_method = getattr(cls, self.base_name)
@functools.wraps(base_method)
def method(obj, *args, **kwargs):
return base_method(obj, *args, **kwargs)
method.__doc__ = "alias of `{}`".format(self.base_name)
return method.__get__(instance, cls) More expensive though since it constructs a function upon attribute lookup |
I think that's a reasonable option, but will let other reviewers chime in first. |
To be clear, I think what you have now is fine, and am just exploring alternatives out of curiosity :) |
In general I like this. The clutter-reduction is great, and I agree with favoring the singular forms. My only discomfort with the PR is that in ipython, the single-question-mark works perfectly but the double-question-mark returns the wrapper-method code (and no docstring) instead of the relevant code:
That's not a blocker, but it's not ideal, either. |
fwiw the relevant code in IPython is in IPython.core.oinspect (_info); specifically https://github.com/ipython/ipython/blob/3c53804581bd51347eca577950752e1eac1d5e9b/IPython/core/oinspect.py#L823: "Add docstring only if no source is to be shown (avoid repetitions)." @Carreau any hooks in IPython that we could rely on for this? thanks :-) |
Could this be done as a class decorator? I think that would be clearer than hacking on |
A class decorator will be slower, since it would invoke the descriptor protocol on each attribute lookup. (Rather than the direct dict lookup that is happening here). I suppose it could use |
I don't think it even needs to be a decorator - just a free function with signature |
Not that I can think of from the top of my head. This is some old code that would highly need refactor, and I am thinking about an ability to make that more configurable with a way for a package to provide a custom object->doc mapping for |
@tacaswell It could be a decorator or a free function but I think it would actually make things less clear. A decorator would either need to declare the aliases at the beginning of the class definition, before the properties themselves are defined (or read the alias table from a table defined in the body of the function, but that doesn't play well with inheritance). A free function would modify the class definition post-hoc, which is not a model of clarity either. @Carreau Perhaps the line at https://github.com/ipython/ipython/blob/3c53804581bd51347eca577950752e1eac1d5e9b/IPython/core/oinspect.py#L823 ("Add docstring only if no source is to be shown (avoid repetitions).") could try to parse the source tree and see whether the docstring is textually present in the source, and decide accordingly whether to display the docstring? This would be a fairly generic solution for dynamically generated functions. It sounds a bit scary but I don't think it's that hard to write (happy to push the item somewhere on my todo list if that sounds good to you). |
486aa0c
to
f789c2a
Compare
I have to think about it and like feedback from other dev about that. I don't see any drawbacks, so feel free to send a PR or open an issue. |
Works fine if you name the table Edit: Wait, in what way does this mess up inheritance? The decorator should only be applying to the top-level class anyway, shouldn't it? |
Good point that because the decorator is only applied once (which is actually what you want) there are no problems with inheritance. Still, per my above comment, I don't really like splitting the aliasing-relevant code into two places (one table in the body that lists the aliases and one decorator at the top). If there's a strong pushback against the current design I can revisit it, so let me know what you prefer. |
@efiring ipython/ipython#10966 should at least help for future IPython versions. |
@anntzer I don't understand your comments. I was imagining something like @alias_manager({'linewidth': ['lw', 'linewidths'], ...})
class FooBar:
... Everything is still in one place, it uses a language provided tools for mucking with class dictionaries between when they are defined and when the user gets the classes, and makes it very clear from looking at the class definition that we are doing so. That said, do we need to do something fancier to deal with the case where a sub-class overides the thing that is aliased so the aliases point back to the sub-classed version of method? |
Basically I didn't like having a huge decorator before the class itself is defined, but it's not a big deal; changed implementation accordingly. |
lib/matplotlib/cbook/__init__.py
Outdated
if not exists: | ||
raise ValueError("property {} does not exist".format(prop)) | ||
|
||
cls._alias_map = alias_d |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be tempted to have _define_aliases
read this directly, rather than include it in the decorator argument and have it set it anyway - that means the aliases come after the definitions too, and that it becomes more obvious where _alias_map
comes from when calling normalize_kwargs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really would prefer the aliases to stay close to the decorator, at least.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer it this way, to keep the decorator and the alias map close together.
Would it make sense to have the decorator also add a _normalize_kw
method as
def normalize_kvwargs(self, kw):
return normalize_kwargs(kw, self._alias_map)
?
Might also be better to check if the class already has an _alias_map
and if so recursively update it so that the decorator can be used on sub-classes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if so recursively update it
What you really want here is a collections.ChainMap
, but it's not there in python 2.7.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or maybe
@staticmethod
def _get_alias_map(self):
d = {}
for cls in self.mro():
for k, aliases in cls.__dict__.get('_alias_map',{}).items()
d.setdefault(k, []).extend(aliases)
edit: updated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think ChainMap helps here, ex
@_define_aliases({'linewidth': ['lw']})
class A:
...
@_define_aliases({'linewidth': ['linewidths', 'lws']})
class B(A):
...
You would want B._alias_map['linewidth'] == ['lw', 'linewidths', 'lws']
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Updated my other comment to reflect that update
doesn't work either
lib/matplotlib/cbook/__init__.py
Outdated
method.__doc__ = "alias for `{}`".format(prefix + prop) | ||
setattr(cls, prefix + alias, method) | ||
if not exists: | ||
raise ValueError("property {} does not exist".format(prop)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use a different word than 'property' here? They are not python @property
instances which may lead to confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about "getter or setter methods for {!r} do not exist"
?
edit: or "neither getter nor setter methods for {!r} exist"
"edgecolor": ["edgecolors"], | ||
"facecolor": ["facecolors"], | ||
"linestyle": ["linestyles", "dashes"], | ||
"linewidth": ["linewidths", "lw"], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not sets, instead of lists? Duplicates wouldn't make any sense here. (edit: because normalize_kwargs
requires it to be so)
Ping on this outdated comment on inheritance which I think still applies. Edit: Now resolved |
lib/matplotlib/cbook/__init__.py
Outdated
"Neither getter nor setter exists for {!r}".format(prop)) | ||
|
||
if hasattr(cls, "_alias_map"): | ||
raise NotImplementedError("Parent class already defines aliases") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A comment here along the lines of # supporting inheritance requires us to have a plan for conflicting aliases
would be good, so that someone doesn't just come along and add a naive implementation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
lib/matplotlib/cbook/__init__.py
Outdated
if cls is None: | ||
return functools.partial(_define_aliases, alias_d) | ||
|
||
def make_alias(name): # Enfore a closure over *name*. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typo enforce
?
btw @efiring the next IPython release will show the docstring in this case too. |
2941847
to
fb67c60
Compare
fb67c60
to
f6d8cbc
Compare
f6d8cbc
to
473590c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks to me like this has converged via discussion to the point where it should go in so we can see whether any real-world problems turn up.
The consolidation of the alias declarations is a big gain.
bump. |
OK, seems reasonable - are many of the aliases tested? I guess the common ones are. |
Well, looks like codecov is happy for once :-) |
🎉 I am happy this made it in! |
PR Summary
Define property aliases in a more systematic manner. Note that there were some "funny" aliases that I needed to work around, e.g. Text.set_name is an alias for Text.set_family but Text.get_name is different from Text.get_family.
Also use the aliases for kwarg normalization.
For collections, prefer normalizing get_facecolors, etc. to the singular form, as this is more consistent with the Artist interface.
This will add some setters/getters that were not defined before, e.g. there was a Patch.set_aa (set_antialiased) but no Patch.get_aa; this PR adds the missing method.
PR Checklist