6
\$\begingroup\$

Earlier today I answered Timetable viewer, where one of my recommendations was to make a get_input function. However, this hasn't been the first time I've recommended making a bespoke get_input function.

This function should get user input, until it is valid. This may be any input, or any number in a specific range. The arguments customize how it works, such as working with strings or integers. The function should repeatedly prompt the user for specific input, until a valid input is entered. In the case case of valid input we should return that data, so the end user can use it.

Since it's quite a common function, with a lot of uses, I decided to make a generic version of the function, that should cover most use cases. I decided to make two functions, as strings don't need to be converted, however when changing to numbers, you'd have to duplicate a lot of arguments.

def build_input(
    prompt=None,
    *,
    errors=None,
    values=None,
    process=None,
    wanted=None,
    end=None
  ):
    prompt = prompt or ''
    process = process or (lambda v: v)
    errors = errors or tuple()
    if values is None:
        wanted = wanted or (lambda v: True)
    else:
        values = list(values)
        prompt += '[{}] '.format('/'.join(str(i) for i in values))
        wanted = wanted or (lambda v: v in values)
        values = set(values)

    if end is not None:
        prompt += end
    elif values:
        prompt += '\n> '

    def ask():
        while True:
            value = input(prompt)
            try:
                value = process(value)
            except errors:
                continue
            if wanted(value):
                return value
    return ask

def build_number_input(
    prompt=None,
    *,
    errors=ValueError,
    values=None,
    process=int,
    wanted=None,
    end=None
  ):
    return build_input(
        prompt,
        errors=errors,
        values=values,
        process=process,
        wanted=wanted,
        end=end
    )

I have a couple of concerns:

  • build_number_input spans 17 lines, for a function definition and a function call. Is there a better way to define this?
  • Are there a better way to style my function definitions? I think they may not be PEP8 compliant, but couldn't think of anything nicer to read.
  • Are there any common options that you think should be added?

Below are a couple of ways to use the above functions, and a sample run:

name_input = build_input('What is your name? ')
age_input = build_number_input('What is your age? ')
day_input = build_number_input('Day: ', values=[1, 2, 3, 4, 5])
again_input = build_input('Again? ', values='yn', process=lambda v:v.lower())

print('Hello, {}! You are {}.'.format(name_input(), age_input()))
print('You want day {}'.format(day_input()))
print('You {}want to go again.'.format('' if again_input() == 'y' else "don't "))
What is your name? Peilonrayz
What is your age? 20.5
What is your age? 20a
What is your age? 20
Hello, Peilonrayz! You are 20.
Day: [1/2/3/4/5] 
> 6
Day: [1/2/3/4/5] 
> 0
Day: [1/2/3/4/5] 
> 55
Day: [1/2/3/4/5] 
> five
Day: [1/2/3/4/5] 
> 5
You want day 5
Again? [y/n] 
> yeah
Again? [y/n] 
> Nope?
Again? [y/n] 
> N
You don't want to go again.
\$\endgroup\$
  • \$\begingroup\$ 20.5 is a number. If you want to force an int, use a different function name. \$\endgroup\$ – glenn jackman Sep 19 '17 at 20:16
  • \$\begingroup\$ @glennjackman Yeah, I should probably re-name it, or use float rather than int. \$\endgroup\$ – Peilonrayz Sep 19 '17 at 20:23
5
\$\begingroup\$

I do not like the functional approach of this, especially since you are "inheriting" the user_input by number_input. Here's an object-oriented version:

#! /usr/bin/env python3

class UserInput:

    def __init__(self, prompt=None, *, errors=None, values=None, process=None,
                 wanted=None, default=None, end=None):
        self.prompt = prompt
        self.errors = errors
        self.values = values
        self.process = process
        self.wanted = wanted
        self.default = default
        self.end = end

    def __call__(self):
        return self.get()

    @property
    def prompt(self):
        """Returns the prompt."""
        prompt = self._prompt or ''

        if self.values:
            prompt += '[{}] '.format('/'.join(str(i) for i in self.values))

        if self.end is not None:
            prompt += self.end
        elif self.values:
            prompt += '\n> '

        return prompt

    @prompt.setter
    def prompt(self, prompt):
        self._prompt = prompt

    @property
    def errors(self):
        return self._errors or tuple()

    @errors.setter
    def errors(self, errors):
        self._errors = errors

    @property
    def values(self):
        return self._values

    @values.setter
    def values(self, values):
        self._values = set(values) if values is not None else None

    @property
    def process(self):
        return self._process or (lambda v: v)

    @process.setter
    def process(self, process):
        self._process = process

    @property
    def wanted(self):
        """Returns the wanted callback."""
        if self._wanted:
            return self._wanted
        elif self.values is None:
            return lambda _: True

        return lambda value: value in self.values

    @wanted.setter
    def wanted(self, wanted):
        self._wanted = wanted

    def get(self):
        """Retrieves the user input."""
        while True:
            try:
                value = input(self.prompt)
            except (EOFError, KeyboardInterrupt):
                if self.default is not None:
                    return self.default

                raise

            try:
                value = self.process(value)
            except self.errors:
                continue

            if self.wanted(value):
                return value


class NumberInput(UserInput):

    def __init__(self, prompt=None, *, errors=ValueError, values=None,
                 process=int, wanted=None, end=None):
        super().__init__(
            prompt, errors=errors, values=values, process=process,
            wanted=wanted, end=end)


def main():

    name_input = UserInput('What is your name? ')
    age_input = NumberInput('What is your age? ')
    day_input = NumberInput('Day: ', values=[1, 2, 3, 4, 5])
    again_input = UserInput('Again? ', values='yn', process=lambda v:v.lower())

    print('Hello, {}! You are {}.'.format(name_input(), age_input()))
    print('You want day {}'.format(day_input()))
    print('You {}want to go again.'.format('' if again_input() == 'y' else "don't "))


if __name__ == '__main__':
    main()

I also added an additional keyword argument default the value of which will be returned on KeyboardInterrupts and EOFErrors (which occur on pressing [Ctrl]+[D]) iff specified.

Regarding the function of the code itself, I think that it is trying to be a jack-of-all-trades-device and doing a bad job at it while violating the YAGNI principle.
User input is highly variable and dynamic. If you think of a simple yes/no question where yes should be default (if one just hits enter) and the prompt should highlight the default value like in the pacman prompt below, this is really hard to implement using your functions (or classes).

$ LANG=en_US sudo pacman -S kate
resolving dependencies...
:: There are 2 providers available for phonon-qt5-backend:
:: Repository extra
   1) phonon-qt5-gstreamer  2) phonon-qt5-vlc

Enter a number (default=1):   # Just hit [Return] here.
looking for conflicting packages...
warning: dependency cycle detected:
warning: phonon-qt5-gstreamer will be installed before its phonon-qt5 dependency

Packages (43) attica-qt5-5.38.0-1  editorconfig-core-c-0.12.1-2  kactivities-5.38.0-1  karchive-5.38.0-1  kauth-5.38.0-1  kbookmarks-5.38.0-1  kcodecs-5.38.0-1  kcompletion-5.38.0-1  kconfig-5.38.0-1
              kconfigwidgets-5.38.0-1  kcoreaddons-5.38.0-1  kcrash-5.38.0-1  kdbusaddons-5.38.0-1  kglobalaccel-5.38.1-1  kguiaddons-5.38.0-1  ki18n-5.38.0-1  kiconthemes-5.38.0-1  kio-5.38.0-1
              kitemmodels-5.38.0-1  kitemviews-5.38.0-1  kjobwidgets-5.38.0-1  knewstuff-5.38.0-1  knotifications-5.38.0-1  kparts-5.38.0-1  kservice-5.38.0-1  ktexteditor-5.38.0-1  ktextwidgets-5.38.0-1
              kwallet-5.38.0-1  kwidgetsaddons-5.38.0-1  kwindowsystem-5.38.0-1  kxmlgui-5.38.0-1  libdbusmenu-qt5-0.9.3+16.04.20160218-1  media-player-info-22-2  phonon-qt5-4.9.1-4
              phonon-qt5-gstreamer-4.9.0-3  polkit-qt5-0.112.0+git20160226-1  qt5-multimedia-5.9.1-2  qt5-speech-5.9.1-2  solid-5.38.0-1  sonnet-5.38.0-1  syntax-highlighting-5.38.0-1  threadweaver-5.38.0-1
              kate-17.08.1-1

Total Download Size:    37.95 MiB
Total Installed Size:  146.67 MiB

:: Proceed with installation? [Y/n]  # Default option is capitalized.
\$\endgroup\$
0
\$\begingroup\$

A few things that can save you in LOC (Lines of Code)

Recursion: keep trying to ask for input, recursive each time. that gets rid of a whole loop

Function Alias: build_input accepts values and saves them. When the callable is called, it will call the appropriate function with your settings saved.

Default args: can be set in the function definition.

#returns a callable
def build_input(*args,**kwargs):
    def a():
        return _build_input(*args,**kwargs)
    return a

def _build_input(prompt = None, values = None, process = lambda a : a, end = ''):
    try:

        text = process(input(prompt + end))
        if(values != None):
            return text if text in values else _build_input(prompt,values,process,end)
        return text
    except:
        return _build_input(prompt,values,process,end)

#this is also a better way to build_number_input, by using kwargs
## ** is the packing/unpacking directive, so you can take the settings, set some defaults, and pass to the next funciton
def build_number_input(*args,**kwargs):
    kwargs.update({'process':int})
    return build_input(*args,**kwargs)

name_input = build_input('what is your name',process = str)
age_input = build_input('what is your age',process = int,values = [12,2,4])
day_input = build_input('Day: ',process = int,values = [1,2,3,4,5])
again_input = build_input('Again?',process = lambda v:v.lower(),values = 'yn')
num_input = build_number_input(prompt='what number?')

num_input()
age_input()
day_input()
again_input()

not sure what the errors directive was, I don't really think you care what the error is, if there is an error you just ask again.

\$\endgroup\$
  • \$\begingroup\$ Whilst I want to reduce LOC, that's not the main aim. Your code is more annoying to use, as you have to specify the same stuff over and over. The errors variable is very important, I don't want to silence ctrl-c. And recursion uses \$O(n)\$ memory, as you build a call stack, and you can exceed the maximum stack limit, causing errors with your code. In all, I don't see how your code is better than the original. Explaining how your changes are better would help sway me to your side. \$\endgroup\$ – Peilonrayz Sep 19 '17 at 23:17
  • \$\begingroup\$ I'm just a fan of reduced LOC. I guess the best changes to your code would be prompt = prompt or '' process = process or (lambda v: v) errors = errors or tuple() defined in the function definition. Also, I think build_number_input is cleaner and if you change kwargs later its adaptable. I think that there are aspects of my code that make improvements on yours, I think that there is likely things you can take from my code to make yours better -- I might have just got a little carried away on LOC. \$\endgroup\$ – Joshua Klein Sep 19 '17 at 23:58
  • \$\begingroup\$ also you convert values to a list, then a set. Why did you translate it to a set? You do not need to convert it to a list, a python string is already an iterate. \$\endgroup\$ – Joshua Klein Sep 20 '17 at 0:02
  • \$\begingroup\$ It'd be clearer if you could add your first comment to your answer, as I don't really understand you. \$\endgroup\$ – Peilonrayz Sep 20 '17 at 8:10
  • \$\begingroup\$ If values is an iterator, rather than an iterable, then it will be consumed when building the prompt. Converting it to a list, means that we can loop through it twice, once to make the prompt - which needs ordered data too, the other to make the set. The set is used for \$O(1)\$, rather than \$O(n)\$, lookup. \$\endgroup\$ – Peilonrayz Sep 20 '17 at 8:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.