Learning Goals
3 minBy the end of this lesson you can:
- Use a dictionary as a cipher — letters in, letters out.
- Build a reverse-direction dictionary from an existing one —
decoder = {v: k for k, v in encoder.items()}. - Loop a string with
for ch in word:and assemble a new string with+=.
Warm-Up
5 minA Caesar cipher shifts every letter forward by a fixed amount. Three forward turns a → d, b → e, c → f. The famous "ROT-3" from Roman times.
We could write 26 if/elif branches — but you're a Level-2 student now. We have dict.
shift3 = {"a": "d", "b": "e", "c": "f"} print(shift3["a"]) print(shift3["c"])
Show the answer
d f
Three lines of code stand in for three if branches — and they'll do the work of 26 once we fill the table out.
A cipher is just a lookup table. Lookup tables are dictionaries. Every Caesar cipher, every substitution puzzle, every "letter swap" game is the same dictionary with different values.
New Concept · Encoding with a Dictionary
14 minThe encoder table
Here's the ROT-3 encoder. Each lower-case letter maps to the one three places forward; the last three wrap around to the start.
encoder = { "a": "d", "b": "e", "c": "f", "d": "g", "e": "h", "f": "i", "g": "j", "h": "k", "i": "l", "j": "m", "k": "n", "l": "o", "m": "p", "n": "q", "o": "r", "p": "s", "q": "t", "r": "u", "s": "v", "t": "w", "u": "x", "v": "y", "w": "z", "x": "a", "y": "b", "z": "c", }
The encoding loop
Walk the input string letter by letter. Look each one up in the encoder. Glue the results together into a new string with +=.
message = "hello" secret = "" for ch in message: secret += encoder[ch] print(secret) # → khoor
Five letters in, five lookups, five letters out. += is shorthand for secret = secret + encoder[ch] — same thing, less typing.
The safe lookup · .get() saves us from punctuation
What if the message has a space or a comma? encoder[" "] would crash with KeyError. Use .get(ch, ch) — "look up ch; if it's not there, just leave it alone".
message = "hello, world" secret = "" for ch in message: secret += encoder.get(ch, ch) print(secret) # → khoor, zruog
Spaces and commas stay put because they're not in the encoder — .get() hands them back as-is.
The reverse direction · building a decoder
If a → d in the encoder, then d → a in the decoder. We could re-type all 26 pairs backwards — but we already know how to walk a dict and unpack pairs. Flip the keys and values in one tidy comprehension:
decoder = {v: k for k, v in encoder.items()} print(decoder["d"]) # → a print(decoder["k"]) # → h
Same syntax as Level-1 list comprehensions, but with curly braces and v: k instead of k: v. We'll see dict comprehensions properly in Level 3 — for today, type it as a recipe.
The decoding loop
Exactly the same shape as encoding, but using the decoder table:
secret = "khoor" plain = "" for ch in secret: plain += decoder.get(ch, ch) print(plain) # → hello
If your encoder and decoder are built correctly, encoding then decoding should hand you back the original message exactly. Use that as your unit test in the worked example below.
Worked Example · The Spy Message
12 minWe'll build the full encode/decode pair, wrap each into a function, and round-trip a message through both to prove they cancel out.
Save as spy.py:
Code
# spy.py — Caesar-3 cipher with encode + decode functions encoder = { "a": "d", "b": "e", "c": "f", "d": "g", "e": "h", "f": "i", "g": "j", "h": "k", "i": "l", "j": "m", "k": "n", "l": "o", "m": "p", "n": "q", "o": "r", "p": "s", "q": "t", "r": "u", "s": "v", "t": "w", "u": "x", "v": "y", "w": "z", "x": "a", "y": "b", "z": "c", } decoder = {v: k for k, v in encoder.items()} def encode(plain): secret = "" for ch in plain.lower(): secret += encoder.get(ch, ch) return secret def decode(secret): plain = "" for ch in secret: plain += decoder.get(ch, ch) return plain # Round-trip test original = "Meet me at the warung at six." hidden = encode(original) back = decode(hidden) print("Original:", original) print("Hidden :", hidden) print("Back :", back) print("Match? :", original.lower() == back)
Output
Original: Meet me at the warung at six. Hidden : phhw ph dw wkh zduxqj dw vla. Back : meet me at the warung at six. Match? : True
What just happened
encodewalked "Meet me at the warung at six.", lower-cased it, and looked each letter up. Spaces, dots and unknown characters slipped through thanks to.get(ch, ch).decoder = {v: k for k, v in encoder.items()}built the reverse map automatically — we never typed the decoder out by hand.decodereversed the same loop using the flipped dict.- The
Match?line confirms encoding then decoding round-tripped the input back to itself.
We lower-case during encoding so capital letters don't fall through (the encoder only has lower-case keys). That means "Meet" comes back as "meet" — case is the one thing this cipher doesn't preserve. Real ciphers handle both cases; we'll see how when we meet .upper() tricks in PY-L2-14.
Try It Yourself
13 minThree exercises. Use the encoder/decoder you built in the worked example.
Use your encode function to print your own name in cipher.
Hint
print(encode("Aisyah")) # → dlvbdk
Decode the secret string "khoor, zruog" using your decode function. Print the result.
Hint
print(decode("khoor, zruog")) # → hello, world
The comma and the space pass through untouched because .get(ch, ch) hands them back as-is.
Write a function build_encoder(shift) that returns a brand-new encoder where every letter is shifted shift places forward. Use it to build a ROT-5 encoder, then encode "hello" with it.
Hint
def build_encoder(shift): alphabet = "abcdefghijklmnopqrstuvwxyz" encoder = {} for i in range(26): encoder[alphabet[i]] = alphabet[(i + shift) % 26] return encoder rot5 = build_encoder(5) print(rot5["a"]) # → f print(rot5["z"]) # → e # Now encode "hello" with it secret = "" for ch in "hello": secret += rot5.get(ch, ch) print(secret) # → mjqqt
The % 26 bit is modulo — "the remainder when divided by 26". It wraps the index back to 0 once you go past z. We met % in PY-L1-04.
Mini-Challenge · The Two-Way Translator
8 minBuild translator.py — a tiny menu app that lets the user encode or decode any message.
Your file must:
- Include your
encoderdict and the auto-builtdecoderfrom the worked example. - In a
while True:loop, print a three-option menu:1 encode,2 decode,3 quit. - For
1: ask for a message withinput(), encode it, print the result. - For
2: ask for a secret, decode it, print the result. - For
3:break.
Stretch goal. Add option 4 — "test the cipher". Encode a fixed test phrase, decode it, and print Cipher OK ✓ if the round-trip matches; otherwise BROKEN!
Show one possible solution
# translator.py — encode or decode any message encoder = { "a": "d", "b": "e", "c": "f", "d": "g", "e": "h", "f": "i", "g": "j", "h": "k", "i": "l", "j": "m", "k": "n", "l": "o", "m": "p", "n": "q", "o": "r", "p": "s", "q": "t", "r": "u", "s": "v", "t": "w", "u": "x", "v": "y", "w": "z", "x": "a", "y": "b", "z": "c", } decoder = {v: k for k, v in encoder.items()} def encode(plain): out = "" for ch in plain.lower(): out += encoder.get(ch, ch) return out def decode(secret): out = "" for ch in secret: out += decoder.get(ch, ch) return out while True: print() print("1 encode 2 decode 3 quit 4 test") pick = input("Choose: ") if pick == "1": msg = input("Message: ") print("Secret :", encode(msg)) elif pick == "2": sec = input("Secret : ") print("Plain :", decode(sec)) elif pick == "3": print("Bye!") break elif pick == "4": test = "the quick brown fox" if decode(encode(test)) == test: print("Cipher OK ✓") else: print("BROKEN!") else: print("Pick 1-4.")
Non-negotiables: the encoder dict, the auto-built decoder (via the dict comprehension), two functions that loop the string with .get(ch, ch), and a menu loop with a break. The round-trip test is the cleanest way to be sure your cipher works.
Recap
3 minA cipher is a lookup table, and a lookup table is a dictionary. Build the encoder once; flip it with {v: k for k, v in encoder.items()} to get the decoder for free. Walk a string with for ch in word: and glue results together with +=. Use .get(ch, ch) to keep spaces and punctuation safe. Round-trip your input to be sure the two sides line up.
Vocabulary Card
- cipher
- A scheme for converting a message into a secret form (and back). The simplest ciphers are letter-for-letter swaps.
- encode / decode
- Encode turns plain text into the secret form. Decode reverses it.
- round trip
- Encoding then decoding should land you back where you started. A good first test for any cipher.
- +=
- Shorthand for "add to and reassign".
x += 1is the same asx = x + 1.
Homework
4 minSave a new file morse.py. Build a Morse-code encoder using a dictionary that maps each letter to its dot/dash pattern.
Your file must:
- Include a dict with at least twelve letters. Sample:
"a": ".-","b": "-...","c": "-.-.". (You can copy a full Morse table from class notes.) - Write a function
to_morse(message)that loops the message and uses.get(ch, "?")for letters that aren't in your table. - Join the dots/dashes with a space between letters and a
" / "between words. (Hint: split by spaces first.) - Print the Morse code for the word
SOS— should be... --- ....
Stretch. Build the matching from_morse(code) function using the auto-flipped decoder.
Sample · morse.py
# morse.py — text → Morse via dictionary lookup 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": "--..", } def to_morse(message): words_out = [] for word in message.lower().split(" "): letters = [] for ch in word: letters.append(morse.get(ch, "?")) words_out.append(" ".join(letters)) return " / ".join(words_out) print(to_morse("SOS")) # → ... --- ... print(to_morse("hello world")) # → .... . .-.. .-.. --- / .-- --- .-. .-.. -.. # Stretch — flip the dict and decode back = {v: k for k, v in morse.items()} def from_morse(code): words_out = [] for word in code.split(" / "): letters = [] for token in word.split(" "): letters.append(back.get(token, "?")) words_out.append("".join(letters)) return " ".join(words_out) print(from_morse("... --- ...")) # → sos
Non-negotiables: a dict of at least twelve letters, a to_morse function using .get(ch, "?"), and the words joined with " / ". Your .join() usage is a sneak peek at PY-L2-14's String Superpowers.