Repository: pallets/click

Issue:
FloatRange: add options that exclude each endpoint
Thanks for developing this beautiful program.

At this time, FloatRange assumes that the passing argument value is always in *closed* interval `[a, b]`, not *open* one. Sometimes, this may be harsh when the user want to accept a floating number exclude two endpoints, `a` and `b`. A user who uses IntRange can solve this problem fairly easily, but FloatRange cannot.

So, I think FloatRange should have the options which can exclude each endpoint. May I PR for this?


Discussion:

Resolving patch:
diff --git a/src/click/core.py b/src/click/core.py
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -23,6 +23,7 @@
 from .termui import confirm
 from .termui import prompt
 from .termui import style
+from .types import _NumberRangeBase
 from .types import BOOL
 from .types import convert_type
 from .types import IntRange
@@ -2060,9 +2061,11 @@ def _write_opts(opts):
                 default_string = self.default
             extra.append(f"default: {default_string}")
 
-        if isinstance(self.type, IntRange):
-            if self.type.min is not None and self.type.max is not None:
-                extra.append(f"{self.type.min}-{self.type.max} inclusive")
+        if isinstance(self.type, _NumberRangeBase):
+            range_str = self.type._describe_range()
+
+            if range_str:
+                extra.append(range_str)
 
         if self.required:
             extra.append("required")
diff --git a/src/click/types.py b/src/click/types.py
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -255,138 +255,150 @@ def __repr__(self):
         return "DateTime"
 
 
-class IntParamType(ParamType):
-    name = "integer"
+class _NumberParamTypeBase(ParamType):
+    _number_class = None
 
     def convert(self, value, param, ctx):
         try:
-            return int(value)
+            return self._number_class(value)
         except ValueError:
-            self.fail(f"{value} is not a valid integer", param, ctx)
-
-    def __repr__(self):
-        return "INT"
-
+            self.fail(f"{value} is not a valid {self.name}", param, ctx)
 
-class IntRange(IntParamType):
-    """A parameter that works similar to :data:`click.INT` but restricts
-    the value to fit into a range.  The default behavior is to fail if the
-    value falls outside the range, but it can also be silently clamped
-    between the two edges.
 
-    See :ref:`ranges` for an example.
-    """
-
-    name = "integer range"
-
-    def __init__(self, min=None, max=None, clamp=False):
+class _NumberRangeBase(_NumberParamTypeBase):
+    def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False):
         self.min = min
         self.max = max
+        self.min_open = min_open
+        self.max_open = max_open
         self.clamp = clamp
 
     def convert(self, value, param, ctx):
+        import operator
+
         rv = super().convert(value, param, ctx)
+        lt_min = self.min is not None and (
+            operator.le if self.min_open else operator.lt
+        )(rv, self.min)
+        gt_max = self.max is not None and (
+            operator.ge if self.max_open else operator.gt
+        )(rv, self.max)
 
         if self.clamp:
-            if self.min is not None and rv < self.min:
-                return self.min
-            if self.max is not None and rv > self.max:
-                return self.max
-        if (
-            self.min is not None
-            and rv < self.min
-            or self.max is not None
-            and rv > self.max
-        ):
-            if self.min is None:
-                self.fail(
-                    f"{rv} is bigger than the maximum valid value {self.max}.",
-                    param,
-                    ctx,
-                )
-            elif self.max is None:
-                self.fail(
-                    f"{rv} is smaller than the minimum valid value {self.min}.",
-                    param,
-                    ctx,
-                )
-            else:
-                self.fail(
-                    f"{rv} is not in the valid range of {self.min} to {self.max}.",
-                    param,
-                    ctx,
-                )
+            if lt_min:
+                return self._clamp(self.min, 1, self.min_open)
+
+            if gt_max:
+                return self._clamp(self.max, -1, self.max_open)
+
+        if lt_min or gt_max:
+            self.fail(f"{rv} is not in the range {self._describe_range()}.", param, ctx)
+
         return rv
 
+    def _clamp(self, bound, dir, open):
+        """Find the valid value to clamp to bound in the given
+        direction.
+
+        :param bound: The boundary value.
+        :param dir: 1 or -1 indicating the direction to move.
+        :param open: If true, the range does not include the bound.
+        """
+        raise NotImplementedError
+
+    def _describe_range(self):
+        """Describe the range for use in help text."""
+        if self.min is None:
+            op = "<" if self.max_open else "<="
+            return f"x{op}{self.max}"
+
+        if self.max is None:
+            op = ">" if self.min_open else ">="
+            return f"x{op}{self.min}"
+
+        lop = "<" if self.min_open else "<="
+        rop = "<" if self.max_open else "<="
+        return f"{self.min}{lop}x{rop}{self.max}"
+
     def __repr__(self):
-        return f"IntRange({self.min}, {self.max})"
+        clamp = " clamped" if self.clamp else ""
+        return f"<{type(self).__name__} {self._describe_range()}{clamp}>"
 
 
-class FloatParamType(ParamType):
-    name = "float"
+class IntParamType(_NumberParamTypeBase):
+    name = "integer"
+    _number_class = int
 
-    def convert(self, value, param, ctx):
-        try:
-            return float(value)
-        except ValueError:
-            self.fail(f"{value} is not a valid floating point value", param, ctx)
+    def __repr__(self):
+        return "INT"
+
+
+class IntRange(_NumberRangeBase, IntParamType):
+    """Restrict an :data:`click.INT` value to a range of accepted
+    values. See :ref:`ranges`.
+
+    If ``min`` or ``max`` are not passed, any value is accepted in that
+    direction. If ``min_open`` or ``max_open`` are enabled, the
+    corresponding boundary is not included in the range.
+
+    If ``clamp`` is enabled, a value outside the range is clamped to the
+    boundary instead of failing.
+
+    .. versionchanged:: 8.0
+        Added the ``min_open`` and ``max_open`` parameters.
+    """
+
+    name = "integer range"
+
+    def _clamp(self, bound, dir, open):
+        if not open:
+            return bound
+
+        return bound + dir
+
+
+class FloatParamType(_NumberParamTypeBase):
+    name = "float"
+    _number_class = float
 
     def __repr__(self):
         return "FLOAT"
 
 
-class FloatRange(FloatParamType):
-    """A parameter that works similar to :data:`click.FLOAT` but restricts
-    the value to fit into a range.  The default behavior is to fail if the
-    value falls outside the range, but it can also be silently clamped
-    between the two edges.
+class FloatRange(_NumberRangeBase, FloatParamType):
+    """Restrict a :data:`click.FLOAT` value to a range of accepted
+    values. See :ref:`ranges`.
+
+    If ``min`` or ``max`` are not passed, any value is accepted in that
+    direction. If ``min_open`` or ``max_open`` are enabled, the
+    corresponding boundary is not included in the range.
 
-    See :ref:`ranges` for an example.
+    If ``clamp`` is enabled, a value outside the range is clamped to the
+    boundary instead of failing. This is not supported if either
+    boundary is marked ``open``.
+
+    .. versionchanged:: 8.0
+        Added the ``min_open`` and ``max_open`` parameters.
     """
 
     name = "float range"
 
-    def __init__(self, min=None, max=None, clamp=False):
-        self.min = min
-        self.max = max
-        self.clamp = clamp
+    def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False):
+        super().__init__(
+            min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp
+        )
 
-    def convert(self, value, param, ctx):
-        rv = super().convert(value, param, ctx)
+        if (min_open or max_open) and clamp:
+            raise TypeError("Clamping is not supported for open bounds.")
 
-        if self.clamp:
-            if self.min is not None and rv < self.min:
-                return self.min
-            if self.max is not None and rv > self.max:
-                return self.max
-        if (
-            self.min is not None
-            and rv < self.min
-            or self.max is not None
-            and rv > self.max
-        ):
-            if self.min is None:
-                self.fail(
-                    f"{rv} is bigger than the maximum valid value {self.max}.",
-                    param,
-                    ctx,
-                )
-            elif self.max is None:
-                self.fail(
-                    f"{rv} is smaller than the minimum valid value {self.min}.",
-                    param,
-                    ctx,
-                )
-            else:
-                self.fail(
-                    f"{rv} is not in the valid range of {self.min} to {self.max}.",
-                    param,
-                    ctx,
-                )
-        return rv
+    def _clamp(self, bound, dir, open):
+        if not open:
+            return bound
 
-    def __repr__(self):
-        return f"FloatRange({self.min}, {self.max})"
+        # Could use Python 3.9's math.nextafter here, but clamping an
+        # open float range doesn't seem to be particularly useful. It's
+        # left up to the user to write a callback to do it if needed.
+        raise RuntimeError("Clamping is not supported for open bounds.")
 
 
 class BoolParamType(ParamType):


Test patch:
diff --git a/tests/test_basic.py b/tests/test_basic.py
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -412,76 +412,6 @@ def cli(start_date):
     assert result.output == "2010-06-05T00:00:00\n"
 
 
-def test_int_range_option(runner):
-    @click.command()
-    @click.option("--x", type=click.IntRange(0, 5))
-    def cli(x):
-        click.echo(x)
-
-    result = runner.invoke(cli, ["--x=5"])
-    assert not result.exception
-    assert result.output == "5\n"
-
-    result = runner.invoke(cli, ["--x=6"])
-    assert result.exit_code == 2
-    assert (
-        "Invalid value for '--x': 6 is not in the valid range of 0 to 5.\n"
-        in result.output
-    )
-
-    @click.command()
-    @click.option("--x", type=click.IntRange(0, 5, clamp=True))
-    def clamp(x):
-        click.echo(x)
-
-    result = runner.invoke(clamp, ["--x=5"])
-    assert not result.exception
-    assert result.output == "5\n"
-
-    result = runner.invoke(clamp, ["--x=6"])
-    assert not result.exception
-    assert result.output == "5\n"
-
-    result = runner.invoke(clamp, ["--x=-1"])
-    assert not result.exception
-    assert result.output == "0\n"
-
-
-def test_float_range_option(runner):
-    @click.command()
-    @click.option("--x", type=click.FloatRange(0, 5))
-    def cli(x):
-        click.echo(x)
-
-    result = runner.invoke(cli, ["--x=5.0"])
-    assert not result.exception
-    assert result.output == "5.0\n"
-
-    result = runner.invoke(cli, ["--x=6.0"])
-    assert result.exit_code == 2
-    assert (
-        "Invalid value for '--x': 6.0 is not in the valid range of 0 to 5.\n"
-        in result.output
-    )
-
-    @click.command()
-    @click.option("--x", type=click.FloatRange(0, 5, clamp=True))
-    def clamp(x):
-        click.echo(x)
-
-    result = runner.invoke(clamp, ["--x=5.0"])
-    assert not result.exception
-    assert result.output == "5.0\n"
-
-    result = runner.invoke(clamp, ["--x=6.0"])
-    assert not result.exception
-    assert result.output == "5\n"
-
-    result = runner.invoke(clamp, ["--x=-1.0"])
-    assert not result.exception
-    assert result.output == "0\n"
-
-
 def test_required_option(runner):
     @click.command()
     @click.option("--foo", required=True)
diff --git a/tests/test_options.py b/tests/test_options.py
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -64,10 +64,7 @@ def cli(v):
 
     result = runner.invoke(cli, ["-vvvv"])
     assert result.exception
-    assert (
-        "Invalid value for '-v': 4 is not in the valid range of 0 to 3."
-        in result.output
-    )
+    assert "Invalid value for '-v': 4 is not in the range 0<=x<=3." in result.output
 
     result = runner.invoke(cli, [])
     assert not result.exception
@@ -216,14 +213,20 @@ def cmd(username):
     assert "(current user)" in result.output
 
 
-def test_intrange_default_help_text(runner):
-    @click.command()
-    @click.option("--count", type=click.IntRange(1, 32), show_default=True, default=1)
-    def cmd(arg):
-        click.echo(arg)
-
-    result = runner.invoke(cmd, ["--help"])
-    assert "1-32 inclusive" in result.output
+@pytest.mark.parametrize(
+    ("type", "expect"),
+    [
+        (click.IntRange(1, 32), "1<=x<=32"),
+        (click.IntRange(1, 32, min_open=True, max_open=True), "1<x<32"),
+        (click.IntRange(1), "x>=1"),
+        (click.IntRange(max=32), "x<=32"),
+    ],
+)
+def test_intrange_default_help_text(runner, type, expect):
+    option = click.Option(["--count"], type=type, show_default=True, default=1)
+    context = click.Context(click.Command("test"))
+    result = option.get_help_record(context)[1]
+    assert expect in result
 
 
 def test_toupper_envvar_prefix(runner):
diff --git a/tests/test_types.py b/tests/test_types.py
new file mode 100644
--- /dev/null
+++ b/tests/test_types.py
@@ -0,0 +1,56 @@
+import pytest
+
+import click
+
+
+@pytest.mark.parametrize(
+    ("type", "value", "expect"),
+    [
+        (click.IntRange(0, 5), "3", 3),
+        (click.IntRange(5), "5", 5),
+        (click.IntRange(5), "100", 100),
+        (click.IntRange(max=5), "5", 5),
+        (click.IntRange(max=5), "-100", -100),
+        (click.IntRange(0, clamp=True), "-1", 0),
+        (click.IntRange(max=5, clamp=True), "6", 5),
+        (click.IntRange(0, min_open=True, clamp=True), "0", 1),
+        (click.IntRange(max=5, max_open=True, clamp=True), "5", 4),
+        (click.FloatRange(0.5, 1.5), "1.2", 1.2),
+        (click.FloatRange(0.5, min_open=True), "0.51", 0.51),
+        (click.FloatRange(max=1.5, max_open=True), "1.49", 1.49),
+        (click.FloatRange(0.5, clamp=True), "-0.0", 0.5),
+        (click.FloatRange(max=1.5, clamp=True), "inf", 1.5),
+    ],
+)
+def test_range(type, value, expect):
+    assert type.convert(value, None, None) == expect
+
+
+@pytest.mark.parametrize(
+    ("type", "value", "expect"),
+    [
+        (click.IntRange(0, 5), "6", "6 is not in the range 0<=x<=5."),
+        (click.IntRange(5), "4", "4 is not in the range x>=5."),
+        (click.IntRange(max=5), "6", "6 is not in the range x<=5."),
+        (click.IntRange(0, 5, min_open=True), 0, "0<x<=5"),
+        (click.IntRange(0, 5, max_open=True), 5, "0<=x<5"),
+        (click.FloatRange(0.5, min_open=True), 0.5, "x>0.5"),
+        (click.FloatRange(max=1.5, max_open=True), 1.5, "x<1.5"),
+    ],
+)
+def test_range_fail(type, value, expect):
+    with pytest.raises(click.BadParameter) as exc_info:
+        type.convert(value, None, None)
+
+    assert expect in exc_info.value.message
+
+
+def test_float_range_no_clamp_open():
+    with pytest.raises(TypeError):
+        click.FloatRange(0, 1, max_open=True, clamp=True)
+
+    sneaky = click.FloatRange(0, 1, max_open=True)
+    sneaky.clamp = True
+
+    with pytest.raises(RuntimeError):
+        sneaky.convert("1.5", None, None)