Skip to content

Instantly share code, notes, and snippets.

@seblin
Last active March 18, 2019 16:52
Show Gist options
  • Save seblin/134d9c008114f161c1b4b6122d2801c4 to your computer and use it in GitHub Desktop.
Save seblin/134d9c008114f161c1b4b6122d2801c4 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# Copyright (c) 2018-2019 Sebastian Linke
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
A tool to ask for user input easily. It also includes validators for common
input queries.
The following examples will give you a quick overview:
>>> ask_input('Give a number: ', int)
Give a number: spam
Give a number: 42
42 # Result
>>> ask_input('Choose a number (1-10): ', Interval(1, 10), 'Please try again!')
Choose a number (1-10): 11
Please try again!
Choose a number (1-10): 0
Please try again!
Choose a number (1-10): 4
4 # Result
>>> ask_input('Choose a positive number: ', POS, 'Wrong input')
Choose a positive number: -5
Wrong input
Choose a positive number: -1
Wrong input
Choose a positive number: 42
42 # Result
>>> ask_input('Choose an answer (a-c): ', Choice('abc'))
Choose an answer (a-c): x
Choose an answer (a-c): d
Choose an answer (a-c): a
'a' # Result
>>> ask_input('Really want to quit? (yes/no): ', YES_NO, 'Type "yes" or "no"')
Really want to quit? (yes/no): not sure
Type "yes" or "no"
Really want to quit? (yes/no): um...
Type "yes" or "no"
Really want to quit? (yes/no): yes
True # Result
"""
__author__ = 'Sebastian Linke'
__version__ = '0.2-dev'
import sys
def ask_input(
prompt=None, converter=str, error_message=None,
error_prompt=None, interrupt_message=None, interrupt_value=None
):
"""
Ask for user input via *prompt* and return the result converted by
*converter* (which must take one argument). If the conversion failed,
print *error_message* and ask again until a correct value is given.
If *error_prompt* is a non-empty string, change the prompt to that
string after an invalid input.
On KeyboardInterrupt (Ctrl+C), print *interrupt_message* and return
the *interrupt_value*.
"""
while True:
try:
return converter(input(prompt or ''))
except ValueError:
if error_message:
print(error_message, file=sys.stderr)
if error_prompt:
prompt = error_prompt
except (KeyboardInterrupt, EOFError):
print()
if interrupt_message:
print(interrupt_message, file=sys.stderr)
return interrupt_value
def logged(converter, logger):
"""
Set a *logger* that must have been created by the standard library's
logging module. This makes *converter* log invalid input with logging
level DEBUG. Return the modified converter.
"""
if not isinstance(converter, BasicConverter):
# Use the Converter API to enable logging
converter = TypeConverter(converter)
converter._logger = logger
return converter
class BasicConverter:
"""
Base class for all converters. A subclass needs to implement convert().
An error during conversion must be indicated by calling fail().
"""
def __call__(self, value):
"""
Using this instance as a callable will invoke convert().
"""
return self.convert(value)
def __repr__(self):
"""
Return a nice representation for this instance.
"""
settings = ', '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
return f'{type(self).__name__}({settings})'
def convert(self, value):
"""
Return converted *value*. Raise ValueError if *value* is invalid.
This method must be implemented by a subclass.
"""
raise NotImplementedError
def fail(self, message=''):
"""
Raise ValueError with *message*.
"""
if hasattr(self, '_logger'):
self._logger.debug(message)
raise ValueError(message) from None
class YesNoConverter(BasicConverter):
"""
Converter for yes/no values.
"""
def __init__(
self, yes_values=('y', 'yes'), no_values=('n', 'no'), default=None
):
"""
Initialize a new YesNoConverter. *yes_values* and *no_values*
must be sequences holding the strings to be interpreted as a
True or False value. If *default* is not None then it will be
returned when the input is an empty string.
"""
self.yes_values = yes_values
self.no_values = no_values
self.default = default
def convert(self, value):
"""
Convert *value* to a boolean. Return True if it is a yes-value
or False if it is a no-value. Raise ValueError otherwise.
If input is an empty string and a default value is defined for
this instance then return the default value instead of failing.
"""
value = value.lower()
if not value:
if self.default is None:
self.fail('Input must be a non-empty string')
return self.default
if value in self.yes_values:
return True
if value in self.no_values:
return False
self.fail(f'Could not associate {value!r}')
class TypeConverter(BasicConverter):
"""
Converter using a given type.
"""
def __init__(self, value_type):
"""
Initialize a new TypeConverter. *value_type* must be a callable
which takes one argument and returns the desired type.
"""
self.value_type = value_type
def convert(self, value):
"""
Return *value* converted to the given type. Raise ValueError if
the conversion failed.
"""
try:
return self.value_type(value)
except Exception as error:
self.fail(f'Failed to convert: {error}')
class Interval(TypeConverter):
"""
Converter that checks whether a value is within an interval.
"""
def __init__(
self, low, high, exclusions=[], interval_type='closed', value_type=int
):
"""
Initialize a new Interval. *low* and *high* define the minimum and
maximum of the interval. *exclusions* is a sequence of values that
are not part of the interval.
Use *interval_type* to determine whether endpoints are included.
The default ("closed") means that both *low* and *high* are part
of the interval. "open" means that the endpoints are not included
in the interval. With "left-open" or "right-open" the left or right
endpoint will be excluded. Note that changing the interval type is
useful primarily when dealing with floats.
*value_type* defines the callable to convert the given value. It
takes one argument and must return the desired type.
"""
self.low = low
self.high = high
self.exclusions = exclusions
self.interval_type = interval_type
super().__init__(value_type)
def convert(self, value):
"""
Return *value* converted to the *value_type* of this instance.
Raise ValueError if the conversion failed or if *value* is not
within the Interval.
"""
value = super().convert(value)
if not self.includes(value):
self.fail(f'{value!r} not in interval')
return value
def includes(self, value):
"""
Return True if *value* is within the Interval, otherwise False.
"""
if value in self.exclusions:
return False
interval_type = self.interval_type.lower()
if interval_type == 'closed':
return self.low <= value <= self.high
if interval_type == 'open':
return self.low < value < self.high
if interval_type == 'left-open':
return self.low < value <= self.high
if interval_type == 'right-open':
return self.low <= value < self.high
self.fail(f'Unknown type: {interval_type!r}')
class Choice(TypeConverter):
"""
Converter that checks whether a value is a valid choice.
"""
def __init__(self, choices, value_type=str):
"""
Initialize a new instance. *choices* must be a container holding
the valid choices.
*value_type* defines the callable to convert the given value.
It takes one argument and must return the desired type.
"""
self.choices = set(choices)
super().__init__(value_type)
def convert(self, value):
"""
Return *value* converted to the *value_type* of this instance.
Raise ValueError if the conversion failed or if *value* is not
a valid choice.
"""
value = super().convert(value)
if value not in self.choices:
self.fail(f'Invalid choice: {value!r}')
return value
# Infinity
INF = float('inf')
# Simple validators to get integers
POS = POSITIVE = Interval(1, INF)
NEG = NEGATIVE = Interval(-INF, -1)
NONZERO = Interval(-INF, INF, exclusions=[0])
# Get a bool from yes/no input
YES_NO = YesNoConverter()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment