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

Skip to content

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

Merged
merged 7 commits into from
Mar 15, 2018

Conversation

anntzer
Copy link
Contributor

@anntzer anntzer commented Oct 19, 2017

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

  • Has Pytest style unit tests... in the sense that tests still pass.
  • Code is PEP 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 anntzer force-pushed the unify-aliases branch 2 times, most recently from cc7b870 to 5d04619 Compare December 1, 2017 19:26
Copy link
Contributor

@eric-wieser eric-wieser left a 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))

exists = True
for alias in aliases:
method = make_alias(prefix + prop)
method.__name__ = str(prefix + alias) # Py2 compat.
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 this line should be handled by make_alias(orig_name, alias_name)

Copy link
Contributor Author

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

Copy link
Contributor

@eric-wieser eric-wieser Dec 15, 2017

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

Copy link
Contributor

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.

Copy link
Contributor Author

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().

def method(self, *args, **kwargs):
return getattr(self, name)(*args, **kwargs)
method.__doc__ = "alias for {}".format(name)
return method
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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

method = make_alias(prefix + prop)
method.__name__ = str(prefix + alias) # Py2 compat.
local_d[prefix + alias] = method
if not exists:
Copy link
Contributor Author

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.

for alias in aliases:
method = make_alias(prefix + prop)
method.__name__ = str(prefix + alias) # Py2 compat.
method.__doc__ = "alias for `{}`".format(prefix + prop)
Copy link
Contributor

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

Copy link
Contributor Author

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

@eric-wieser
Copy link
Contributor

eric-wieser commented Dec 15, 2017

A novel idea for future: have a global MATPLOTLIB_WARN_ALIASES environment variable, and when set emit warnings when aliases are used. Follow up: ensure that internally matplotlib never uses the aliases, but always the full names.

@eric-wieser
Copy link
Contributor

eric-wieser commented Dec 15, 2017

Alright, here's the solution that preserves __signature__ in python 3:

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

@anntzer
Copy link
Contributor Author

anntzer commented Dec 15, 2017

I think that's a reasonable option, but will let other reviewers chime in first.

@eric-wieser
Copy link
Contributor

To be clear, I think what you have now is fine, and am just exploring alternatives out of curiosity :)

@efiring
Copy link
Member

efiring commented Dec 27, 2017

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:

In [5]: l.get_mec?
Signature: l.get_mec(*args, **kwargs)
Docstring: alias for `get_markeredgecolor`
File:      ~/work/programs/py/mpl/matplotlib/lib/matplotlib/cbook/__init__.py
Type:      method

In [6]: l.get_mec??
Signature: l.get_mec(*args, **kwargs)
Source:
        def method(self, *args, **kwargs):
            return getattr(self, name)(*args, **kwargs)
File:      ~/work/programs/py/mpl/matplotlib/lib/matplotlib/cbook/__init__.py
Type:      method

That's not a blocker, but it's not ideal, either.

@anntzer
Copy link
Contributor Author

anntzer commented Dec 27, 2017

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

@tacaswell
Copy link
Member

Could this be done as a class decorator? I think that would be clearer than hacking on locals() in the class body.

@tacaswell tacaswell added this to the v2.2 milestone Dec 28, 2017
@eric-wieser
Copy link
Contributor

eric-wieser commented Dec 28, 2017

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 cls.__dict__[name] instead of getattr(cls, name) to avoid that though.

@eric-wieser
Copy link
Contributor

I don't think it even needs to be a decorator - just a free function with signature add_aliases(cls: Type, aliases: Dict) -> None

@Carreau
Copy link
Contributor

Carreau commented Dec 28, 2017

@Carreau any hooks in IPython that we could rely on for this? thanks :-)

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 ?/?? but that will take time.

@anntzer
Copy link
Contributor Author

anntzer commented Dec 28, 2017

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

@Carreau
Copy link
Contributor

Carreau commented Dec 29, 2017

@Carreau Perhaps the line at ipython/ipython:IPython/core/oinspect.py@3c53804#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).

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.

@eric-wieser
Copy link
Contributor

eric-wieser commented Dec 29, 2017

or read the alias table from a table defined in the body of the function, but that doesn't play well with inheritance

Works fine if you name the table __aliases, and within your helper use getattr(cls, '_{}__aliases'.format(cls.__name__))

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?

@anntzer
Copy link
Contributor Author

anntzer commented Dec 29, 2017

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.

@anntzer
Copy link
Contributor Author

anntzer commented Dec 30, 2017

@efiring ipython/ipython#10966 should at least help for future IPython versions.

@tacaswell
Copy link
Member

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

@anntzer
Copy link
Contributor Author

anntzer commented Jan 1, 2018

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.
Subclassing should not be an issue because the aliases call getattr at call time and not at function-definition time.

if not exists:
raise ValueError("property {} does not exist".format(prop))

cls._alias_map = alias_d
Copy link
Contributor

@eric-wieser eric-wieser Jan 1, 2018

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

Copy link
Contributor Author

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.

Copy link
Member

@tacaswell tacaswell Jan 1, 2018

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.

Copy link
Contributor

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.

Copy link
Contributor

@eric-wieser eric-wieser Jan 2, 2018

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

Copy link
Member

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

Copy link
Contributor

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

method.__doc__ = "alias for `{}`".format(prefix + prop)
setattr(cls, prefix + alias, method)
if not exists:
raise ValueError("property {} does not exist".format(prop))
Copy link
Member

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.

Copy link
Contributor

@eric-wieser eric-wieser Jan 1, 2018

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"],
Copy link
Contributor

@eric-wieser eric-wieser Jan 2, 2018

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)

@eric-wieser
Copy link
Contributor

eric-wieser commented Jan 2, 2018

Ping on this outdated comment on inheritance which I think still applies.

Edit: Now resolved

"Neither getter nor setter exists for {!r}".format(prop))

if hasattr(cls, "_alias_map"):
raise NotImplementedError("Parent class already defines aliases")
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

if cls is None:
return functools.partial(_define_aliases, alias_d)

def make_alias(name): # Enfore a closure over *name*.

Choose a reason for hiding this comment

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

typo enforce?

@anntzer
Copy link
Contributor Author

anntzer commented Jan 4, 2018

btw @efiring the next IPython release will show the docstring in this case too.

Copy link
Member

@efiring efiring left a 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.

@anntzer
Copy link
Contributor Author

anntzer commented Mar 15, 2018

bump.

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

OK, seems reasonable - are many of the aliases tested? I guess the common ones are.

@anntzer
Copy link
Contributor Author

anntzer commented Mar 15, 2018

Well, looks like codecov is happy for once :-)

@jklymak jklymak modified the milestones: needs sorting, v3.0 Mar 15, 2018
@jklymak jklymak merged commit b083c2a into matplotlib:master Mar 15, 2018
@anntzer anntzer deleted the unify-aliases branch March 15, 2018 19:36
@tacaswell
Copy link
Member

🎉 I am happy this made it in!

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.

7 participants