19

I want to make a script that would prompt you for a number between 0-100 and then give you the grade based on the number.

I'd like it in bash.

PS3='Please enter your choice: '
(Something here)

do
case $
    "0-59")
        echo "F"
        ;;
    "60-69")
        echo "D"
        ;;
    "70-79")
        echo "C"
        ;;
    "Quit")
        break
        ;;
    *) echo invalid option;;
esac
done
0

9 Answers 9

23

You already have the basic idea. If you want to code this in bash (which is a reasonable choice since it is the default shell on Ubuntu and most other Linuxes), you can't use case because it doesn't understand ranges. Instead, you could use if/else:

#!/usr/bin/env bash

read -p "Please enter your choice: " response

## If the response given did not consist entirely of digits
if [[ ! $response =~ ^[0-9]*$ ]]
then
    ## If it was Quit or quit, exit
    [[ $response =~ [Qq]uit ]] && exit
    ## If it wasn't quit or Quit but wasn't a number either,
    ## print an error message and quit.
    echo "Please enter a number between 0 and 100 or \"quit\" to exit" && exit
fi
## Process the other choices
if [ $response -le 59 ]
then
    echo "F"
elif [ $response -le 69 ]
then
    echo "D"
elif  [ $response -le 79 ]
then
    echo "C"
elif  [ $response -le 89 ]
then
    echo "B"
elif [ $response -le 100 ]
then
    echo "A"
elif [ $response -gt 100 ]
then
    echo "Please enter a number between 0 and 100"
     exit
fi
2
  • 4
    A bunch of those -ge tests can be eliminated, presumably, since you're using elif. And no love for (( $response < X ))?
    – muru
    May 9, 2015 at 12:11
  • 2
    @muru true, thanks. I was stuck thinking in number ranges but there was no reason to. As for (( $response < X )), sure, but I find this clearer and the OP is obviously new to bash scripting.
    – terdon
    May 9, 2015 at 12:17
20

Brevity vs. Readability: A Middle Ground

As you've seen, this problem admits to solutions that are moderately long and somewhat repetitive but highly readable (terdon's and A.B.'s bash answers), as well as those that are very short but non-intuitive and much less self-documenting (Tim's python and bash answers and glenn jackman's perl answer). All these approaches are valuable.

You can also solve this problem with code in the middle of the continuum between compactness and readability. This approach is almost as readable as the longer solutions, with a length closer to the small, esoteric solutions.

#!/usr/bin/env bash

read -erp 'Enter numeric grade (q to quit): '
case $REPLY in [qQ]) exit;; esac

declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100

for letter in F D C B A; do
    ((REPLY <= cutoffs[$letter])) && { echo $letter; exit; }
done
echo "Grade out of range."

In this bash solution, I've included some blank lines to enhance readability, but you could remove them if you wanted it even shorter.

Blank lines included, this is actually only slightly shorter than a compactified, still pretty readable variant of A.B.'s bash solution. Its main advantages over that method are:

  • It's more intuitive.
  • It's easier to change the boundaries between grades (or add additional grades).
  • It automatically accepts input with leading and trailing spaces (see below for an explanation of how (( )) works).

All three of these advantages arise because this method uses the user's input as numeric data rather than by manually examining its constituent digits.

How it Works

  1. Read input from the user. Let them use the arrow keys to move around in the text they've entered (-e) and don't interpret \ as an escape character (-r).
    This script is not a feature-rich solution--see below for a refinement--but those useful features only make it two characters longer. I recommend always using -r with read, unless you know you need to let the user supply \ escapes.
  2. If the user wrote q or Q, quit.
  3. Create an associative array (declare -A). Populate it with the highest numeric grade associated with each letter grade.
  4. Loop through the letter grades from lowest to highest, checking if the user-provided number is low enough to fall into each letter grade's numeric range.
    With (( )) arithmetic evaluation, variable names don't need to be expanded with $. (In most other situations, if you want to use a variable's value in place of its name, you must do this.)
  5. If it falls in the range, print the grade and exit.
    For brevity, I use the short-circuit and operator (&&) rather than an if-then.
  6. If the loop finishes and no range has been matched, assume the number entered is too high (over 100) and tell the user it was out of range.

How this Behaves, with Weird Input

Like the other short solutions posted, that script doesn't check the input before assuming it's a number. Arithmetic evaluation ((( ))) automatically strips leading and trailing whitespace, so that's no problem, but:

  • Input that doesn't look like a number at all is interpreted as 0.
  • With input that looks like a number (i.e., if it starts with a digit) but contains invalid characters, the script emits errors.
  • Multi-digit input starting with 0 is interpreted as being in octal. For example, the script will tell you 77 is a C, while 077 is a D. Although some users may want this, most probably don't and it can cause confusion.
  • On the plus side, when given an arithmetic expression, this script automatically simplifies it and determines the associated letter grade. For example, it will tell you 320/4 is a B.

An Expanded, Fully Featured Version

For those reasons, you may want to use something like this expanded script, which checks to ensure the input is good, and includes some other enhancements.

#!/usr/bin/env bash
shopt -s extglob

declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100

while read -erp 'Enter numeric grade (q to quit): '; do
    case $REPLY in  # allow leading/trailing spaces, but not octal (e.g. "03") 
        *( )@([1-9]*([0-9])|+(0))*( )) ;;
        *( )[qQ]?([uU][iI][tT])*( )) exit;;
        *) echo "I don't understand that number."; continue;;
    esac

    for letter in F D C B A; do
        ((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }
    done
    echo "Grade out of range."
done

This is still a pretty compact solution.

What features does this add?

The key points of this expanded script are:

  • Input validation. terdon's script checks input with if [[ ! $response =~ ^[0-9]*$ ]] ..., so I show another way, which sacrifices some brevity but is more robust, allowing the user to enter leading and trailing spaces and refusing to allow an expression that might or might not be intended as octal (unless it's zero).
  • I've used case with extended globbing instead of [[ with the =~ regular expression matching operator (as in terdon's answer). I did that to show that (and how) it can also be done that way. Globs and regexps are two ways of specifying patterns that match text, and either method is fine for this application.
  • Like A.B.'s bash script, I've enclosed the whole thing in an outer loop (except the initial creation of the cutoffs array). It requests numbers and gives corresponding letter grades as long as terminal input is available and the user hasn't told it to quit. Judging by the do...done around the code in your question, it looks like you want that.
  • To make quitting easy, I accept any case-insensitive variant of q or quit.

This script uses a few constructs that may be unfamiliar to novices; they're detailed below.

Explanation: Use of continue

When I want to skip over the rest of the body of the outer while loop, I use the continue command. This brings it back up to the top of the loop, to read more input and run another iteration.

The first time I do this, the only loop I'm in is the outer while loop, so I can call continue with no argument. (I'm in a case construct, but that doesn't affect the operation of break or continue.)

        *) echo "I don't understand that number."; continue;;

The second time, however, I'm in an inner for loop which is itself nested inside the outer while loop. If I used continue with no argument, this would be equivalent to continue 1 and would continue the inner for loop instead of the outer while loop.

        ((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }

So in that case, I use continue 2 to make bash find and continue the second loop out instead.

Explanation: case Labels with Globs

I don't use case to figure out which letter grade bin a number falls into (as in A.B.'s bash answer). But I do use case to decide if the user's input should be considered:

  • a valid number, *( )@([1-9]*([0-9])|+(0))*( )
  • the quit command, *( )[qQ]?([uU][iI][tT])*( )
  • anything else (and thus invalid input), *

These are shell globs.

  • Each is followed by a ) that is not matched by any opening (, which is case's syntax for separating a pattern from the commands that run when it is matched.
  • ;; is case's syntax for indicating the end of commands to run for a paticular case match (and that no subsequent cases should be tested after running them).

Ordinary shell globbing provides * to match zero or more characters, ? to match exactly one character, and character classes/ranges in [ ] brackets. But I'm using extended globbing, which goes beyond that. Extended globbing is enabled by default when using bash interactively, but is disabled by default when running a script. The shopt -s extglob command at the top of the script turns it on.

Explanation: Extended Globbing

*( )@([1-9]*([0-9])|+(0))*( ), which checks for numeric input, matches a sequence of:

  • Zero or more spaces (*( )). The *( ) construct matches zero or more of the pattern in the parentheses, which here is just a space.
    There are actually two kinds of horizontal whitespace, spaces and tabs, and often it's desirable to match tabs also. But I'm not worrying about that here, because this script is written for manual, interactive input, and the -e flag to read enables GNU readline. This is so the user can move back and forth in their text with the left and right arrow keys, but it has the side effect of generally preventing tabs from being entered literally.
  • One occurrence (@( )) of either (|):
    • A nonzero digit ([1-9]) followed by zero or more (*( )) of any digit ([0-9]).
    • One or more (+( )) of 0.
  • Zero or more spaces (*( )), again.

*( )[qQ]?([uU][iI][tT])*( ), which checks for the quit command, matches a sequence of:

  • Zero or more spaces (*( )).
  • q or Q ([qQ]).
  • Optionally--i.e., zero or one occurrences (?( ))--of:
    • u or U ([uU]) followed by i or I ([iI]) followed by t or T ([tT]).
  • Zero or more spaces (*( )), again.

Variant: Validating Input with an Extended Regular Expression

If you prefer to test the user's input against a regular expression rather than a shell glob, you might prefer to use this version, which works the same but uses [[ and =~ (like in terdon's answer) instead of case and extended globbing.

#!/usr/bin/env bash
shopt -s nocasematch

declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100

while read -erp 'Enter numeric grade (q to quit): '; do
    # allow leading/trailing spaces, but not octal (e.g., "03")
    if [[ ! $REPLY =~ ^\ *([1-9][0-9]*|0+)\ *$ ]]; then
        [[ $REPLY =~ ^\ *q(uit)?\ *$ ]] && exit
        echo "I don't understand that number."; continue
    fi

    for letter in F D C B A; do
        ((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }
    done
    echo "Grade out of range."
done

Possible advantages of this approach are that:

  • In this particular case, the syntax is a little simpler, at least in the second pattern, where I check for the quit command. This is because I was able to set the nocasematch shell option, and then all case variants of q and quit were covered automatically.

    That's what the shopt -s nocasematch command does. The shopt -s extglob command is omitted as globbing isn't used in this version.

  • Regular expression skills are more common than proficiency in bash's extglobs.

Explanation: Regular Expressions

As for the patterns specified to the right of the =~ operator, here's how those regular expressions work.

^\ *([1-9][0-9]*|0+)\ *$, which checks for numeric input, matches a sequence of:

  • The beginning--i.e., left edge--of the line (^).
  • Zero or more (*, applied postfix) spaces. A space does not ordinarily need to be \-escaped in a regular expression, but this is needed with [[ to prevent a syntax error.
  • A substring (( )) that is one or the other (|) of:
    • [1-9][0-9]*: a nonzero digit ([1-9]) followed by zero or more (*, applied postfix) of any digit ([0-9]).
    • 0+: one or more (+, applied postfix) of 0.
  • Zero or more spaces (\ *), as before.
  • The end--i.e., right edge--of the line ($).

Unlike case labels, which match against the whole expression being tested, =~ returns true if any part of its left-hand expression matches the pattern given as its right-hand expression. This is why the ^ and $ anchors, specifying the beginning and end of the line, are needed here, and don't correspond syntactically to anything appearing in the method with case and extglobs.

The parentheses are needed to make ^ and $ bind to the disjunction of [1-9][0-9]* and 0+. Otherwise it would be the disjunction of ^[1-9][0-9]* and 0+$, and match any input starting with a nonzero digit or ending with a 0 (or both, which might still include non-digits in between).

^\ *q(uit)?\ *$, which checks for the quit command, matches a sequence of:

  • The beginning of the line (^).
  • Zero or more spaces (\ *, see above explanation).
  • The letter q. Or Q, since shopt nocasematch is enabled.
  • Optionally--i.e., zero or one occurrences (postfix ?)--of the substring (( )):
    • u, followed by i, followed by t. Or, since shopt nocasematch is enabled u may be U; independently, i may be I; and independently, t may be T. (That is, the possibilities are not limited to uit and UIT.)
  • Zero or more spaces again (\ *).
  • The end of the line ($).
5
  • 3
    tell me the truth..how much time it has taken? ;)
    – heemayl
    May 9, 2015 at 18:37
  • 4
    @heemayl I'm not totally sure, since I wrote it in many little parts throughout the day (followed by a full read-through and edits, just before posting). I'm pretty sure it has ended up taking longer than I would've thought when I started, though, if I'd been thinking about how long it was going to take. :) May 9, 2015 at 18:43
  • 6
    write more and more, I need a book of your answers. May 10, 2015 at 12:44
  • TL;DR but made me laugh anyway!
    – Fabby
    Aug 25, 2015 at 18:58
  • everything from title to explanation is good. I understood it in the first go but i read it again just because i wanted to Apr 4, 2017 at 5:23
12
#!/bin/bash

while true
do
  read -p "Please enter your choice: " choice

  case "$choice"
   in
      [0-9]|[1-5][0-9])
          echo "F"
          ;;
      6[0-9])
          echo "D"
          ;;
      7[0-9])
          echo "C"
          ;;
      8[0-9])
          echo "B"
          ;;
      9[0-9]|100)
          echo "A"
          ;;
      [Qq])
          exit 0
          ;;
      *) echo "Only numbers between 0..100, q for quit"
          ;;
  esac
done

and a more compact version (Thx @EliahKagan):

#!/usr/bin/env bash

while read -erp 'Enter numeric grade (q to quit): '; do
    case $REPLY in
        [0-9]|[1-5][0-9])   echo F ;;
        6[0-9])             echo D ;;
        7[0-9])             echo C ;;
        8[0-9])             echo B ;;
        9[0-9]|100)         echo A ;;

        [Qq])               exit ;;
        *)                  echo 'Only numbers between 0..100, q for quit' ;;
    esac
done
3
  • 1
    Those are character ranges, surely? i.e. [0-59] means any character from 0,1,2,3,4,5 or 9 and so on. I don't see how that can work for numeric values. May 9, 2015 at 12:02
  • 3
    You don't have to be FGITW all the time. Take you time, write good answers. Look at how terdon or Eliah Kagan work.
    – muru
    May 9, 2015 at 12:13
  • @A.B. I've noticed that this solution can be shortened, mostly through stylistic changes, while still remaining quite readable. Brevity is rarely the most important consideration so I'm don't think you should change what you have in this way. But since the more compact form doesn't take up very much space, you might consider showing it too, in case some readers want a shorter script that works the same way. (If you like, please feel free to use my shortened version, or any variation on it.) May 9, 2015 at 18:09
9

All Ubuntu installs have Python, so here is a python script one liner. If you need it to be in bash, I've also written the equivalent as a shell script.

print (chr(75-max(5,int('0'+raw_input('Enter the number: ')[:-1]))))

To run, save it in a file (e.g. grade.py) and then run it in terminal with this:

python grade.py

This is what you will see:

Enter the number: 65
E

How does this work?

  1. Take input - 65.
  2. Add a 0 to the beginning - 065.
  3. Remove the last char - 06.
  4. 75 subtract that number - 70.
  5. Convert to a letter (A is 65, B is 66) - E.
  6. Print it out - E.

My pronouns are He / Him

6
  • I like your idea. +1
    – A.B.
    May 9, 2015 at 13:29
  • Don't use input(), it will call eval(), use raw_input() instead..also your grading is not right as 90+ will print grade B..use chr(74 - max(4, num))....
    – heemayl
    May 9, 2015 at 14:20
  • well..your solution is nice and will work in python2 also..just change the input() to raw_input() for python2..thats it..
    – heemayl
    May 9, 2015 at 14:48
  • print chr(75-max(5,int('0'+raw_input('Enter the number: ')[:-1])))
    – heemayl
    May 9, 2015 at 14:50
  • Then you need to change your original code too..your modifed code as it currently stands is wrong though as python3 does not have raw_input().. i suggested raw_input() for your initial one as you have told to run it using python2..
    – heemayl
    May 9, 2015 at 14:58
6

Here's my semi-esoteric bash solution, which populates an array with 101 entries and then checks user input against them. Even for real-world use that's reasonable--if you needed excellent performance you wouldn't be using bash, and a hundred (or so) assignments is still fast. But it would stop being reasonable if extended to a way bigger range (like a million).

#!/usr/bin/env bash
p(){ for i in `seq $2 $3`; do g[$i]=$1; done; }
p A 90 100; p B 80 89; p C 70 79; p D 60 69; p F 0 59
while read -r n && [[ ! $n =~ ^[qQ] ]]; do echo ${g[$n]}; done

Advantages:

  • It's not really that esoteric. Although it's longer than the shortest solutions, and not quite as self-documenting as the longer solutions... it's reasonably self-documenting, while still decidedly on the tiny side.
  • It allows easy modification to change grade ranges or add/remove grades.
  • It operates in a loop and quits on q, quit, or anything starting with q/Q.
  • Lists the higher grades first, to help you think positive. :)
  • Hmm, this does the job, keeps making sense even after you look at it, and has the essential features. You could actually use this!

Disadvantages:

  • It gives you an F when you put in non-numeric input... but that's not really so bad, is it? If you give a non-number where a number is needed, maybe you deserve an F!
  • Ambiguous, possibly octal input is treated as octal (since g is a one-dimensional indexed array). As the old saying goes, "It's not a bug, it's a feature!" Well, maybe.
  • Input that's out of range or not a number causes an empty line to be printed. There's nothing really wrong with that though: it tells you what letter grade corresponds to your input, and for wrong input there isn't one.
  • Put in a negative number, and it... well, call it an easter egg.
  • Still significantly longer than Tim's python solution. Yeah, I can't really spin that to seem like an advantage.

Kinda cool, huh? (Well, I think so.)

How It Works

  1. The p function populates a numerically indexed array g of grades, at indexes ranging from its first argument to its second, with the (letter) value given in its third argument.
  2. p is called for each letter grade, to define its numeric range.
  3. Keep reading user input as long as it's available and doesn't start with q (or Q), checking the g array for which letter grade corresponds to the number entered, and printing that letter.
1
  • What about this conditional? [[ $n =~ ^(0|[1-9]+[0-9]*)$ ]]
    – 0x2b3bfa0
    Jun 22, 2015 at 7:43
6

After making it in Python 2, I decided to make it in bash.

#! /bin/bash

read -p "Enter the number: " i
i=0$i
x=$((10#${i::-1}))
printf "\x$(printf %x $((11-($x>5?$x:5)+64)))\n"

To run, save it in a file (e.g. grade.sh), make it executable with chmod +x grade.sh and then run with ./grade.sh.

This is what you will see:

Enter the number: 65
E

How does this work?

  1. Take input - 65.
  2. Add a 0 to the beginning - 065 (and the 10# keeps it base 10).
  3. Remove the last char - 06.
  4. 75 subtract that number - 70.
  5. Convert to a letter (A is 65, B is 66) - E.
  6. Print it out - E.

My pronouns are He / Him

2
  • Very clever, well done
    – kos
    May 11, 2015 at 5:38
  • @kos thanks :) I doubt it will work for the OP because his ranges are probably not what he posted. I expect those are for simplicity.
    – Tim
    May 11, 2015 at 7:15
5

And here is my awk version:

awk '{
  if($_ <= 100 && $_ >= 0) {
      sub(/^([0-9]|[1-5][0-9])$/, "F", $_);
      sub(/^(6[0-9])$/, "D", $_);
      sub(/^(7[0-9])$/, "C", $_);
      sub(/^(8[0-9])$/, "B", $_);
      sub(/^(9[0-9]|100)$/, "A", $_);
      print
    }
    else {
      print "Only numbers between 0..100"
    }
}' -

or as one-liner:

awk '{if($_ <= 100 && $_ >= 0) { sub(/^([0-9]|[1-5][0-9])$/, "F", $_); sub(/^(6[0-9])$/, "D", $_); sub(/^(7[0-9])$/, "C", $_); sub(/^(8[0-9])$/, "B", $_);sub(/^(9[0-9]|100)$/, "A", $_);   print} else { print "Only numbers between 0..100"}}' -
0
4

Here's another "esoteric" answer

perl -E '
    print "number: "; 
    $n = <>; 
    say qw/A A B C D E F F F F F/[11-($n+1)/10]
       if $n=~/^\s*\d/ and 0<=$n and $n<=100
'

Explanation

  • perl -E : the -E, like -e, allows passing a script as a command line argument. This is a way to run perl one-liners. Unlike -e, -E also enables all optional features (such as say, which is basically a print with a trailing newline. ).
  • print "number: "; : prompt the user to enter a number.
  • $n = <>; : save that number as $n.

The next bit needs to be broken down a little. qw/string/ evaluates to a list made by breaking string at whitespace. So, qw/A A B C D E F F F F F/ is actually this list:

0 : A
1 : A
2 : B
3 : C
4 : D
5 : E
6 : F
7 : F
8 : F
9 : F
10 : F

Therefore, say qw/A A B C D E F F F F F/[11-($n+1)/10] is equivalent to

my @F=("A","A","B","C","D","E","F","F","F","F","F");
print "$F[11-($n+1)/10]\n"

Now, Perl allows using negative indices to retrieve elements counting from the end of the array. For example, $arrray[-1] will print the last element of the array. In addition, floating point array indices (e.g. 10.7) are automatically truncated to the next lower integer (10.7, or 10.3 or whatever all become 10.)

The result of all this is that the index 11-($n+1)/10 always evaluates to the appropriate element (grade) of the array.

1
  • 4
    Esoteric answers are all nice, but please include an explanation.
    – muru
    May 9, 2015 at 15:34
1

Although you asked for a bash solution, I think in python, this can be done in an elegant an short way. Covering both handling errors in case of incorrect input and the "conversion" of a number between 0 and 100 into letters from A to F (or any other):

#!/usr/bin/env python3
try:
    n = int(input("number: ")); n = n if n>0 else ""
    print("FEDCBA"[[n>=f for f in [50,60,70,80,90,101]].count(True)])
except:
    print("invalid input")

Explanation

  1. First we need to get the number from the user:

    n = int(input("number: "))
    
  2. We test this number to be valid for a number of conditions:

    n>=50, n>=60, n>=70, n>=80, n>=90
    

    For each of these tests, the result will be either False or True. Therefore (compressing the code a bit):

    [n>=f for f in [50,60,70,80,90]].count(True)]
    

    will produce a figure from 0 to 5

  3. Subsequently, we can use this figure as an index for a string, to produce a character as output, e.g.

    "ABCDEF"[3] 
    

    will output "D" (since the first character = "A")

  4. The additional 101 to the list is to generate an (Index-) error in case the number exceeds 100, since "ABCDEF"[6] does not exist. The same goes for n = n if n>=0 else "", which will create a (Value-) error if a number below 0 is entered
    In those cases, as well as if the input is not a figure, the result will be:

    invalid input
    

The tests:

number: 10
F

number: 50
E

number: 60
D

number: 70
C

number: 80
B

number: 90
A

number: 110
invalid input

number: -10
invalid input

number: Monkey
invalid input

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .