Repository navigation
CLI task arguments #1
Copy link
Copy link
Closed
Description
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
--fooflag and a user declaresmytask(foo='bar'), and then we unknowingly add--footo 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.
makeuses 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 oftaskname: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.
- 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.
- What do other tools like Make, Rake, Thor etc use?
- 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.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
bitprophet commentedon Apr 2, 2012
Iteration time:
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.)
bitprophet commentedon Apr 2, 2012
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:
sys.argvinto core args and a collection of tasks+their args, and using a bunch of "sub" parsers'*'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.)bitprophet commentedon Apr 3, 2012
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_argumentfor 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
nargsworks. 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.
bitprophet commentedon Apr 3, 2012
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 usenargs), 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=barvs--foo barvs--foo I am using nargs lol) and would let us reconstruct a richer setup, but it has the following problems:parser.add_argument('label', default=argparse.SUPPRESS, nargs='?', action=MyActionClass).MyActionClassonly gets called if the task exists (so if you give it 10 and call 2, you only see the action called 2 times)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.
bitprophet commentedon Apr 3, 2012
--flagis 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 ofadd_argument.bitprophet commentedon Apr 20, 2012
@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 task2do? heck, what doesinvoke task1 task2: argsdo? 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 - task2orinvoke 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.)bitprophet commentedon Apr 21, 2012
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.tswicegood commentedon Apr 21, 2012
I think this could be done by chunking up
sys.argvourselves 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:
sys.argvon each task and assume everything aftertask Aand beforetask Bis supposed to go withtask AIt wouldn't be pretty and requires at least two trips through
argparse, but it would allowinvoke --global taskA --some-opt some-opt-value taskB --other --opt valuestyle approaches. We should encourage the use of--opt=valueto avoid ambiguous task clashing.tswicegood commentedon Apr 21, 2012
FWIW, I'll try taking a stab at the parser and see what I can come up with.
bitprophet commentedon Apr 21, 2012
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.argvas 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
argparsefor 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
argparsepossible is if we went with one of the "easy to tell task chunks apart" routes (egtask:or the no-spaces-in-flags route), used one simple manual parse overargvto break things into chunks, then fed each chunk into its ownargparseparser. I am actually leaning towards this approach right now, as per earlier comment.myusuf3 commentedon Apr 21, 2012
👍
bitprophet commentedon Apr 21, 2012
@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.
bitprophet commentedon Apr 21, 2012
@myusuf3 (or any other users) do you have any thoughts to share? Specifically, choosing between this style:
where you can't do positional arguments, but the task names have no special formatting; and this style:
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.
myusuf3 commentedon Apr 21, 2012
@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 fortask1:if majority still finds it funky looking.
dhellmann commentedon Apr 21, 2012
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
15 remaining items
bitprophet commentedon Jun 1, 2012
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 (, and hooking up the parser to the actual task execution (though this should be easyish as both ends of that equation are already working.)invoke blah blah -- this here should be one literal string)Add failing tests re: = in flags
Implement equals sign splitting re #1
Failing test re: remainder, re #1
Implement remainder splitting
bitprophet commentedon Jun 3, 2012
Really at the crux of it now. Stuff being dealt with:
fooindef mytask(foo): passinto--fooautomaticallyRe #1, prepend long-flag prefix to auto-args
bitprophet commentedon Jun 3, 2012
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
--fooso thatinvoke --fooworks.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
--fooandfoo. However this A) muddies things and B) introduces ambiguity problems wheremytask foobecomes valid, when it really shouldn't be (at least according to our spec.)Brainstorming:
"--" + token, only fits the base case of simple long flags, and breaks as soon as we introduce short flags.--flagnamea valid flag" / "give me the Argument for--flagname" really needs to live inside the Context or below.Contextmust expose a 2nd API besides.args, which is mostly for use by Parser, while.argsis for use by clients of the result.Contextobjects too -- tho right now a Lexicon works well because it lets us preserve the commonresult.optionnameaccess pattern.Try adding .flags flaglike access to Context
bitprophet commentedon Jun 5, 2012
Sweet, got that working, went with
.argsvs.flags, whose values are the same Argument objects, so the parser can manipulate.flagsand the creators/consumers of Context objects can use.argsfor easier/more natural access.Two more rough spots in the API and I think we can close this:
.valueofNoneinstead ofFalse.Nonecould be a valid default.bitprophet commentedon Jun 5, 2012
First bullet point done, plus related updates such that Argument
kindvalues 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.
bitprophet commentedon Jun 5, 2012
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 assumetask1but haven't debugged yet.This is in the integration level tests that use a Collection, its
to_contextsand 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_contextsis 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.
bitprophet commentedon Jun 8, 2012
This is all set, what's left are some smaller skipped tests I'll fill in as we go.
Merge pull request #1 from pyinvoke/master