Last active
March 18, 2019 16:52
-
-
Save seblin/134d9c008114f161c1b4b6122d2801c4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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