7
\$\begingroup\$

I created a simple text to morse code converter in python, and was wondering if there was an easier/shorter way to do this. Is there a way to generate the dictionary without having to hard-code every single option?

def main():

    print("This program translates words to morse code\n")

    words = input("Enter a word or phrase to be encoded: ")
    words = words.upper()

    codeDict = createDict()

    i = 0

    try:
        while (i < len(words)):
            if (words[i] != " "):
                print(codeDict[words[i]], end = " ")
            else:
                print("/", end = " ")
            i += 1
    except:
        print("\nInvalid characters found - refer to morse code alphabet")


def createDict():
    code = {'A': '.-',
            'B': '-...',
            'C': '-.-.',
            'D': '-..',
            'E': '.',
            'F': '..-.',
            'G': '--.',
            'H': '....',
            'I': '..',
            'J': '.---',
            'K': '-.-',
            'L': '.-..',
            'M': '--',
            'N': '-.',
            'O': '---',
            'P': '.--.',
            'Q': '--.-',
            'R': '.-.',
            'S': '...',
            'T': '-',
            'U': '..-',
            'V': '...-',
            'W': '.--',
            'X': '-..-',
            'Y': '-.--',
            'Z': '--..',
            '1': '.----',
            '2': '..---',
            '3': '...--',
            '4': '....-',
            '5': '.....',
            '6': '-....',
            '7': '--...',
            '8': '---..',
            '9': '----.',
            '0': '-----'}

    return code


main()
\$\endgroup\$
2
  • 1
    \$\begingroup\$ I don't know about a generator, but why do you need a createDict() function if it's a static dict with specific letter-to-code mappings? Just define it as a variable in your code, don't bother creating a function just to create a static dict. (I mention this because I don't see a generator-capable function to assign the letters to their morse code equivalents or vice versa.) \$\endgroup\$ – Thomas Ward Apr 16 at 20:28
  • \$\begingroup\$ Keep in mind that a morse dictionary is actually a binary tree, with "." and "-" branches and the letters as leaves. It makes sense to use this. \$\endgroup\$ – tofro Apr 16 at 21:49
6
\$\begingroup\$

NOTE: You do not specify a Python version, so I am assuming you are using a recent version of Python - I use Python 3.9 personally so I'm writing with this in mind as an assumption. I've written this answer to be compatible with Python 3.6 and newer, there's a spot where I give a workaround for versions of Python 3 earlier than 3.6.

Firstly, I'm not sure you can generate the Morse Code equivalents of a letter in any pattern - to my knowledge there's no algorithm to do that. So you'll have to still statically code in the list. However, you have some unnecessary bits related to that.

Unnecessary function to generate a static dictionary

You're using a function to define and return a static dictionary. You can easily improve that component by defining it once in your program and reuse it throughout - don't bother wrapping that in a function.

Just define it at the beginning of your program once, don't regenerate it every time. And then refer to that variable accordingly (I named it LETTERS_TO_MC so it's more understandable what it's for). But also, add a translation case for a space too - it'll cut down on your logical comparisons later.

LETTERS_TO_MC = {
    'A': '.-',
    'B': '-...',
    'C': '-.-.',
    'D': '-..',
    'E': '.',
    'F': '..-.',
    'G': '--.',
    'H': '....',
    'I': '..',
    'J': '.---',
    'K': '-.-',
    'L': '.-..',
    'M': '--',
    'N': '-.',
    'O': '---',
    'P': '.--.',
    'Q': '--.-',
    'R': '.-.',
    'S': '...',
    'T': '-',
    'U': '..-',
    'V': '...-',
    'W': '.--',
    'X': '-..-',
    'Y': '-.--',
    'Z': '--..',
    '1': '.----',
    '2': '..---',
    '3': '...--',
    '4': '....-',
    '5': '.....',
    '6': '-....',
    '7': '--...',
    '8': '---..',
    '9': '----.',
    '0': '-----',
    ' ': '/'
}

Rename main() to translate()

While technically this is an optional case, usually functions should be indicative of what they actually do - in this case, they translate something, so let's call main() by the name of what it does - translate().

Reduce translate() to only do what it says - translation - and make the function more function-specific

Let's reduce the translate function specifically to translation and then returning of the morse code string. We'll let getting the information from the end user be part of the next suggestion of improvements I make.

Firstly, lets iterate over the string and simply join them together as string objects. Which we can easily do in a single line 'return' under the function. And,should an invalid character in the words provided to the function will trigger a KeyError internally, so let's catch that KeyError, and raise a KeyError with a custom error message indicating that an invalid character was provided for conversion. We'll handle displaying this to the end user later in the next section of suggestions.

def translate(words):
    return " ".join(LETTERS_TO_MC[char] for char in words.upper())

This makes the function smaller, and extremely less complex!

Note that there's no error handling in this function - if a character cannot be converted, Python will raise a KeyError because the letter does not exist in the dictionary. This will be caught in the next section where we make this script executable!

Use if __name__ == "__main__": for direct script execution handling

Let's consider that in the future you might want to be able to import translate elsewhere as part of a script. That's why we've written it as we did in the last suggestion. However, you still want to execute this script directly, and get input. So, let's do that!

Instead of just calling translate() at the end of the .py file, let's put that under an if __name__ == "__main__": block at the end. Also, since we reduced the translate function to ONLY do translation, we can do the user input bits here.

if __name__ == "__main__":
    print("This program translates words into to morse code.\n")

    words = input("Enter a word or phrase to be encoded: ")

    try:
        print(translate(words))
    except KeyError:
        print("The word or phrase you've provided contains invalid characters - we can only convert the letters A-Z, numbers, and spaces to Morse Code in this program.")

This is where we handle direct script execution of your Python code (as in calling python3 morsecode-convert.py or such on the command line). We use the try/except handling like you did before (except we KNOW that an invalid character raises a KeyError in translate, so only catch those exceptions and print a nice error message to the user), but we only do so when the program is executed as a script directly. This also prevents execution of translate without actual useful data passed to the function if you ever import translate or your LETTERS_TO_MC dict into another Python program/script/project later.

The entire rewritten program, with the above suggestions

Note that I put multiple letter-to-morse-code definitions per line in the dict (three per line), to shrink this to be more readable in scrolling; you can easily expand it as i did earlier in my answer, but you can still easily determine what's in the dict even this way.

LETTERS_TO_MC = {
    'A': '.-', 'B': '-...', 'C': '-.-.',
    'D': '-..', 'E': '.', 'F': '..-.',
    'G': '--.', 'H': '....', 'I': '..',
    'J': '.---', 'K': '-.-', 'L': '.-..',
    'M': '--', 'N': '-.', 'O': '---',
    'P': '.--.', 'Q': '--.-', 'R': '.-.',
    'S': '...', 'T': '-', 'U': '..-',
    'V': '...-', 'W': '.--', 'X': '-..-',
    'Y': '-.--', 'Z': '--..',  '1': '.----',
    '2': '..---', '3': '...--', '4': '....-',
    '5': '.....', '6': '-....', '7': '--...',
    '8': '---..', '9': '----.', '0': '-----',
    ' ': '/'
}


def translate(words):
    return " ".join(LETTERS_TO_MC[char] for char in words.upper())
    

if __name__ == "__main__":
    print("This program translates words into to morse code.\n")

    words = input("Enter a word or phrase to be encoded: ")

    try:
        print(translate(words))
    except KeyError:
        print("The word or phrase you've provided contains invalid characters - we can only convert the letters A-Z, numbers, and spaces to Morse Code in this program.")
\$\endgroup\$
3
  • \$\begingroup\$ Thank you very much! This was super thorough \$\endgroup\$ – am2021 Apr 16 at 21:07
  • 4
    \$\begingroup\$ Ha, it seems I was a bit too slow with my answer. The only improvement I'd suggest is replacing the string concatenation inside of translate with return " ".join(LETTERS_TO_MC[char] for char in words.upper()). It's more pythonic and more efficient afaik. It will also get rid of leading and trailing spaces, which are not intended as far as I can tell. \$\endgroup\$ – riskypenguin Apr 16 at 21:16
  • 1
    \$\begingroup\$ @riskypenguin Good point i wrote this while tired. I can put that in. \$\endgroup\$ – Thomas Ward Apr 17 at 16:53
10
\$\begingroup\$

As already mentioned in the comments, I don't think there's a strictly better way to create the morse code mapping. There is however a lot we can do to make this code more concise and more pythonic. This looks like Java code that was directly translated into Python and doesn't utilise some of Python's key features.


Morse Code dict

See the comments on your question: We don't need function createDict, which should be called create_dict (PEP 8), we only need a top-level variable:

CHAR_TO_MORSE = {'A': '.-',
                 ...
                 '0': '-----'}

Never iterate over indices in python

In Python this pattern is generally not recommended:

i = 0
while (i < len(words)):
    print(words[i])

You almost never need to iterate over and access elements by their indices. Instead you should directly iterate over the elements of the iterable:

for word in words:
    print(word)

If you do need the index as well you can use built-in enumerate:

for index, word in enumerate(words):
    print(f"{index}: {word}")

words is not a descriptive variable name for a string. If I iterate over a variable named words, I expect to receive words, not single characters. I'd change it to text or message and then change the loop to:

for char in text:
    ...

This makes it immediately clear what is happening.


Exception handling

Do not use bare except. Doing so can make it harder to interrupt programs and it can disguise other problems, since it catches all errors, not just the ones you're expecting. Instead you should only catch KeyError, since that's the only error you're explicitly handling.


words[i] != " "

You perform this check in every loop iteration, although it's not really a special case. This basically only translates " " to "/", the rest of the logic is the same. So you can simply add it to your char to morse mapping:

CHAR_TO_MORSE = {'A': '.-',
                 ...
                 '0': '-----',
                 ' ': '/'}

Calling main()

For most use cases, you should wrap a call to main() from within the script in a if __name__ == “__main__”: condition.

if __name__ == "__main__":
    main()

What does if name == “main”: do?


Complete code

I also applied .upper() directly to the return value of input(...) and moved the Exception clause into the loop to allow for a better error statement to the user.

CHAR_TO_MORSE = {'A': '.-',
                 ...
                 '0': '-----',
                 ' ': '/'}


def main():
    print("This program translates words to morse code", end="\n\n")

    text = input("Enter a word or phrase to be encoded: ").upper()

    for char in text:
        try:
            print(CHAR_TO_MORSE[char], end=" ")
        except KeyError:
            print(f"\nInvalid character found: '{char}' - refer to morse code alphabet")
            break


if __name__ == "__main__":
    main()

join and list comprehensions / generator expressions

If you're not set on printing the morse code up until the invalid character, this is a perfect use case for join together with a list comprehension:

try:
    print(" ".join(CHAR_TO_MORSE[char] for char in text))
except KeyError:
    print(f"\nInvalid character found - refer to morse code alphabet")

What we're using here is actually not a list comprehension, but a generator expression. If you're interested you can read more about Generator Expressions vs List Comprehensions.

\$\endgroup\$
1
  • \$\begingroup\$ I definitely see now after looking this over that I was not utilizing python's features/formatting as much as I should have \$\endgroup\$ – am2021 Apr 20 at 19:26
9
\$\begingroup\$

Is there a way to generate the dictionary without having to hard-code every single option?

Addressing only this part of the question: I don't necessarily recommend the following, but mentioning it just for completeness. Morse code was designed such that more frequent letters have shorter codes: for instance, the most frequent letter, E, has code . and the second-most frequent letter, T, has code - and so on. The story (though details are disputed) is that either Samuel Morse or his partner Alfred Vail counted the number of occurrences of each letter on a page of a newspaper, to get a list of letters ordered by frequency. (The ordering in Morse code is ETIANMSURWDKGOHVFLPJBXCYZQ which is not exactly the ETAOINSHRDLCUMWFGYPBVKJXQZ (etc) (see Etaoin shrdlu) that more exhaustive counting would give.)

This makes Morse code somewhat an instance of entropy encoding (like arithmetic coding and Huffman coding), and thus it can be viewed as a binary tree (image via Wikipedia):

Image from Wikimedia at https://commons.wikimedia.org/wiki/File:Morse_code_tree3.png

— here, moving to a left child in the tree corresponds to a . in Morse code, and moving to a right child corresponds to a - as suggested by how the edges are drawn. This gives a correspondence (at each level of the tree) with the representation of numbers in binary: the one-symbol Morse codes (for E and T), namely . and - correspond to 0 and 1 and binary, then the second level has I, A, N, M which are .., .-, -. and -- (corresponding to 00, 01, 10, 11 in binary), and so on. In turn, this lets us "generate" (in some sense) the Morse code table, with code like the following:

def make_morse_table():
  ret = {' ': '/'}
  s = 'ETIANMSURWDKGOHVF*L*PJBXCYZQ**54*3***2*******16*******7***8*90'
  w = 0
  for length in range(1, 6):
    for n in range(2**length):
      ret[s[w]] = (bin(n)[2:]      # Convert n to binary
                   .zfill(length)  # fix length, by padding 0s
                   .replace('0', '.').replace('1', '-'))  # 0,1 -> dot,dash
      w += 1
  del ret['*']  # Each '*' in s was 
  return ret

With this, make_morse_table() gives the same table that is called LETTERS_TO_MC or CHAR_TO_MORSE or MORSE_CODE in the other three answers. You could call this function once, assign the result (dict) to a variable, and then use it the same way as in the other answers.

Is it a good idea to use a function like this? The obvious problem is that this is "tricky" code that is hard to understand or see what it's doing, so it would be best accompanied by copious documentation, maybe even linking to a diagram. The next person (such as yourself, later) to maintain this code (to add more punctuation symbols, say) would have to understand all this, convert the string s into a tree, insert the new symbols into the tree, and update s.

That is why I wouldn't necessarily recommend using something like this. On the other hand, there are some small advantages, like it being harder to accidentally assign two letters to the same code, say.

\$\endgroup\$
3
  • \$\begingroup\$ This is really cool actually, I wasn't aware of the background of how this all works before \$\endgroup\$ – am2021 Apr 19 at 0:31
  • \$\begingroup\$ Yes, this is how you can do it ; no, please never do this in real life. Morse code is a global communication standard, and so it's critically important to exactly reproduce the standard table. Given how short it is, a derivation like this has only academic interest. \$\endgroup\$ – Reinderien Apr 19 at 4:08
  • 1
    \$\begingroup\$ @Reinderien On the one hand I sort of agree; on the other hand, one of the advantages of doing it this way is that because this is a reasonably standard transformation, one can search online for "ETIANMSURWDKGOHVF" (including the quotes) or for "ETIANMSURWDKGOHVFLPJBXCYZQ" and find many occurrences, confirming that the string data is correct. (Though of course there may be bugs hiding in the code.) In the regular representation as a dict, if F had been encoded as ..-- instead of ..-. it may be harder to detect. But I agree about the importance of getting the encoding exactly right! \$\endgroup\$ – ShreevatsaR Apr 19 at 4:26
5
\$\begingroup\$

Review

Thomas Ward and riskypenguin have given great review comments:

  • Remove unnecessary functions
  • Better function names
  • Use a main-guard
  • Add space to dictionary to avoid special case
  • Iterate over indices
  • Don't use bare except

I won't repeat them. Instead, I have to mention the PEP-8: Style Guide for Python Code has coding conventions that every Python programmer should follow. Specifically ...

Naming

Variable and function names should be in snake_case. This means createDict() should actually be named create_dict(), and likewise codeDict should be code_dict.

White Space

Binary operators usually should be surrounded by one space, and you follow that rule perfectly.

However, there is an exception: the = symbol in keyword parameters is not technically a binary operator. Here the style guide recommends no spaces around the = for keyword parameters. So instead of:

print("/", end = " ")

it is recommended to use:

print("/", end=" ")

Built-in Translation

One of my favourite and often overlooked functions is str.translate(). It is used to translate characters in a string, which is exactly what you are trying to do here. 'S' is translated to '...', and so on.

>>> MORSE = {'A': '.-', 'B': '-...', 'C': '-.-.', 'O': '---', 'S': '...'}
>>> morse_table = str.maketrans(MORSE)
>>> "SOS".translate(morse_table)
'...---...'
>>> 

The problem here is all the dots and dashes have run together. You want each code to be followed by a space. The simplest thing you could do would be to include the space in the codes.

>>> MORSE = {'A': '.- ', 'B': '-... ', 'C': '-.-. ', 'O': '--- ', 'S': '... '}
>>> morse_table = str.maketrans(MORSE)
>>> "SOS".translate(morse_table)
'... --- ... '
>>> 

The only gotcha is the built-in translate function will leave any character that is not found in the translation table unchanged. This would leave unexpected characters in your translation.

>>> "S.O.S!".translate(morse_table)
'... .--- .... !'

The exclamation mark at the end is left untranslated. Worse, the periods are left untranslated, making .O into .--- (a J), and .S into .... (an H). The translate function will delete any characters which translate to None, but as the list of invalid characters is huge, adding an entry for every invalid character is not practical.

We can tweak our translation table into simply returning None for every unknown key:

>>> class MorseTable(dict):
    def __missing__(self, key):
        return None

    
>>> morse_table = MorseTable(morse_table)
>>> "S.O.S!".translate(morse_table)
'... --- ... '

Simply deleting the invalid characters from the string may not be desired, and is not what the question asks for. It printed an error message if the input string had unknown characters. For that, we can simply check if the set of characters in the input string is a subset of the MORSE dictionary keys:

>>> set("SOS") <= MORSE.keys()
True
>>> set("S.O.S!") <= MORSE.keys()
False

Reworked Code

import sys

MORSE_CODE = {
    'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
    'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
    'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
    'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
    'Y': '-.--', 'Z': '--..',
    '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....',
    '6': '-....', '7': '--...', '8': '---..', '9': '----.', '0': '-----',
    ' ': '/'
}

# Add spaces to codes while creating translation table.
MORSE_TRANS_TABLE = str.maketrans({letter: code + " "
                                   for letter, code in MORSE_CODE.items()})

def to_morse_code(message: str) -> str:
    """
    Translate a message string into Morse Code

    Raise a ValueError if the message contains non-Morse code characters.

    >>> to_morse_code("SOS")
    '... --- ...'
    """

    message = message.upper()
    if set(message) <= MORSE_CODE.keys():
        # Translate message and remove trailing space
        return message.translate(MORSE_TRANS_TABLE).strip()
    else:
        raise ValueError("Invalid Morse Code characters in message")

if __name__ == "__main__":
    print("This program translates a message into to morse code.\n")

    message = input("Enter a word or phrase to be encoded: ")

    try:
        print(to_morse_code(message))
    except ValueError:
        print("Invalid characters found - refer to morse code alphabet",
              file=sys.stderr)

Double Translation to Single Translation

Two translations are being performed. First the string is being converted into uppercase, and second the new string is converted to Morse code. Ie) "sos" --> "SOS" --> "... --- ..." The two translations can be combined into just one by including both lower and uppercase translations in the MORSE_CODE mapping:

MORSE_CODE_UC = {
    'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
    'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
    'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
    'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
    'Y': '-.--', 'Z': '--..',
    '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....',
    '6': '-....', '7': '--...', '8': '---..', '9': '----.', '0': '-----',
    ' ': '/'
}

# Include equivalent lowercase to Morse code mappings. 
MORSE_CODE = MORSE_CODE_UC | {letter.lower(): code
                              for letter, code in MORSE_CODE_UC.items()}

With both upper and lowercase mappings in the translation table, the message = message.upper() statement becomes unnecessary and may be removed.

Docstring

The __docstring__ on the to_morse_code function includes a "doctest" string.

    """
    Translate a message string into Morse Code

    Raise a ValueError if the message contains non-Morse code characters.

    >>> to_morse_code("SOS")
    '... --- ...'
    """

This provides a built-in help for the user, and a built-in test to help ensure the function operates as expected.

Help

The user can request help on a function using help(...). As can be seen, the function signature is combine with the __docstring__ to generate the help displayed to the user:

>>> help(to_morse_code)
Help on function to_morse_code in module __main__:

to_morse_code(message: str) -> str
    Translate a message string into Morse Code
    
    Raise a ValueError if the message contains non-Morse code characters.
    
    >>> to_morse_code("SOS")
    '... --- ...'

DocTest

The __docstring__ also allows built-in tests to be specified to help ensure the function operates as expected.

To see it at work, import the doctest module, and run doctest.testmod():

>>> import doctest
>>> doctest.testmod(verbose=True)
Trying:
    to_morse_code("SOS")
Expecting:
    '... --- ...'
ok
1 items had no tests:
    __main__
1 items passed all tests:
   1 tests in __main__.to_morse_code
1 tests in 2 items.
1 passed and 0 failed.
Test passed.
TestResults(failed=0, attempted=1)
\$\endgroup\$
1
  • \$\begingroup\$ Also very thorough, I like the idea of the help function - not something I have utilized before \$\endgroup\$ – am2021 Apr 20 at 19:27

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.