4

I have the following code to set the y and x axis limits from the command line in a script which eventually calls matplotlib (here, ax is a matplotlib axes object, but it shouldn't really matter, and p is an ArgumentParser instance):

p.add_argument('--ylim', help='Set the y axis limits explicitly (e.g., to cross at zero)', type=float, nargs='+')
p.add_argument('--xlim', help='Set the x axis limits explicitly', type=float, nargs='+')

# more stuff


if args.ylim:
    if (len(args.ylim) == 1):
        ax.set_ylim(args.ylim[0])
    elif (len(args.ylim) == 2):
        ax.set_ylim(args.ylim[0], args.ylim[1])
    else:
        sys.exit('provide one or two args to --ylim')

if args.xlim:
    if (len(args.xlim) == 1):
        ax.set_xlim(args.xlim[0])
    elif (len(args.xlim) == 2):
        ax.set_xlim(args.xlim[0], args.xlim[1])
    else:
        sys.exit('provide one or two args to --xlim')

If you believe in DRY that probably makes your eyes burn: these two blocks are identical except that xlim replaces ylim everywhere in the second.

How can I refactor this to remove the duplication? Some kind of limit setting function which I call twice seems obvious, but how do I pass the fact that I want to call set_ylim in one case and set_xlim in other, for example?

Note that calling the script without specifying either or both of the --*lim arguments is totally valid and should behave as-if the corresponding set_*lim functions were never called (that is, as in the code above - although if the functions are called but with the identical effect as the sample that is fine as well).

BeeOnRope
  • 60,350
  • 16
  • 207
  • 386

2 Answers2

3

Functions are first-class objects. If you write ax.set_ylim without invoking it you get a reference to the set_ylim() function, bound to ax.

def check(name, lim, setter) 
    if not lim:
        return

    if (len(lim) == 1):
        setter(lim[0])
    elif (len(lim) == 2):
        setter(lim[0], lim[1])
    else:
        sys.exit(f'provide one or two args to {name}')

check('--ylim', args.ylim, ax.set_ylim)
check('--xlim', args.xlim, ax.set_xlim)
John Kugelman
  • 349,597
  • 67
  • 533
  • 578
1

There are a couple of options using argparse:

Define a custom action that limits the number of values to a specified range as in Python argparse: Is there a way to specify a range in nargs?

This has the advantage of handling the error in the context of your incoming arguments; and as your inputs should be acceptable you can just unpack the args.

# From: https://stackoverflow.com/a/4195302/3279716
def required_length(nmin,nmax):
    class RequiredLength(argparse.Action):
        def __call__(self, parser, args, values, option_string=None):
            if not nmin<=len(values)<=nmax:
                msg='argument "{f}" requires between {nmin} and {nmax} arguments'.format(
                    f=self.dest,nmin=nmin,nmax=nmax)
                raise argparse.ArgumentTypeError(msg)
            setattr(args, self.dest, values)
    return RequiredLength

# Make parser
parser=argparse.ArgumentParser(prog='PROG')
parser.add_argument('--xlim', nargs='+', type=int, action=required_length(1,2))
parser.add_argument('--ylim', nargs='+', type=int, action=required_length(1,2))

# Use args in code
ax.set_xlim(*args.xlim)
ax.set_ylim(*args.ylim)

Alternatively, you can allow inputs with length one or more (nargs=“+”) and then just check the length of your inputs manually and raise a parser error if you need:

# Make parser
parser=argparse.ArgumentParser(prog='PROG')
parser.add_argument('--xlim', nargs='+', type=int)
parser.add_argument('--ylim', nargs='+', type=int)

# Check args
if not 1 <= len(args.xlim) <= 2:
    parser.error("xlim must have length 1 or 2")
if not 1 <= len(args.ylim) <= 2:
    parser.error("ylim must have length 1 or 2")

# Use args in code
ax.set_xlim(*args.xlim)
ax.set_ylim(*args.ylim)
Alex
  • 6,610
  • 3
  • 20
  • 38
  • I didn't think that will work for the (usual) case where some of `--xlim` or `--ylim` aren't set, but it seems that `set_xlim` [is a no-op](https://matplotlib.org/3.1.1/api/_as_gen/matplotlib.axes.Axes.set_ylim.html) when passed 0 arguments. – BeeOnRope Nov 28 '19 at 02:09
  • You could also specify the default arguments to be `[None, None]` which should leave them unaffected. – Alex Nov 28 '19 at 02:13
  • Indeed, my realization now was that there is a way for leave them unspecified even while calling the `mlp` API functions. However, the default arg approach, which results in a call to (e.g.,) `set_xlim([None, None])` approach has no advantage over the approach you show in your answer, does it? Maybe I am missing something. – BeeOnRope Nov 28 '19 at 02:18
  • In both my approaches I think that both `xlim` and `ylim` are required because of `nargs=‘+’`. The None list gets around that. But, you could instead use `nargs=‘*’` and slightly altered checks – Alex Nov 28 '19 at 02:24
  • Ah, I missed that. Yes a key (undocumented) requirement is that leaving `--[x|y]lim` off needs to be supported and in fact is the most common way to call this script. Leaving them off should be "as if" the `set_*` methods were not called. I am updating the question to include the `argparse` lines to make that clearer. – BeeOnRope Nov 28 '19 at 02:29