From 9d8beb3aef03eeb0b21a03d9da0f40b69dfc3a66 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 13:23:47 +0800 Subject: [PATCH] Refactor to unified caesar() function with encode/decode support - Replace separate encrypt/decrypt with shared caesar() function - Add direction constants (DIRECTION_ENCODE, DIRECTION_DECODE) and prompts - Update main.py to support interactive encode/decode loop - Keep encrypt/decrypt as wrappers for backward compatibility - Add tests for caesar() with both directions and error handling - Update tests for main.py with interactive scenario tests --- README.md | 2 +- docs/index.md | 2 +- src/caesar_cipher/__init__.py | 4 +-- src/caesar_cipher/cipher.py | 31 ++++++++++++----- src/caesar_cipher/constants.py | 28 +++++++++++++++ src/caesar_cipher/main.py | 63 +++++++++++++++++++++++----------- tests/test_cipher.py | 14 +++++++- tests/test_main.py | 28 +++++++++++++++ 8 files changed, 139 insertions(+), 33 deletions(-) create mode 100644 tests/test_main.py diff --git a/README.md b/README.md index 19e7ce1..58c4937 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A beginner-friendly Python program that encodes and decodes messages with the Ca ## 🧭 Overview -The Caesar cipher shifts every letter by a fixed number of places (with a shift of 3, `E` becomes `H`). This project, from Udemy's *100 Days of Code: The Complete Python Pro Bootcamp* (Day 8), finds letter positions with `list.index` and wraps around the alphabet with the modulo operator. Parts 1 and 2 cover encryption and decryption; a combined program follows in Part 3. Code lives in `src/`, tests in `tests/`, docs in `docs/`. +The Caesar cipher shifts every letter by a fixed number of places (with a shift of 3, `E` becomes `H`). This project, from Udemy's *100 Days of Code: The Complete Python Pro Bootcamp* (Day 8), finds letter positions with `list.index` and wraps around the alphabet with the modulo operator. The interactive program lets you encode or decode as many messages as you like. Code lives in `src/`, tests in `tests/`, docs in `docs/`. ## 📋 Requirements diff --git a/docs/index.md b/docs/index.md index ad5c79d..6d8e4ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ Each letter is moved `shift` places through the alphabet: `new_position = (ALPHABET.index(letter) + shift) % 26`. The modulo keeps positions in range for shifts past `z`. -Decryption is encryption with `-shift`. Non-letters are left untouched and letter case is preserved. +Decoding shifts by `-shift`; one shared `caesar(text, shift, direction)` function does both. Non-letters are left untouched and letter case is preserved. API reference: run `doxygen Doxyfile` from the repository root and open `docs/doxygen/html/index.html`. The step-by-step plan is in [plan.md](plan.md). diff --git a/src/caesar_cipher/__init__.py b/src/caesar_cipher/__init__.py index ac439e9..b322b9c 100644 --- a/src/caesar_cipher/__init__.py +++ b/src/caesar_cipher/__init__.py @@ -1,6 +1,6 @@ ## @file __init__.py # @brief Public API of the caesar_cipher package. -from caesar_cipher.cipher import decrypt, encrypt +from caesar_cipher.cipher import caesar, decrypt, encrypt -__all__ = ["encrypt", "decrypt"] +__all__ = ["caesar", "encrypt", "decrypt"] diff --git a/src/caesar_cipher/cipher.py b/src/caesar_cipher/cipher.py index 06449fd..df8fb3e 100644 --- a/src/caesar_cipher/cipher.py +++ b/src/caesar_cipher/cipher.py @@ -1,14 +1,21 @@ ## @file cipher.py -# @brief Caesar cipher encryption and decryption. +# @brief Caesar cipher: one shared function for encoding and decoding. -from caesar_cipher.constants import ALPHABET +from caesar_cipher.constants import ALPHABET, DIRECTION_DECODE, DIRECTION_ENCODE -def encrypt(text: str, shift: int) -> str: - ## @brief Encrypt a text with the Caesar cipher. - # @param text Original text. - # @param shift Positions to move each letter (may be negative or > 26). - # @return The encrypted text; non-letters are kept, case is preserved. +def caesar(text: str, shift: int, direction: str) -> str: + ## @brief Encode or decode a text with the Caesar cipher. + # @param text Text to transform. + # @param shift Positions to move each letter (may be negative or > 26). + # @param direction DIRECTION_ENCODE or DIRECTION_DECODE. + # @return The transformed text; non-letters are kept, case is preserved. + # @throws ValueError If direction is not a known direction. + if direction == DIRECTION_DECODE: + shift = -shift + elif direction != DIRECTION_ENCODE: + raise ValueError(f"Unknown direction: {direction!r}") + result = [] for char in text: lower = char.lower() @@ -21,9 +28,17 @@ def encrypt(text: str, shift: int) -> str: return "".join(result) +def encrypt(text: str, shift: int) -> str: + ## @brief Encrypt a text with the Caesar cipher. + # @param text Original text. + # @param shift Number of positions to shift forward. + # @return The encrypted text. + return caesar(text, shift, DIRECTION_ENCODE) + + def decrypt(text: str, shift: int) -> str: ## @brief Decrypt a Caesar-cipher text. # @param text Encrypted text. # @param shift Shift that was used to encrypt. # @return The original text. - return encrypt(text, -shift) + return caesar(text, shift, DIRECTION_DECODE) diff --git a/src/caesar_cipher/constants.py b/src/caesar_cipher/constants.py index 611caf7..35e8465 100644 --- a/src/caesar_cipher/constants.py +++ b/src/caesar_cipher/constants.py @@ -4,10 +4,38 @@ ## Lowercase alphabet used to look up letter positions. ALPHABET = list("abcdefghijklmnopqrstuvwxyz") +## Direction keyword for encoding. +DIRECTION_ENCODE = "encode" +## Direction keyword for decoding. +DIRECTION_DECODE = "decode" + +## Prompt for the direction. +PROMPT_DIRECTION = "Type 'encode' to encrypt, type 'decode' to decrypt:\n" ## Prompt for the message. PROMPT_TEXT = "Type your message:\n" ## Prompt for the shift number. PROMPT_SHIFT = "Type the shift number:\n" +## Prompt to continue. +PROMPT_AGAIN = "Type 'yes' if you want to go again. Otherwise type 'no'.\n" ## Name of the environment file holding tokens. ENV_FILE = ".env" + +## ASCII art title shown at start-up. +TITLE = r""" + ____ _ _____ ____ _ ____ + / ___| / \ | ____/ ___| / \ | _ \ +| | / _ \ | _| \___ \ / _ \ | |_) | +| |___ / ___ \| |___ ___) / ___ \| _ < + \____/_/ \_\_____|____/_/ \_\_| \_\ + ____ ___ ____ _ _ _____ ____ + / ___|_ _| _ \| | | | ____| _ \ +| | | || |_) | |_| | _| | |_) | +| |___ | || __/| _ | |___| _ < + \____|___|_| |_| |_|_____|_| \_\ +""" + +## ANSI escape code for bright cyan text. +COLOR_TITLE = "\033[96m" +## ANSI escape code that resets text colour. +COLOR_RESET = "\033[0m" diff --git a/src/caesar_cipher/main.py b/src/caesar_cipher/main.py index 6c84d9d..426faff 100644 --- a/src/caesar_cipher/main.py +++ b/src/caesar_cipher/main.py @@ -1,20 +1,43 @@ -## @file main.py -# @brief Interactive command line interface. - -from caesar_cipher import constants as c -from caesar_cipher.cipher import encrypt - - -def main() -> None: - ## @brief Ask for a message and a shift, then print the encrypted text. - text = input(c.PROMPT_TEXT) - try: - shift = int(input(c.PROMPT_SHIFT)) - except ValueError: - print("The shift must be a whole number.") - return - print(f"Here's the encoded result: {encrypt(text, shift)}") - - -if __name__ == "__main__": - main() +## @file main.py +# @brief Interactive command line interface. + +import os +import sys + +from caesar_cipher import constants as c +from caesar_cipher.cipher import caesar + + +def print_title() -> None: + ## @brief Print the ASCII title, in colour when the terminal supports it. + if sys.stdout.isatty() and "NO_COLOR" not in os.environ: + os.system("") # enables ANSI escape sequences on older Windows consoles + print(f"{c.COLOR_TITLE}{c.TITLE}{c.COLOR_RESET}") + else: + print(c.TITLE) + + +def main() -> None: + ## @brief Run the interactive encode/decode loop until the user stops. + print_title() + while True: + direction = input(c.PROMPT_DIRECTION).strip().lower() + if direction not in (c.DIRECTION_ENCODE, c.DIRECTION_DECODE): + print("Please type 'encode' or 'decode'.") + continue + text = input(c.PROMPT_TEXT) + try: + shift = int(input(c.PROMPT_SHIFT)) + except ValueError: + print("The shift must be a whole number.") + continue + + print(f"Here's the {direction}d result: {caesar(text, shift, direction)}") + + if input(c.PROMPT_AGAIN).strip().lower() != "yes": + print("Goodbye") + break + + +if __name__ == "__main__": + main() diff --git a/tests/test_cipher.py b/tests/test_cipher.py index 7332899..b3b9603 100644 --- a/tests/test_cipher.py +++ b/tests/test_cipher.py @@ -1,4 +1,6 @@ -from caesar_cipher import decrypt, encrypt +import pytest + +from caesar_cipher import caesar, decrypt, encrypt from caesar_cipher.env import load_env @@ -37,3 +39,13 @@ def test_decrypt_roundtrip(): def test_decrypt_wraps_around_alphabet(): assert decrypt("abc", 3) == "xyz" assert decrypt("def", 29) == "abc" + + +def test_caesar_encode_and_decode(): + assert caesar("hello", 5, "encode") == "mjqqt" + assert caesar("mjqqt", 5, "decode") == "hello" + + +def test_caesar_unknown_direction(): + with pytest.raises(ValueError): + caesar("hello", 5, "sideways") diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..1a2f651 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,28 @@ +from caesar_cipher.main import main + + +def run(monkeypatch, capsys, answers): + it = iter(answers) + monkeypatch.setattr("builtins.input", lambda _="": next(it)) + main() + return capsys.readouterr().out + + +def test_encode_then_stop(monkeypatch, capsys): + out = run(monkeypatch, capsys, ["encode", "hello", "5", "no"]) + assert "mjqqt" in out and "Goodbye" in out + + +def test_decode_then_go_again(monkeypatch, capsys): + out = run(monkeypatch, capsys, ["decode", "mjqqt", "5", "yes", "encode", "a", "1", "no"]) + assert "hello" in out and "b" in out + + +def test_invalid_input_reprompts(monkeypatch, capsys): + out = run(monkeypatch, capsys, ["nope", "decode", "x", "abc", "encode", "a", "1", "no"]) + assert "encode' or 'decode" in out and "whole number" in out + + +def test_title_printed_plain_when_not_a_tty(monkeypatch, capsys): + out = run(monkeypatch, capsys, ["encode", "a", "1", "no"]) + assert "____" in out and "\033[" not in out -- 2.54.0