Created
June 6, 2022 18:12
-
-
Save Auctoris/a7e70b5f701b3615e52e5bd3c3074658 to your computer and use it in GitHub Desktop.
An Argparse example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import argparse | |
import sys | |
# Our goal here is to illustrate a simple example of how to use | |
# argparse to handle a slightly non-trivial (but quite common) case. | |
# | |
# This is inspired by an example in this video: | |
# https://www.youtube.com/watch?v=26mEa1Ojux8 | |
# | |
# What we want to do, is to have a tool that we can run in one | |
# of two modes: 'local' or 'remote'; and when we're running it in the | |
# remote mode - we are *required* to specify the hostname (target) | |
# which we're going to connect to, and the port number we should use | |
# for that connection. If we're running in the local mode however, | |
# we don't require (and shouldn't specify) these arguments. | |
# | |
# We'll start by instantiating a parser for us to use later. | |
parser = argparse.ArgumentParser(description="Python Argparse demo") | |
# Next we want to think about how to specify the mode. | |
# There are several design choices in terms of how to do this. | |
# | |
# We could do that with a positional argument... Which would look | |
# something like this: | |
# | |
# parser.add_argument('mode', type=str, choices=['local', 'remote']) | |
# | |
# But I'm *personally* not a huge fan of positional arguments for | |
# anything other than a single arg for a filename... | |
# | |
# e.g. $ demo file.dat | |
# | |
# Another way is that we could use a single required option argument | |
# to select the mode. This feels like a better fit (to me) for a | |
# simple fixed-choice argument like a mode... | |
# For this example we could also add some additional help text. | |
# | |
# parser.add_argument('--mode', '-m', required=True, type=str, | |
# choices=['local', 'remote'], | |
# help="Specify a local or a remote target to run against.") | |
# | |
# But that was the easy part. What about the second half of the | |
# requirements? How do we require that the user specifies a | |
# target and a port - *if* (and only if) our mode was set | |
# to remote? | |
# | |
# If we didn't care about when these were used we could add them | |
# in the usual way. | |
# | |
# parser.add_argument('--host', '-t', type=str, | |
# help="Specify an address for the tool to run against, " | |
# "when using remote mode.") | |
# | |
# parser.add_argument('--port', '-p', type=int, | |
# help="Specify a port to connect to the host on, when using " | |
# "remote mode.") | |
# | |
# BTW note that we don't need to set defaults - as the argument will | |
# default to a None if not specified; which we can easily check for. | |
# | |
# Given that we *do* want to constrain (and conditionally madate) | |
# the use of these arguments, we have a few different options on | |
# how to do this. | |
# | |
# A commonly seen solution to this is dymanically specify the | |
# "required" param for the argument, based on the value of sys.argv | |
# e.g. | |
# | |
# parser.add_argument('--port', required=('remote' in sys.argv)) | |
# | |
# But this feels rather like we're trying to write C in Python... | |
# | |
# A better solution is to turn the whole thing around and to have | |
# separate "local" and "remote" arguments. Note that if we do this | |
# we need to set them to be mutually exclusive (since we can't be in | |
# both local, and remote modes at the same time). To do this we can | |
# simply implement an argparser group... We can specify that one of | |
# the elements within this group is required, by making the group | |
# itself 'required'. | |
# | |
# mutex_group = parser.add_mutually_exclusive_group(required=True) | |
# | |
# We could do this by using arguments that use the action | |
# "store_true" to set a boolean flag if they're present. | |
# | |
# mutex_group.add_argument("--local", '-l', action='store_true', | |
# help="Specify the tool to run in local mode.") | |
# | |
# mutex_group.add_argument("--remote", '-r', action='store_true', | |
# help="Specify the tool to run in remote mode. " | |
# "Target & Port must be set.") | |
# | |
# We could change the remote argument, to use positional elements, | |
# for the target & port; by setting nargs and a metavar to name them. | |
# | |
# mutex_group.add_argument("--remote", nargs=2, | |
# help="Specify the tool to run in remote mode. Both a target " | |
# "address and port number must be specified", | |
# metavar=('TARGET', 'PORT')) | |
# | |
# This is arguably better than the sys.argv solution (IMHO); and it | |
# needs less tweaking to make the help-text read usefully. | |
# | |
# It's *possible* to implement something a bit like this using | |
# subparsers (the cannonical use-case for which is a git-like tool, | |
# with multiple sub-commands, each of which have some arguments). | |
# However, it's quite fragile to being used in a particular way | |
# and I've not found a way to implement this requirement using | |
# subparsers. | |
# Moreover even if there was a way, we're inherrently back to the | |
# use of possitional arguments... | |
# | |
# The final way is to think about this in a UNIX way. We're kind of | |
# there with all of the above; but in terms of requiring the minimum | |
# of information, we can see that we can turn this around again... | |
# The port and target are *only* required when we're in remote mode; | |
# so if we don't specify these arguments, we can infer that we | |
# *must* be in 'local' mode; and if we do – then we must be in the | |
# 'remote' mode. | |
# | |
# We could do this with an optional argument for remote mode, which | |
# then takes a pair of values – more or less as we did for the | |
# mutex group example. | |
# | |
# parser.add_argument("--remote", '-r', nargs=2, | |
# help="Specify the tool to run in remote mode. Both a target " | |
# "address and port number must be specified", | |
# metavar=('TARGET', 'PORT')) | |
# | |
# But, given that we're again back to positional arguments, we could | |
# just as well just go all the way down that road, and back to a | |
# place where a positional argument makes sense. | |
# | |
# For the target we'll just use a simple positional argument, with | |
# the nargs param set to '?' (to demote 0 or 1). If it's not | |
# supplied it'll default to None (as we saw before). | |
parser.add_argument("target", nargs='?', | |
help="The address of a remote target, if required.") | |
# For the port; we'll take a different approach (although we could | |
# use the same approach as above, and check for None ourselves)... | |
# Specifically we'll provide a default port number - which can be | |
# used if nothing else is specified by the user. | |
parser.add_argument("port", nargs='?', default="1337", | |
help="The port to use when connecting to the remote target. " | |
"Port 1337 is assumed if a port is not otherwise specified.") | |
# Now we just need to call the parse_args() method... | |
args = parser.parse_args() | |
# And finally we can check for to see if target is None, and then | |
# run our own logic. | |
# | |
# If we'd gone with the --remote solution, this'd look like: | |
# | |
# if args.remote is not None: | |
# print(f"We're in remote mode: Target is {args.remote[0]}; " | |
# f"port = {args.remote[1]}.") | |
# else: | |
# print("We're running in local mode.") | |
# | |
# Otherwise (if we're using the positional approach) we have | |
# a very slightly different filter... | |
if args.target is not None: | |
print(f"We're in remote mode: Target is {args.target}; " | |
f"port = {args.port}.") | |
else: | |
print("We're running in local mode.") | |
# Given the two final approaches, I *think* the '--remote' one wins | |
# out for me in terms of elegance because we don't need a default | |
# port value (and/or any other of our own code to check that a port | |
# number has been specified by the user); but there's not really all | |
# that much to choose between them. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This gist should probably be a blog post! :-)