49

I'd like to use argparse on Python 2.7 to require that one of my script's parameters be between the range of 0.0 and 1.0. Does argparse.add_argument() support this?

OneCricketeer
  • 179,855
  • 19
  • 132
  • 245
Dolan Antenucci
  • 15,432
  • 17
  • 74
  • 100

5 Answers5

54

The type parameter to add_argument just needs to be a callable object that takes a string and returns a converted value. You can write a wrapper around float that checks its value and raises an error if it is out of range.

def restricted_float(x):
    try:
        x = float(x)
    except ValueError:
        raise argparse.ArgumentTypeError("%r not a floating-point literal" % (x,))

    if x < 0.0 or x > 1.0:
        raise argparse.ArgumentTypeError("%r not in range [0.0, 1.0]"%(x,))
    return x

p = argparse.ArgumentParser()
p.add_argument("--arg", type=restricted_float)
chepner
  • 497,756
  • 71
  • 530
  • 681
  • I was originally going to choose FJ's as the accepted answer, simply because I like the "cleanness" of it (obviously arguable), but the simplicity of this won me over, and I'm using it in my code. Thanks! – Dolan Antenucci Aug 24 '12 at 22:33
  • 1
    Note: `restricted_float` should probably be replaced by whatever it represents as it will be shown in error messages. For example, I used this pattern in a toy project [`geocodertools`](https://pypi.python.org/pypi/geocodertools) and called it `longitude` and `latitude`. – Martin Thoma Mar 23 '15 at 19:35
  • @Danijel What do you mean? There are a lot of different types you might want to define; `argparse` can't know about them all. This *is* how `argparse` supports converting the string argument to whatever value you want. – chepner Oct 24 '19 at 14:47
  • I was expecting that `float` would be supported as `int` is with regards to `range`. Am I missing something? – Danijel Oct 25 '19 at 10:22
  • @Danijel A range of floats wouldn't be terribly useful. What's the next float after `0.5`? `0.6`, or `0.51`, or `0.501`, or...? While technically discrete, `float` isn't enumerable in any useful way. – chepner Oct 25 '19 at 12:02
  • An interval type that *only* supported inclusion, rather than enumeration, might be useful, but Python doesn't provide one. – chepner Oct 25 '19 at 12:05
  • @chepner Thanks. I was thinking about a from-to range of floats, not individual values. – Danijel Oct 28 '19 at 07:09
32

Here is a method that uses the choices parameter to add_argument, with a custom class that is considered "equal" to any float within the specified range:

import argparse

class Range(object):
    def __init__(self, start, end):
        self.start = start
        self.end = end
    def __eq__(self, other):
        return self.start <= other <= self.end

parser = argparse.ArgumentParser()
parser.add_argument('--foo', type=float, choices=[Range(0.0, 1.0)])
Andrew Clark
  • 202,379
  • 35
  • 273
  • 306
  • 6
    I like this one because it leaves the exception raising to argparse. Thanks! – Dolan Antenucci Aug 24 '12 at 21:59
  • 5
    One suggestion: have your `Range` class implement the `__contains__` method; then you can say `choices=Range(0.0, 1.0)` instead of wrapping it in a list. – chepner Aug 24 '12 at 22:01
  • 2
    The `__contains__`-approach gives `ValueError: length of metavar tuple does not match nargs` using Python 3.4. Otherwise it works well and I implemented `__repr__` to return `'{0}-{1}'.format(self.start, self.end)` for prettier help text as well. – RickardSjogren Apr 06 '16 at 08:31
  • 1
    Andrew's answer combined with @RickardSjogren comment gives a great result. Thanks! – James McCormac Nov 06 '16 at 19:30
  • Even with `__contains__` redefined, in Python 3.5.2 I get: `TypeError: 'Range' object is not iterable` if I don't put it in a single-element list. Solved redefining: `def __getitem__(self, index): if index == 0: return self else: raise IndexError()` – PJ_Finnegan May 16 '18 at 15:06
  • I tried using @PJ_Finnegan `__getitem__` addition, but it did not work in python 3.6.5. I still get `ValueError: length of metavar tuple does not match nargs` – rasen58 Jul 10 '19 at 22:42
8

Adding str makes that the boundaries are visuable in the help.

import argparse

class Range(object):
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __eq__(self, other):
        return self.start <= other <= self.end

    def __contains__(self, item):
        return self.__eq__(item)

    def __iter__(self):
        yield self

    def __str__(self):
        return '[{0},{1}]'.format(self.start, self.end)

parser = argparse.ArgumentParser()
parser.add_argument('--foo', type=float, choices=Range(0.0, 1.0))
parser.add_argument('--bar', type=float, choices=[Range(0.0, 1.0), Range(2.0,3.0)])
Bob Baeck
  • 81
  • 1
  • 3
  • 2
    For me the argparse error message seems to show the class's repr so I just changed your `__str__` method to `__repr__` to get a nice error message. – Chris Jan 23 '21 at 00:43
2

The argparse.add_argument call expects an iterable as 'choices' parameter. So what about adding the iterable property to the Range class above. So both scenarios could be used:

import argparse

class Range(object):
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __eq__(self, other):
        return self.start <= other <= self.end

    def __contains__(self, item):
        return self.__eq__(item)

    def __iter__(self):
        yield self

parser = argparse.ArgumentParser()
parser.add_argument('--foo', type=float, choices=Range(0.0, 1.0))
parser.add_argument('--bar', type=float, choices=[Range(0.0, 1.0), Range(2.0,3.0)])
0

In case you would also like to in-/exclude the boundaries of the float range(s), I have extended the codings of above as follows:

from typing import Generator
import re
import argparse


class Range(object):
    
    def __init__(self, scope: str):
        r = re.compile(
            r'^([\[\]]) *([-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[Ee][+-]?\d+)?) *'
            r', *([-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[Ee][+-]?\d+)?) *([\[\]])$'
        )
        try:
            i = [j for j in re.findall(r, scope)[0]]
            self.__start, self.__end = float(i[1]), float(i[2])
            if self.__start >= self.__end:
                raise ArithmeticError
        except (IndexError, ArithmeticError):
            raise SyntaxError("An error occurred with the range provided!")
        self.__st = '{}{{}},{{}}{}'.format(i[0], i[3])
        self.__lamba = "lambda start, end, item: start {0} item {1} end".format(
            {'[': '<=', ']': '<'}[i[0]],
            {']': '<=', '[': '<'}[i[3]]
        )    
    def __eq__(self, item: float) -> bool: return eval(self.__lamba)(
        self.__start,
        self.__end,
        item
    )
    def __contains__(self, item: float) -> bool: return self.__eq__(item)
    def __iter__(self) -> Generator[object, None, None]: yield self
    def __str__(self) -> str: return self.__st.format(self.__start, self.__end)
    def __repr__(self) -> str: return self.__str__()

parser = argparse.ArgumentParser()
parser.add_argument('--foo', type=float, choices=Range('[0., 1.0['))
parser.add_argument('--bar', type=float, choices=[Range(']0., 1.0['), Range(']2.0E0, 3.0e0]')])