Best practices in software engineering

Final exercise

We've now covered all the topics on this course so to finish off, work through this final exercise. It is designed to give you a chance to practise what you've learned on some new code.

Make a new directory called crypto. In the Terminal change to that directory with cd crypto and in the Python Console change there with %cd crypto. In that directory make two new files called morse.py and test_morse.py:

morse.py
# A lookup dictionary which, given a letter will return the morse code equivalent
_letter_to_morse = {'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':'--..',
                   '0':'-----', '1':'.----', '2':'..---', '3':'...--', '4':'....-',
                   '5':'.....', '6':'-....', '7':'--...', '8':'---..', '9':'----.',
                   ' ':'/'}

# This will create a dictionary that can go from the morse back to the letter
_morse_to_letter = {}
for letter in _letter_to_morse:
    morse = _letter_to_morse[letter]
    _morse_to_letter[morse] = letter


def encode(message):
    morse = []

    for letter in message:
        letter = letter.lower()
        morse.append(_letter_to_morse[letter])

    # We need to join together Morse code letters with spaces
    morse_message = " ".join(morse)
    
    return morse_message


def decode(message):
    english = []

    # Now we cannot read by letter. We know that morse letters are
    # separated by a space, so we split the morse string by spaces
    morse_letters = message.split(" ")

    for letter in morse_letters:
        english.append(_morse_to_letter[letter])

    # Rejoin, but now we don't need to add any spaces
    english_message = "".join(english)
    
    return english_message
test_morse.py
from morse import encode, decode

def test_encode():
    assert encode("SOS") == "... --- ..."

This module is designed to convert message to and from Morse code. It provides one function which takes an English message and converts it to a Morse code string, separated by spaces and another function which takes the Morse code string and converts it to English.

Exercise

  • Add documentation to the morse module and to the encode and decode functions. Make sure you detail the inputs, outputs and give an example of their usage. Look at the tests to get an idea of how it works or try importing morse in the Python Console and have a play with the functions to understand them.

answer

Exercise

  • Add a test for the decode function to test_morse.py and check it passes with pytest
  • Parametrise both tests to give several examples
    • Make sure you include upper and lower case letters as well as checking what happens if you pass in empty strings
  • Make sure to use --doctest-modules to run the documentation examples that you added in the last exercise
  • Hint: When writing doctests, it cares whether your test output uses single or double quotes (' or "). Use single quotes for doctest outputs.

answer

Exercise

  • What happens if you pass in the string "Don't forget to save us" to encode?
    • Hint: The problem is caused by the ' in the string
  • Edit morse.py to raise a ValueError in this situation instead.
  • Write a test to make sure that the ValueError is raised when a string with a ' is passed in.
  • Parametrise that test with some other examples including the & and £ characters.

answer

Another cypher

Let's add another text cypher to our crypto package. This time we will implement the Caesar Cipher or ROT13. Once more the module will provide encode and decode functions:

rot13.py
import string

_lower_cipher = string.ascii_lowercase[13:] + string.ascii_lowercase[:13]
_upper_cipher = string.ascii_uppercase[13:] + string.ascii_uppercase[:13]

def encode(message):
    output = []
    for letter in message:
        if letter in string.ascii_lowercase:
            i = string.ascii_lowercase.find(letter)
            output.append(_lower_cipher[i])
        elif letter in string.ascii_uppercase:
            i = string.ascii_uppercase.find(letter)
            output.append(_upper_cipher[i])
    
    return "".join(output)


def decode(message):
    output = []
    for letter in message:
        if letter in _lower_cipher:
            i = _lower_cipher.find(letter)
            output.append(string.ascii_uppercase[i])
        elif letter in _upper_cipher:
            i = _upper_cipher.find(letter)
            output.append(string.ascii_uppercase[i])
    
    return "".join(output)

Exercise

  • Add documentation for the rot13 module.

answer

This time the tests are provided for you. Copy this into a new file called test_rot13.py:

test_rot13.py
import pytest

from rot13 import encode, decode

@pytest.mark.parametrize("message, expected", [
    ("SECRET", "FRPERG"),
    ("secret", "frperg"),
])
def test_encode(message, expected):
    assert encode(message) == expected

@pytest.mark.parametrize("message, expected", [
    ("FRPERG", "SECRET"),
    ("frperg", "secret"),
])
def test_decode(message, expected):
    assert decode(message) == expected

def test_encode_spaces_error():
    with pytest.raises(ValueError):
        encode("Secret message for you")

When we run these tests with pytest we see that there are some passes and some failures:

$
pytest -v test_rot13.py
=================== test session starts ====================
platform linux -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /home/matt/projects/courses/software_engineering_best_practices
plugins: requests-mock-1.8.0
collected 5 items                                          

test_rot13.py::test_encode[SECRET-FRPERG] PASSED     [ 20%]
test_rot13.py::test_encode[secret-frperg] PASSED     [ 40%]
test_rot13.py::test_decode[FRPERG-SECRET] PASSED     [ 60%]
test_rot13.py::test_decode[frperg-secret] FAILED     [ 80%]
test_rot13.py::test_encode_spaces_error FAILED       [100%]

========================= FAILURES =========================
________________ test_decode[frperg-secret] ________________

message = 'frperg', expected = 'secret'

    @pytest.mark.parametrize("message, expected", [
        ("FRPERG", "SECRET"),
        ("frperg", "secret"),
    ])
    def test_decode(message, expected):
>       assert decode(message) == expected
E       AssertionError: assert 'SECRET' == 'secret'
E         - secret
E         + SECRET

test_rot13.py:18: AssertionError
_________________ test_encode_spaces_error _________________

    def test_encode_spaces_error():
        with pytest.raises(ValueError):
>           encode("Secret message for you")
E           Failed: DID NOT RAISE <class 'ValueError'>

test_rot13.py:22: Failed
================= short test summary info ==================
FAILED test_rot13.py::test_decode[frperg-secret] - Assert...
FAILED test_rot13.py::test_encode_spaces_error - Failed: ...
=============== 2 failed, 3 passed in 0.10s ================

Exercise

There are two failing tests:

  1. test_rot13.py::test_decode[frperg-secret] is failing due to a bug in the code. Find the bug in rot13.py and fix it so that the test passes.
  2. test_rot13.py::test_encode_spaces_error is failing due to a missing feature in our code. At the moment any spaces in the string are ignored. Change encode and decode in rot13.py so that they raise an error if any letter in the message is not found in the lookup string.
    • Hint: You should add an else to the if/elif blocks

answer

Exercise

  • Add a test to both test_morse.py and test_rot13.py which checks for "round-tripping". That is, check that a valid message which is passed to encode and then the output of that is passed to decode gets you back the original message.
  • What types of messages do not round-trip correctly in morse? What could you do to the test to make it pass?

answer