Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow open intervals on float range #1303

Merged
merged 2 commits into from
Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ Unreleased
:issue:`1582`
- If validation fails for a prompt with ``hide_input=True``, the value
is not shown in the error message. :issue:`1460`
- An ``IntRange`` option shows the accepted range in its help text.
:issue:`1525`
- An ``IntRange`` or ``FloatRange`` option shows the accepted range in
its help text. :issue:`1525`, :pr:`1303`
- ``IntRange`` and ``FloatRange`` bounds can be open (``<``) instead
of closed (``<=``) by setting ``min_open`` and ``max_open``. Error
messages have changed to reflect this. :issue:`1100`
- An option defined with duplicate flag names (``"--foo/--foo"``)
raises a ``ValueError``. :issue:`1465`
- ``echo()`` will not fail when using pytest's ``capsys`` fixture on
Expand Down
37 changes: 16 additions & 21 deletions docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -775,39 +775,34 @@ boolean flag you need to separate it with ``;`` instead of ``/``:
Range Options
-------------

A special mention should go to the :class:`IntRange` type, which works very
similarly to the :data:`INT` type, but restricts the value to fall into a
specific range (inclusive on both edges). It has two modes:
The :class:`IntRange` type extends the :data:`INT` type to ensure the
value is contained in the given range. The :class:`FloatRange` type does
the same for :data:`FLOAT`.

- the default mode (non-clamping mode) where a value that falls outside
of the range will cause an error.
- an optional clamping mode where a value that falls outside of the
range will be clamped. This means that a range of ``0-5`` would
return ``5`` for the value ``10`` or ``0`` for the value ``-1`` (for
example).
If ``min`` or ``max`` is omitted, that side is *unbounded*. Any value in
that direction is accepted. By default, both bounds are *closed*, which
means the boundary value is included in the accepted range. ``min_open``
and ``max_open`` can be used to exclude that boundary from the range.

Example:
If ``clamp`` mode is enabled, a value that is outside the range is set
to the boundary instead of failing. For example, the range ``0, 5``
would return ``5`` for the value ``10``, or ``0`` for the value ``-1``.
When using :class:`FloatRange`, ``clamp`` can only be enabled if both
bounds are *closed* (the default).

.. click:example::

@click.command()
@click.option('--count', type=click.IntRange(0, 20, clamp=True))
@click.option('--digit', type=click.IntRange(0, 10))
@click.option("--count", type=click.IntRange(0, 20, clamp=True))
@click.option("--digit", type=click.IntRange(0, 9))
def repeat(count, digit):
click.echo(str(digit) * count)

if __name__ == '__main__':
repeat()

And from the command line:

.. click:run::

invoke(repeat, args=['--count=1000', '--digit=5'])
invoke(repeat, args=['--count=1000', '--digit=12'])
invoke(repeat, args=['--count=100', '--digit=5'])
invoke(repeat, args=['--count=6', '--digit=12'])

If you pass ``None`` for any of the edges, it means that the range is open
at that side.

Callbacks for Validation
------------------------
Expand Down
9 changes: 6 additions & 3 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
210 changes: 111 additions & 99 deletions src/click/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading