镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

CLI task arguments #1

Closed
Closed

Description

@bitprophet

See also: Fabric #69

Problem

Need to allow parameterization of tasks as invoked via the CLI (tasks invoked via Python are simply passing regular args/kwargs or their list/dict counterparts.) Sub-problem: translating strings/flag existence into Python types such as lists, booleans etc.

Current implementation in Fabric is fab taskname:positional_arg_value,keyword_arg=kwarg_value,.... Some other tools use "regular" CLI flags, e.g. paver taskname -F value.

"Custom"/"attached" args

Pluses:

  • Works great for multi-task invokes, zero ambiguity about which param values go to which tasks
  • Supports positional arguments well

Minuses:

  • Requires custom parsing, which may lead to bugs and at the very least increases code maintenance burden
  • Slight documentation burden -- requires users to learn how that custom parsing works, re: need for escaping (esp. how that meshes with normal shell escapes) and etc.

"Regular" flag args

Pluses:

  • Can (probably) just use argparse
  • Many users already familiar with it

Minuses:

  • Doesn't mesh well with multi-task invokes unless we:
    • enforce BSD-style "order matters" (not even sure argparse can do this? EDIT: perhaps with subcommands, if >1 subcommand can be invoked at a time)
    • or use an ugly "task delimiter" syntax like invoke task1 --arg1 --next task2 --arg2 (again, can argparse do this?)
    • or hope/pray that all simultaneously-invoked tasks have zero overlap in their kwargs and no positional args are ever needed.
      • How common is this? If we assume flags nix any possibility of positional args and that this is OK, it could be reasonable to assume two or three tasks called together would all have different names for their kwargs. Maybe.
  • Namespace clashes with core (non task related) flags such as -c/--collection
    • This also requires order-matters, or simply limiting the user-allowed namespace
    • Limiting the namespace also presents backwards compatibility problems -- if version 1.1 lacks a --foo flag and a user declares mytask(foo='bar'), and then we unknowingly add --foo to core in 1.2, that user's code will break.
  • May require more setup on the user side, re: explicitly declaring each task's args/kwargs via decorators or whatnot
    • Though we could introspect the function signature and automatically create some, if needed
  • Doesn't allow for positional arguments unless we go the delimiter route.

Potential solutions

  • Continue using Fabric's "custom parsing" solution pretty much as-is
    • See above re: pluses/minuses
  • Try coming up with a "better" custom parsing solution/syntax, if there are any
    • What do other tools like Make, Rake, Thor etc use?
      • Thor uses regular flags + positional arguments, seems to simply ignore the "multiple tasks vs one task + positional args" problem, and uses "pass all flags to all tasks" to handle multi-task invocation.
        • Don't like this very much -- it "works" but has the "all tasks invoked must have different args" requirement mentioned above.
      • make uses shell environment variables, which is similar to how Thor treats flags -- every task is going to get the same values for any overlapping names.
      • Rake uses custom parsing like we do, but uses taskname[args, here] instead of taskname:args,here. I find this actually worse than the colon we use, because some shells like zsh treat square-brackets specially and need extra escaping/quoting.
  • Allow custom parsing everywhere, but also allow use of flag args in single-task invocations
    • Plus: gives flag lovers what they want
    • Minus: "more than one way to do it" and its problems (newbie confusion, more code/bugs, more docs to write.)
  • Use flags 100% of the time, relying on ordering or delimiting flags to handle multi-task invocations
    • See above re: pluses/minuses
    • Possibly use argparse subcommands, but AFAIK those are again targeted at using a single subcommand per invoke.

Activity

  1. bitprophet commented on Apr 2, 2012

    @bitprophet
    MemberAuthor

    Iteration time:

    • A few folks on IRC have mentioned they dislike the custom style for the reasons I anticipated and have heard before -- it's just different than the usual flags, is a bit more of a pain to type, etc.
    • The 'delimit' sub-solution to using flags (or another special syntax, though all the non-alpha characters seem like they have special shell meaning already) feels like a dead end -- I'm not even sure argparse can handle it, and since it already implicitly requires "order matters", we may as well just go with the implicit version. (Again, if argparse lets us.)
    • Subcommands are right out, argparse definitely cannot do >1 per invoke.

    Still reading over argparse to see just what it gives us -- would prefer to work within it instead of doing something totally custom (which would defeat one of the reasons to go with flags anyways.)

  2. bitprophet commented on Apr 2, 2012

    @bitprophet
    MemberAuthor

    Looks like argparse can't do interleaved "positional arguments" (i.e. task names) and options (flags), by default.

    May be able to hack something together with one of:

    • custom action objects
    • manually preprocessing sys.argv into core args and a collection of tasks+their args, and using a bunch of "sub" parsers
    • screwing around with the '*' version of the nargs action (though this feels like the inverse of what we need, it lets you associated multiple positional values to a given flag -- we need multiple flags associated with a "positional" task name argument.)
  3. bitprophet commented on Apr 3, 2012

    @bitprophet
    MemberAuthor

    Custom action objects seem to only work if we knew beforehand how many "options" (non-flag values) were going to be passed in (as we'd need to call add_argument for each "slot"), in which case we'd have to parse argv ourselves anyways.

    Though perhaps we could simply add all discovered tasks explicitly, as optional or something, to the parser. Slightly silly but might get the job done.

    Will triple check this approach and how nargs works. Might be worthwhile to do the preprocess approach -- I can't think of any real edge cases we need that would make "split things up by not-a-flag strings and optional trailing --, pass each chunk into different parser objects" not work.

    Part of me wants to use PyParsing for that, but not sure it's worth the dependency.

  4. bitprophet commented on Apr 3, 2012

    @bitprophet
    MemberAuthor

    Right. Preprocessing isn't going to work because it's impossible to distinguish a non-equals-signed flag argument (--foo bar) from a task name, especially in more subtle cases (e.g. if we ever needed to use nargs), without literally reinventing what argparse does. So AFAICT there's no foolproof way to chop things up -- argparse simply doesn't map well to what we're trying to do here.


    We could try the custom-action route linked above, relying on how the actions get called in order to reconstruct values -- this gives us all the "per argument/flag" parsing (--foo=bar vs --foo bar vs --foo I am using nargs lol) and would let us reconstruct a richer setup, but it has the following problems:

    • Aforementioned need to add in all tasks + their arguments to the parser so it actually calls the action on them
      • N.B. optional and not actioned-upon unless actually given, positional args, work best as: parser.add_argument('label', default=argparse.SUPPRESS, nargs='?', action=MyActionClass).
        • MyActionClass only gets called if the task exists (so if you give it 10 and call 2, you only see the action called 2 times)
        • No errors about "too few arguments" when you tell the parser it could accept up to N positional args and <N are given (which would be the case 99.999% of the time, nobody calls their entire fabfile in one go.)
    • Have to avoid adding the same flag twice for 2 different tasks, or use ArgumentParser(conflict_handler='resolve') to make it STFU.

    Theoretically we could go whole hog and just say "fuck it" to argparse and use PyParsing instead, but I'll only do that if bending argparse to my will fails or turns into a huge mess-o-code. And if I still think it's worth it to not use Fab-style tasks.

  5. bitprophet commented on Apr 3, 2012

    @bitprophet
    MemberAuthor
    • PyParsing probably wouldn't work after all -- you need the knowledge about whether a given --flag is supposed to take an argument after it (or multiple arguments, or whatever) in order to parse -- parsing a command string with none of that knowledge is impossible. Thus why argparse, etc exist and require use of add_argument.
      • Though we do want to add the type conversion feature eventually -- so if we had that set up, then we could use that information to tell which flags would require arguments and which would be just boolean switches. Feels like even more wheel reinvention, but meh.
    • The "messed up argparse" approach is not working -- no matter what setup I use it seems to misinterpret what I want or skip parsing some args or values. There's no way this is going to end well.
  6. bitprophet commented on Apr 20, 2012

    @bitprophet
    MemberAuthor

    @kennethreitz had one new spin on the "custom syntax to denote tasks apart" solution, which was to 'decorate' the task names in a custom manner, e.g. invoke task1: arg arg arg task2: arg arg arg.

    Benefits: aesthetically pleasing (much nicer than --task=task1 --task=task2), sidesteps the problem of telling flag arguments apart from task names (and thus also allows positional arguments).

    Drawbacks: falls into the "custom, non-traditional cli syntax" trap, possibly more confusing to use (what should happen if the colons are omitted? what does invoke task1 task2 do? heck, what does invoke task1 task2: args do? etc) and/or implement.


    If we take "it's not going to look exactly like traditional argparse style" for granted, then we could also return to the idea of using some non-special-shell-meaning character to delimit task+arg chunks. E.g. invoke task1 args --flags - task2 or invoke task1 args --flags , task2. However, none of the available characters look all that great to me, or -- while not technically special to the shell -- have implications (like - implying that it is a regular arg value meaning to use stdin.)

  7. bitprophet commented on Apr 21, 2012

    @bitprophet
    MemberAuthor

    Wrote up a shit ton of thoughts on this yesterday/today: http://docs.pyinvoke.org/en/latest/concepts/cli.html

    For now, I am leaning towards the task: version because while it's less orthodox it should (I think) be much easier to parse, also also enables positional arguments, both of which would be kinda nice. We'll see what happens.

  8. tswicegood commented on Apr 21, 2012

    @tswicegood

    I think this could be done by chunking up sys.argv ourselves and passing only parts of it in at a time. It would require multiple passes, but would end up with a cleaner interface.

    My initial thought is something like:

    • Parse for a very small subset of args and determine all tasks.
    • Break sys.argv on each task and assume everything after task A and before task B is supposed to go with task A
    • Parse each list of arguments for each task.

    It wouldn't be pretty and requires at least two trips through argparse, but it would allow invoke --global taskA --some-opt some-opt-value taskB --other --opt value style approaches. We should encourage the use of --opt=value to avoid ambiguous task clashing.

  9. tswicegood commented on Apr 21, 2012

    @tswicegood

    FWIW, I'll try taking a stab at the parser and see what I can come up with.

  10. bitprophet commented on Apr 21, 2012

    @bitprophet
    MemberAuthor

    We should encourage the use of --opt=value to avoid ambiguous task clashing.

    Thanks, I knew I'd forgotten at least one of the alternative approaches. Just added to the doc.

    I'm not a fan of forcing users to do this, I feel it's too much of a departure from the "orthodox" style people are used to, even though it would simplify parsing. (Rather, it departs from regular argparse style while looking like it, which is IMO more confusing than being obviously something custom like Fab 1.x style.)


    Re: the rest of your comment: if we went the "task names have no special decorations" route, we'd pretty much have to A) obtain knowledge of which flags need arguments and B) walk over sys.argv as you said, keeping our own state. (This is the "deep knowledge" methodology I mentioned in the linked docs.)

    I think it's quite possible, but we can't use argparse for it unless I missed something seriously big when I spent a day or two the other week fucking around :( I'm also not sure there's any point, in this scenario, to try and use argparse for per-task bits, because just to get to the point where we have each task and its args split apart, we've already had to parse everything. There's nothing left for argparse to do.

    I think the only use of argparse possible is if we went with one of the "easy to tell task chunks apart" routes (eg task: or the no-spaces-in-flags route), used one simple manual parse over argv to break things into chunks, then fed each chunk into its own argparse parser. I am actually leaning towards this approach right now, as per earlier comment.

  11. myusuf3 commented on Apr 21, 2012

    @myusuf3
    Contributor

    👍

  12. bitprophet commented on Apr 21, 2012

    @bitprophet
    MemberAuthor

    @tswicegood BTW I was hoping to do at least a proof of concept parser myself tonight / this weekend =( probably trying the colon-decorated approach first FWIW.

  13. bitprophet commented on Apr 21, 2012

    @bitprophet
    MemberAuthor

    @myusuf3 (or any other users) do you have any thoughts to share? Specifically, choosing between this style:

    $ invoke task1 --no-positional-args-here --kwargs are_ok_though task2
    

    where you can't do positional arguments, but the task names have no special formatting; and this style:

    $ invoke task1: positional args --allowed here task2: --task2-args
    

    where you have to do the funky task1: but you can throw in regular non-keyword arguments.

    Does the 2nd one look OK, dumb, other? Would you be sad to lose positional arguments if we did the first approach? etc.

  14. myusuf3 commented on Apr 21, 2012

    @myusuf3
    Contributor

    @bitprophet I like the second one better simply because you need to have positional args. It looks kind of weird when you first look at it; but I can see it easily becoming second nature after a bit of use.

    maybe embracing something like task1 | would be worth investigating as a potential replacement for task1:

    if majority still finds it funky looking.

  15. dhellmann commented on Apr 21, 2012

    @dhellmann

    I've been doing some work in a CLI framework where I want to discover subcommands implemented in plugins, without loading the entire plugin set into the process space just to determine the names of the commands. I found optparse's ability to stop when it encountered a non-flag argument (a value that did not start with "-") helpful. Maybe you should be using that instead of argparse? https://github.com/dhellmann/cliff/blob/master/cliff/app.py#L36

  16. 15 remaining items

  17. bitprophet commented on Jun 1, 2012

    @bitprophet
    MemberAuthor

    Have been bad about linking work to this ticket, but have some basic work done at this point, test-driven, and now using the Fluidity state machine library to help with the actual parse loop (a literal for loop was too brittle re: cleaning up trailing state.)

    Working: boolean flags, flags that take args, flag default values, multiple tasks/contexts per invoke.

    Not working: handling --foo=bar (vs --foo bar), 'remainder' support (invoke blah blah -- this here should be one literal string), and hooking up the parser to the actual task execution (though this should be easyish as both ends of that equation are already working.)

  18. bitprophet commented on Jun 3, 2012

    @bitprophet
    MemberAuthor

    Really at the crux of it now. Stuff being dealt with:

    • Turning the foo in def mytask(foo): pass into --foo automatically
    • Going from there to allowing optional short flags
    • But once that's done it's time to wrap this up and move on to CLI arg typecasting #9 and others.
  19. added a commit that references this issue on Jun 3, 2012
  20. bitprophet commented on Jun 3, 2012

    @bitprophet
    MemberAuthor

    Got that working, then realized it poses a problem: right now Arguments have N names, intended to be potential parser matches, via a dict interface in the parent Context object (e.g. if <token seen in parsing> in <Context object>.)

    So they need to be exactly what is seen on the CLI -- and so the test I just committed creates an Argument named --foo so that invoke --foo works.

    Unfortunately, this same dict interface -- Context.args[argname] -- is intended to be used (And is used in tests) for accessing seen values post-parsing. At that point, we now have -- added in to everything. Not super handy.


    Clear answer seems to be for the Context to expose a slightly different API to the Parser than it does to the clients of the parser result. What exactly that should look like, I'm not sure.

    Alternately, we could simply add more aliases to Context's dict, so that it has both --foo and foo. However this A) muddies things and B) introduces ambiguity problems where mytask foo becomes valid, when it really shouldn't be (at least according to our spec.)


    Brainstorming:

    • The crux point here, given a "specify and query Arguments as non-flaglike string names" desired API, is the point where Parser queries Context objects to see if they exhibit the flag/token being handled.
    • The naive approach, have it query for "--" + token, only fits the base case of simple long flags, and breaks as soon as we introduce short flags.
    • So the knowledge about "is --flagname a valid flag" / "give me the Argument for --flagname" really needs to live inside the Context or below.
    • So Context must expose a 2nd API besides .args, which is mostly for use by Parser, while .args is for use by clients of the result.
      • Though we can of course change how clients interact with the resulting Context objects too -- tho right now a Lexicon works well because it lets us preserve the common result.optionname access pattern.
  21. bitprophet commented on Jun 5, 2012

    @bitprophet
    MemberAuthor

    Sweet, got that working, went with .args vs .flags, whose values are the same Argument objects, so the parser can manipulate .flags and the creators/consumers of Context objects can use .args for easier/more natural access.

    Two more rough spots in the API and I think we can close this:

    • Untyped arguments' default type. Right now I assume boolean, but that's not very friendly and breaks from all other parsing libs, which assume string. Change it to assume string, which will necessitate changing a lot of tests.
    • Default values. I think this is just an oversight/untested spot, but e.g. a boolean Argument, when not actually flipped to True by being seen, will have a .value of None instead of False.
      • I forget if this is also the case with string args, though at least in that case None could be a valid default.
      • Could be as simple as changing it just for bools, then, and probably wasn't caught because None does evaluate to False when used as a test expression.
  22. bitprophet commented on Jun 5, 2012

    @bitprophet
    MemberAuthor

    First bullet point done, plus related updates such that Argument kind values now pull from the default values in the task function, if available.

    Re: the latter, I think None is a decent default value for the time being -- can change later if real world use dictates it.

  23. bitprophet commented on Jun 5, 2012

    @bitprophet
    MemberAuthor

    Fleshed out more tests. Ran into one that should already work, but doesn't: invoke task1 -a value task2. Result from parser only has one context, I assume task1 but haven't debugged yet.

    This is in the integration level tests that use a Collection, its to_contexts and a Parser.

    Pretty sure I have passing tests in Parser that handle multiple Contexts. On the other hand, I verified that this Collection's to_contexts is correctly returning two Context objects, so the Parser is what's dropping/not seeing one of them.


    After this, an area that has not been accounted for yet in the parsing (uh oh!) -- non-space short flags, and combo short flags. The parse engine up til now has assumed space delimited tokens so this will be an interesting challenge.

  24. bitprophet commented on Jun 8, 2012

    @bitprophet
    MemberAuthor

    This is all set, what's left are some smaller skipped tests I'll fill in as we go.

  25. added a commit that references this issue on Jul 15, 2012
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions