Skip to content
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

Fix keyword-only arguments #18

Merged
merged 6 commits into from
Mar 20, 2017
Merged

Fix keyword-only arguments #18

merged 6 commits into from
Mar 20, 2017

Conversation

jgarst
Copy link
Contributor

@jgarst jgarst commented Mar 11, 2017

Should fix the rest of #6.

Jared Garst added 3 commits March 11, 2017 11:14
Also modify tests for inspectutils.GetArgSpec.
Keyword only arguments are now allowed, and are required to be
passed with flags.
@dbieber dbieber changed the title Fix keyword arguments Fix keyword-only arguments Mar 11, 2017
@dbieber
Copy link
Member

dbieber commented Mar 11, 2017

Awesome! I'll look through the pull request later in the week.

We'll also need to get travis passing before we can check this in, and there'll be some style things to fix up too. My first thought for how to make Travis happy is to conditionally exclude files from nosetest (called in .travis.yml atm) based on the python version, which I think Travis exposes as an env variable. Open to suggestions.

@jgarst
Copy link
Contributor Author

jgarst commented Mar 12, 2017

Ach, I missed that you included a travis file, and tried to test by hand. Sorry about that.

That nose test discovery is annoyingly broad. I suppose I like starting a nose exclude list, if the alternative is removing the word 'test' from test related non-scripts. I've updated the nose command in travis.yml. An alternative might be to create a separate test directory. Then testing components would not need 'test' in their name for clarity, and so would not be discovered and run automatically. This is getting way into the minutia though.

Let me know about the style issues. I tried to be conservative and match yours, but I will have of course missed some bits.

The non-CI related problem travis discovered is a bug in python < 3.4.3 namedtuple._asdict. Specifically subclasses of namedtuple always returning an empty dictionary.

The workaround isn't that ugly, but having a datatype with that much of a gotcha is a landmine for development. Unless you are looking for an excuse to drop support for python 3.4.0 - 3.4.3, I'll figure out some way to avoid it.

Copy link
Member

@dbieber dbieber left a comment

Choose a reason for hiding this comment

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

Thanks for doing this.

  • Your update to .travis.yml sounds good for now. If/when we end up needing to grow the exclude list further, we can reevaluate.
  • Thoughts inline about using a class rather than a namedtuple.
  • I also put comments on each of the style bits. Gotta keep the linter happy :). (We should set up Travis to check lint automatically...)

fire/core.py Outdated
@@ -546,16 +546,19 @@ def _MakeParseFn(fn):
can then be called with fn(*varargs, **kwargs). The remaining_args are
the leftover args from the arguments to the parse function.
"""
fn_args, fn_varargs, fn_keywords, fn_defaults = inspectutils.GetArgSpec(fn)
(fn_args, fn_varargs, fn_keywords, fn_defaults, fn_kwonly,
fn_kwonlydefaults, fn_annotations) = inspectutils.GetArgSpec(fn)
Copy link
Member

Choose a reason for hiding this comment

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

style: 4 space indent after paren


missing_kwonly = set(required_kwonly) - set(kwargs)
if missing_kwonly:
raise FireError('Missing required flags:', missing_kwonly)
Copy link
Member

Choose a reason for hiding this comment

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

How about something like 'Missing required keyword-only args'

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you plan to keep this error printing to the terminal when a user mistypes something on the command line? Someone using the final command line program might be familiar with command line flags, but not the python specific concept of keyword only arguments.

Copy link
Member

Choose a reason for hiding this comment

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

That's a fair point.
Yeah, currently this error does surface to the user, so your string sgtm.
There's definitely room to clean up the Fire trace output, so we can think about changing how these errors are displayed if/when we go down that route.

@@ -580,7 +588,7 @@ def _ParseFn(args):

varargs = parsed_args + varargs

consumed_args = args[:len(args)-len(remaining_args)]
consumed_args = args[:len(args) - len(remaining_args)]
Copy link
Member

Choose a reason for hiding this comment

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

thanks.

@@ -102,6 +103,14 @@ def testFireAnnotatedArgs(self):
self.assertEqual(fire.Fire(tc.Annotations, 'double 5'), 10)
self.assertEqual(fire.Fire(tc.Annotations, 'triple 5'), 15)

@unittest.skipIf(sys.version_info < (3, 0),
Copy link
Member

Choose a reason for hiding this comment

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

Do we want this or six.PY2?
We've been using six.PY2, so I lean toward six for consistency.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are correct, and I have fixed it, but these testing libraries tend to only get more version specific over time. You might end up getting back to sys.version_info anyway.

return arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10


class KeywordOnly:
Copy link
Member

Choose a reason for hiding this comment

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

class KeywordOnly(object):

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed, but since this file can't be parsed by python 2 anyway, does it matter?

from collections import namedtuple

_FullArgTuple = namedtuple('FullArgSpec', ('args', 'varargs', 'varkw',
'defaults', 'kwonlyargs', 'kwonlydefaults', 'annotations'))
Copy link
Member

Choose a reason for hiding this comment

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

format this as:

_FullArgTuple = namedtuple(
    'FullArgSpec',
    ('args', 'varargs', 'varkw', 'defaults',
     'kwonlyargs', 'kwonlydefaults', 'annotations'))

"""NamedTuple storing arguments of a callable, populated with defaults."""

def __new__(cls, args=None, varargs=None, varkw=None, defaults=None,
kwonlyargs=None, kwonlydefaults=None, annotations=None):
Copy link
Member

Choose a reason for hiding this comment

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

align kwonlyargs with cls.

'defaults', 'kwonlyargs', 'kwonlydefaults', 'annotations'))


class _FullArgSpec(_FullArgTuple):
Copy link
Member

Choose a reason for hiding this comment

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

Thinking aloud:
Is a class that inherits from a namedtuple really the best choice here?
My initial aversion is that it feels clunky. Also, now that we have 7 args, it strikes me that variable expansion is prone to breaking if we mess up the argument order somewhere and don't notice.
My preference would be to abandon the tuple aspect. And instead I think we should just have a class, and always access the members by name. Something like:

class FullArgSpec(object):
  """The full argspec of a callable."""

  def __init__(self, args=None, varargs=None, varkw=None, defaults=None,
               kwonlyargs=None, kwonlydefaults=None, annotations=None):
    self.args = args or []
    self.varargs = varargs
    self.varkw = varkw
    self.defaults = defaults or ()
    self.kwonlyargs = kwonlyargs or []
    self.kwonlydefaults = kwonlydefaults or {}
    self.annotations = annotations or {}

How's that sound?

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 agree, the tuple is a holdover from python3's inspect.getargspec that I did not reevaluate.

As this object adds more information, it might be wise to converge it to python 3's inspect.Signature object, which seems to be the canonical future of signatures in python.

Signature has an ordered dict of Parameter (name, kind, default, annotation) objects. I think this might make cleaner parsing in core._ParseFn, and I worry that without this representation your signature and python3's signature will evolve in different directions. It is also probably overpowered for what you need, and an example of small pull requests turning into larger pull requests.

The right answer probably depends on your ideas about the scope of this project. I'm already mucking about here, and happy to modify it as you like.

return _UsageStringFromFnDetails(command, args, varargs, keywords, defaults)
spec = inspectutils.GetArgSpec(component)
spec_dict = dict(zip(spec._fields, iter(spec)))
return _UsageStringFromFnDetails(command, **spec_dict)
Copy link
Member

Choose a reason for hiding this comment

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

in line w/ the previous comment, this would become _UsageStringFromFnDetails(command, spec).

@@ -17,10 +17,20 @@
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
import six
Copy link
Member

Choose a reason for hiding this comment

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

new line before import

@dbieber
Copy link
Member

dbieber commented Mar 12, 2017

Also make sure you read this comment first, because a number of the style comments become irrelevant after switching from namedtuple to class.

@jgarst
Copy link
Contributor Author

jgarst commented Mar 14, 2017

Thanks for taking the time to review this, by the way.

Quick question, after making the spec class a simple object, the test testGetArgSpecReturnType becomes.

` def testGetArgSpecReturnType(self):
# Asserts that the object returned by GetArgSpec has the appropriate fields.
spec = inspectutils.GetArgSpec(tc.identity)

self.assertTrue(hasattr(spec, 'args'))
self.assertTrue(hasattr(spec, 'defaults'))
self.assertTrue(hasattr(spec, 'varargs'))
self.assertTrue(hasattr(spec, 'varkw'))
self.assertTrue(hasattr(spec, 'kwonlyargs'))
self.assertTrue(hasattr(spec, 'kwonlydefaults'))
self.assertTrue(hasattr(spec, 'annotations'))`

It is not totally clear what this is testing anymore, that other tests aren't doing implicitly. My inclination would be to remove it, but I didn't want to do that without comment. Let me know what you think.

@dbieber
Copy link
Member

dbieber commented Mar 14, 2017

Yes, I think it's good to remove that testGetArgSpecReturnType test now.

Copy link
Member

@dbieber dbieber left a comment

Choose a reason for hiding this comment

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

Looks good! Some small things inline.

@@ -136,16 +136,19 @@ def _UsageStringFromFnDetails(command, args, varargs, keywords, defaults):
command: The command leading up to the function.
Copy link
Member

Choose a reason for hiding this comment

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

The docstring should now just list command and spec as args.

@@ -63,46 +77,40 @@ def GetArgSpec(fn):
Args:
fn: The function to analyze.
Returns:
A named tuple of type inspect.ArgSpec with the following fields:
An object containing the following fields:
args: A list of the argument names accepted by the function.
Copy link
Member

Choose a reason for hiding this comment

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

Let's move these arg descriptions to the docstring of _FullArgSpec's init, and then the docstring here can be much simpler, something like:

...
Returns:
  A FullArgSpec object for the provided function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops. Missed this pretty bad.

@@ -24,6 +24,20 @@
import six


class _FullArgSpec(object):
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 used outside of this module, albeit never reference by name outside of this module. So, I think we should drop the underscore. What do you think?

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 you are right, my hesitation is that once it is a public non-builtin type it should be able to instantiate itself from a function, and other things people will reasonably expect of it.

I suppose this isn't really a reason not to do it, but it will be trickier to decide what it's job is once it is publically visible.

keywords = argspec.keywords
args = argspec.args[1:] if skip_arg else argspec.args
annotations = getattr(fn, '__annotations__', None)
fullspec = _FullArgSpec(args, *argspec[1:], annotations=annotations)
Copy link
Member

Choose a reason for hiding this comment

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

This makes me a little nervous.

Brainstorming why:
1: It hides which arguments are being passed from getargspec to _FullArgSpec,
2: subject to breaking if either getargspec or _FullArgSpec change (not really a concern I guess)
3: Reduces the approachability of the codebase (as a side effect of 1, plus using slices and *args in the same fn call).

None of these are big issues and this is nicely compact, so I guess it's fine to leave. Up to you.

Applies to line 101 as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is probably too clever. I'll stop inling the if and just pop off the first argument. I'll probably change FullArgSpec so that it takes a function. Then passing hidden arguments won't be a problem. Since FullArgSpec no longer really behaves like inspect.FullArgSpec, it probably also needs a new name.
``


except TypeError:
args = []
defaults = ()
# If we can't get the argspec, how do we know if the fn should take args?
# 1. If it's a builtin, it can take args.
# 2. If it's an implicit __init__ function (a 'slot wrapper'), take no args.
# Are there other cases?
Copy link
Member

Choose a reason for hiding this comment

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

[Unrelated comment]
Heads up that yes, there are other cases, so we'll have to change this logic a little bit (not as part of this review, of course). This does not affect your change and will go in after.
The cases we originally missed are C-extensions, which inspect.signature/inspect.fullargspec cannot get the argspec for. While we can't solve this in the general case, we can add known argspecs for builtin modules. The only ones on my list to add so far are object.__init__ (which gets used a lot and which we already handle correctly) and type.__init__ (which is much less used, but still important).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, callable objects too, like functools.partial. There is a 50/50 chance I am missing something, but I think those are basically all the cases.

You will probably be special casing forever of course. The first one should probably be special logic to grab the real arguments from the partial object, instead of the generic *args, **kwargs it provides. My assumption is that you will eventually want something that is or looks like single dispatch here.

self.assertEqual(spec.annotations, {'arg2': int, 'arg4': int})


@unittest.skipIf(six.PY2, "No keyword arguments in python 2")
Copy link
Member

Choose a reason for hiding this comment

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

single quotes

@@ -102,6 +102,13 @@ def testFireAnnotatedArgs(self):
self.assertEqual(fire.Fire(tc.Annotations, 'double 5'), 10)
self.assertEqual(fire.Fire(tc.Annotations, 'triple 5'), 15)

@unittest.skipIf(six.PY2, "keyword only arguments not supported in python 2")
Copy link
Member

Choose a reason for hiding this comment

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

single quotes

@dbieber
Copy link
Member

dbieber commented Mar 14, 2017

I hope you don't mind I pushed some changes directly to the pull request rather than leaving comments. See them here 157200e.

@dbieber
Copy link
Member

dbieber commented Mar 14, 2017

I'll wait for you to look through those changes before I move anything into google/python-fire:master. Let me know if you have comments.

Re: the naming changes:
While it probably would have been better to go with lower case function names throughout the project, I don't want to partially switch over. I want to keep consistency within the project.

@jgarst
Copy link
Contributor Author

jgarst commented Mar 15, 2017

Haha, no problem. I'm just sorry the long tail of this has taken so much of your time.

In terms of the rename, I wasn't trying to change the naming convention. I was more worried about the conceptual break that has happened with inspect.FullArgSpec, and then the problem of having the constructor for a Specification from a function is called GetArgSpecs. Someone who knew they needed a Specification object might not immediately think to call that.

Maybe this worry is overblown though, and you are the only one with a good sense for the cost of refactoring things.

@dbieber dbieber merged commit babf4f1 into google:master Mar 20, 2017
@dbieber dbieber mentioned this pull request Mar 20, 2017
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.

None yet

2 participants