Skip to content

Instantly share code, notes, and snippets.

@Auctoris
Created June 6, 2022 18:12
Show Gist options
  • Save Auctoris/a7e70b5f701b3615e52e5bd3c3074658 to your computer and use it in GitHub Desktop.
Save Auctoris/a7e70b5f701b3615e52e5bd3c3074658 to your computer and use it in GitHub Desktop.
An Argparse example
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.
@Auctoris
Copy link
Author

Auctoris commented Jun 6, 2022

This gist should probably be a blog post! :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment