CLI Abstraction
Calling another program from Python usually starts as a list of strings and ends as a bug: a path that needed
quoting, a flag whose spelling differs on Windows, a value concatenated with + in the wrong place. The list is
assembled far from where the arguments are decided, and nothing checks it.
pyTooling.CLIAbstraction turns the program into a class and each of its arguments into a
typed member. Assembling, escaping and platform-dependent spelling happen once, in the argument class, instead
of at every call site.
This tutorial builds that class.
See also
- Overview
→ The reference for
pyTooling.CLIAbstraction.- Overview
→
@CLIArgumentis an attribute; this is the mechanism behind it.
git = Git()
git[git.CommandCommit] = True
git[git.FlagAll] = True
git[git.ValueMessage] = "initial commit"
git.ToArgumentList() # -> ['git', 'commit', '-m', 'initial commit', '-a']
Step 1: Program or Executable?
Two base-classes, one question: does your class need to run the program, or only to describe the call?
Base-class |
Gives you |
Choose it when |
|---|---|---|
argument members, |
something else starts the process - a build system, a CI step, |
|
all of that, plus |
your class starts the process and reads its output |
Executable derives from Program, so the
choice is not final: describe first, and change the base-class when the class needs to run something.
Hint
Start with Program when in doubt. A class that only assembles a command line
is trivially testable - ToArgumentList() returns a list you can assert on - while a class that starts
processes needs one to be installed.
Step 2: the class and its first argument
An argument is a nested class derived from one of the predefined argument classes, marked with
@CLIArgument(). The nested class contributes nothing but its name and its base - the
base decides the pattern, and name= fills it in.
Three things are worth naming:
_executableNames maps platform.system to the file name
The executable is looked up on PATH when the object is constructed, and a missing one raises
CLIAbstractionError right there rather than at the first call. Pass
executablePath= to bypass the lookup and name a file directly.
The nested class’ own name is how you address the argument
git[git.CommandCommit] - the member name, not the command line spelling. Renaming --version to -V
later is a change to name= alone; every call site keeps working.
... is the whole body
The nested class exists to be named and to inherit. Anything else in its body is a sign the wrong base-class was chosen.
Note
@CLIArgument() needs its parentheses even though it takes no arguments, because it is an attribute and an attribute is instantiated before it is applied.
from pyTooling.CLIAbstraction import CLIArgument, Program
from pyTooling.CLIAbstraction.Command import CommandArgument
from pyTooling.CLIAbstraction.Flag import ShortFlag, LongFlag
class Git(Program):
_executableNames = {
"Darwin": "git",
"FreeBSD": "git",
"Linux": "git",
"Windows": "git.exe",
}
@CLIArgument()
class FlagVersion(LongFlag, name="version"): ...
@CLIArgument()
class CommandCommit(CommandArgument, name="commit"): ...
@CLIArgument()
class FlagAll(ShortFlag, name="a"): ...
Step 3: set the arguments and assemble
Arguments are set through the indexer, with the value the argument carries - or None for one that carries
nothing:
Attention
The order of the result is the order the arguments were declared in, not the order they were set. -m
precedes -a above because ValueMessage is declared before FlagAll. Declare the nested classes in
the order the program expects them - commands before their flags - and the assembled list is right regardless
of how the caller fills it in.
Caution
A command and a flag carry no value, and the value assigned is not read.
program[program.FlagAll] = True and program[program.FlagAll] = None do the same thing:
assigning registers the argument. True is the better spelling because it says what is meant.
``False`` does not turn it off - it registers the flag like any other assignment, and -a is emitted
anyway. An argument also can’t be unset afterwards: there is no __delitem__, and assigning a second time
raises KeyError. So an argument that depends on a condition is set inside an if, not by assigning a
boolean:
if verbose:
program[program.FlagVerbose] = True
ToArgumentList() returns the list for subprocess.Popen;
str(program) returns the same thing quoted, for a log line or an error message.
git = Git()
git[git.CommandCommit] = True # a command: selects what the program does
git[git.FlagAll] = True # a flag: present
git[git.ValueMessage] = "initial commit" # a valued flag: name and value
git.ToArgumentList() # -> ['git', 'commit', '-m', 'initial commit', '-a']
Step 4: choosing the right argument class
Each predefined class knows one pattern. Pick by what the program’s syntax looks like, then by prefix style -
every family has a Short, a Long and a Windows variant, differing only in -, -- and /.
Base-class |
Assign |
Renders as |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{
"NDEBUG": "1",
"LEVEL": "2",
}
|
|
|
a |
the path, positionally |
Single value vs. tuple vs. list
The three that carry a value differ in how the value reaches argv, and mixing them up is the most common
mistake in an abstraction:
LongValuedFlag- one argv entry, name and value joined by=. This is--output=build/app.LongTupleFlag- two argv entries, the name and then the value. This is-m "initial commit", and it is what a program usinggetopt()with a separate value expects.LongValuedFlagList- the flag repeated, once per element of the assigned list. This is--lib=m --lib=pthread, the shape of-I,-Dand-lin most compilers.
The test is what the program’s own help text shows. --output=FILE is a valued flag; --output FILE is a tuple
flag; --lib LIB (may be given more than once) is a flag list.
Hint
LongOptionalValuedFlag is for the flag that means one thing
bare and another with a value - --color versus --color=always. Assigning None gives the bare form.
Step 5: sharing arguments between program variants
A tool that exists in several variants - a native and a cross compiler, two versions of the same simulator - shares most of its arguments. Put them in a mixin and derive each variant from it, so the argument classes are declared once:
See Mixin for what mixin=True does and why the second base-class needs it.
from pyTooling.MetaClasses import ExtendedType
class GitArgumentsMixin(metaclass=ExtendedType, mixin=True):
_executableNames = {"Linux": "git", "Windows": "git.exe", "Darwin": "git"}
@CLIArgument()
class FlagVersion(LongFlag, name="version"): ...
@CLIArgument()
class CommandCommit(CommandArgument, name="commit"): ...
class Git(Program, GitArgumentsMixin): ...
class GitWithLFS(Git):
@CLIArgument()
class CommandLFS(CommandArgument, name="lfs"): ...
Step 6: running it
Change the base-class to Executable and the same class can start the process
and read its output line by line:
Caution
dryRun=True on the constructor is meant to make the program describe what it would run
instead of running it, so a --dry-run mode in your own tool needs one constructor argument rather than
a branch around every call. It is not usable as it stands: the dry-run paths call
self.LogDryRun(...), which no class in pyTooling defines, so
StartProcess() raises
AttributeError instead. Provide a LogDryRun method on your class until that is resolved.
GetLineReader() does behave as documented and raises
DryRunError when the process was never started.
class Git(Executable, GitArgumentsMixin): ...
git = Git()
git[git.CommandCommit] = None
git[git.ValueMessage] = "initial commit"
git.StartProcess()
for line in git.GetLineReader():
print(line)
git.Wait()
if git.ExitCode != 0:
raise BuildError(f"'{git}' failed with exit code {git.ExitCode}.")