Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c89da47d2a | ||
|
|
27952755d3 | ||
|
|
9d8beb3aef | ||
|
|
d4b3e6ab19 | ||
|
|
621f5f7272 | ||
|
|
cdbf6396ab | ||
|
|
0957bff43a |
@@ -174,3 +174,6 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Doxygen output
|
||||
docs/doxygen/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
PROJECT_NAME = "Caesar Cipher"
|
||||
PROJECT_BRIEF = "Udemy 100 Days of Code - Day 8"
|
||||
OUTPUT_DIRECTORY = docs/doxygen
|
||||
INPUT = src README.md docs/index.md
|
||||
USE_MDFILE_AS_MAINPAGE = README.md
|
||||
FILE_PATTERNS = *.py *.md
|
||||
RECURSIVE = YES
|
||||
EXTRACT_ALL = YES
|
||||
OPTIMIZE_OUTPUT_JAVA = YES
|
||||
GENERATE_HTML = YES
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
@@ -1,2 +1,78 @@
|
||||
# 008-caesar_cipher
|
||||
# 🔐 Caesar Cipher
|
||||
|
||||
A beginner-friendly Python program that encodes and decodes messages with the Caesar cipher.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
|
||||
## 🧭 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. 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
|
||||
|
||||
- Python 3.13 or newer
|
||||
- `pytest` (development only; no runtime dependencies)
|
||||
- Optional: [Doxygen](https://www.doxygen.nl/) to build the API docs
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
Create and activate a local virtual environment, then upgrade pip and install the project:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
Activate it:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
source .venv/bin/activate
|
||||
# Git Bash on Windows
|
||||
source .venv/Scripts/activate
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## ▶️ Run
|
||||
|
||||
```bash
|
||||
python -m caesar_cipher
|
||||
```
|
||||
|
||||
or, after installation, `caesar-cipher`.
|
||||
|
||||

|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Build the API docs with `doxygen Doxyfile` (output in `docs/doxygen/html`).
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU AGPL-3.0 – see [LICENSE](LICENSE).
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Repository](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code)
|
||||
- [Documentation](docs/index.md)
|
||||
- [Issue tracker](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/008-caesar_cipher/issues)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="700" viewBox="0 0 760 700">
|
||||
<rect width="760" height="700" rx="8" fill="#0d1117"/>
|
||||
<circle cx="22" cy="20" r="6" fill="#ff5f56"/><circle cx="42" cy="20" r="6" fill="#ffbd2e"/><circle cx="62" cy="20" r="6" fill="#27c93f"/>
|
||||
<g font-family="Consolas,Menlo,monospace" font-size="14" xml:space="preserve">
|
||||
<text x="20" y="50" fill="#56d4dd"> ____ _ _____ ____ _ ____</text>
|
||||
<text x="20" y="70" fill="#56d4dd"> / ___| / \ | ____/ ___| / \ | _ \</text>
|
||||
<text x="20" y="90" fill="#56d4dd">| | / _ \ | _| \___ \ / _ \ | |_) |</text>
|
||||
<text x="20" y="110" fill="#56d4dd">| |___ / ___ \| |___ ___) / ___ \| _ <</text>
|
||||
<text x="20" y="130" fill="#56d4dd"> \____/_/ \_\_____|____/_/ \_\_| \_\</text>
|
||||
<text x="20" y="150" fill="#56d4dd"> ____ ___ ____ _ _ _____ ____</text>
|
||||
<text x="20" y="170" fill="#56d4dd"> / ___|_ _| _ \| | | | ____| _ \</text>
|
||||
<text x="20" y="190" fill="#56d4dd">| | | || |_) | |_| | _| | |_) |</text>
|
||||
<text x="20" y="210" fill="#56d4dd">| |___ | || __/| _ | |___| _ <</text>
|
||||
<text x="20" y="230" fill="#56d4dd"> \____|___|_| |_| |_|_____|_| \_\</text>
|
||||
<text x="20" y="250" fill="#f0c674"></text>
|
||||
<text x="20" y="270" fill="#c9d1d9">Type 'encode' to encrypt, type 'decode' to decrypt:</text>
|
||||
<text x="20" y="290" fill="#7ee787">encode</text>
|
||||
<text x="20" y="310" fill="#c9d1d9">Type your message:</text>
|
||||
<text x="20" y="330" fill="#7ee787">Hello, World!</text>
|
||||
<text x="20" y="350" fill="#c9d1d9">Type the shift number:</text>
|
||||
<text x="20" y="370" fill="#7ee787">3</text>
|
||||
<text x="20" y="390" fill="#f0c674">Here's the encoded result: Khoor, Zruog!</text>
|
||||
<text x="20" y="410" fill="#c9d1d9">Type 'yes' if you want to go again. Otherwise type 'no'.</text>
|
||||
<text x="20" y="430" fill="#7ee787">yes</text>
|
||||
<text x="20" y="450" fill="#c9d1d9">Type 'encode' to encrypt, type 'decode' to decrypt:</text>
|
||||
<text x="20" y="470" fill="#7ee787">decode</text>
|
||||
<text x="20" y="490" fill="#c9d1d9">Type your message:</text>
|
||||
<text x="20" y="510" fill="#7ee787">Khoor, Zruog!</text>
|
||||
<text x="20" y="530" fill="#c9d1d9">Type the shift number:</text>
|
||||
<text x="20" y="550" fill="#7ee787">3</text>
|
||||
<text x="20" y="570" fill="#f0c674">Here's the decoded result: Hello, World!</text>
|
||||
<text x="20" y="590" fill="#c9d1d9">Type 'yes' if you want to go again. Otherwise type 'no'.</text>
|
||||
<text x="20" y="610" fill="#7ee787">no</text>
|
||||
<text x="20" y="630" fill="#f0c674">Goodbye</text>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,9 @@
|
||||
# Caesar Cipher – documentation
|
||||
|
||||
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`.
|
||||
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).
|
||||
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "caesar-cipher"
|
||||
version = "0.1.0"
|
||||
description = "A beginner-friendly Caesar cipher encoder/decoder (Udemy 100 Days of Code, Day 8)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[project.scripts]
|
||||
caesar-cipher = "caesar_cipher.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,6 @@
|
||||
## @file __init__.py
|
||||
# @brief Public API of the caesar_cipher package.
|
||||
|
||||
from caesar_cipher.cipher import caesar, decrypt, encrypt
|
||||
|
||||
__all__ = ["caesar", "encrypt", "decrypt"]
|
||||
@@ -0,0 +1,6 @@
|
||||
## @file __main__.py
|
||||
# @brief Enables `python -m caesar_cipher`.
|
||||
|
||||
from caesar_cipher.main import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
## @file cipher.py
|
||||
# @brief Caesar cipher: one shared function for encoding and decoding.
|
||||
|
||||
from caesar_cipher.constants import ALPHABET, DIRECTION_DECODE, DIRECTION_ENCODE
|
||||
|
||||
|
||||
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()
|
||||
if lower in ALPHABET:
|
||||
new_position = (ALPHABET.index(lower) + shift) % len(ALPHABET)
|
||||
new_char = ALPHABET[new_position]
|
||||
result.append(new_char.upper() if char.isupper() else new_char)
|
||||
else:
|
||||
result.append(char)
|
||||
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 caesar(text, shift, DIRECTION_DECODE)
|
||||
@@ -0,0 +1,41 @@
|
||||
## @file constants.py
|
||||
# @brief Constants used by the Caesar cipher.
|
||||
|
||||
## 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"
|
||||
@@ -0,0 +1,31 @@
|
||||
## @file env.py
|
||||
# @brief Minimal stdlib `.env` reader (no third-party dependency).
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from caesar_cipher.constants import ENV_FILE
|
||||
|
||||
|
||||
def load_env(path: str | Path = ENV_FILE) -> dict[str, str]:
|
||||
## @brief Read KEY=VALUE pairs from an env file.
|
||||
# @param path Path of the env file.
|
||||
# @return Mapping of variables; empty if the file does not exist.
|
||||
values: dict[str, str] = {}
|
||||
env_path = Path(path)
|
||||
if not env_path.is_file():
|
||||
return values
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
values[key.strip()] = value.strip().strip("\"'")
|
||||
return values
|
||||
|
||||
|
||||
def get_token(name: str, path: str | Path = ENV_FILE) -> str | None:
|
||||
## @brief Get a token from the process environment or the env file.
|
||||
# @param name Variable name, e.g. GITEA_TOKEN.
|
||||
# @return The token or None when not set.
|
||||
return os.environ.get(name) or load_env(path).get(name)
|
||||
@@ -0,0 +1,43 @@
|
||||
## @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()
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
from caesar_cipher import caesar, decrypt, encrypt
|
||||
from caesar_cipher.env import load_env
|
||||
|
||||
|
||||
def test_encrypt_basic():
|
||||
assert encrypt("hello", 5) == "mjqqt"
|
||||
|
||||
|
||||
def test_wraps_around_alphabet():
|
||||
assert encrypt("xyz", 3) == "abc"
|
||||
assert encrypt("abc", 29) == "def"
|
||||
|
||||
|
||||
def test_non_letters_and_case_preserved():
|
||||
assert encrypt("Hi there, 42!", 1) == "Ij uifsf, 42!"
|
||||
|
||||
|
||||
def test_negative_shift():
|
||||
assert encrypt("def", -3) == "abc"
|
||||
|
||||
|
||||
def test_load_env(tmp_path):
|
||||
f = tmp_path / ".env"
|
||||
f.write_text("# c\nA=1\nB='two'\n", encoding="utf-8")
|
||||
assert load_env(f) == {"A": "1", "B": "two"}
|
||||
assert load_env(tmp_path / "missing") == {}
|
||||
|
||||
|
||||
def test_decrypt_basic():
|
||||
assert decrypt("mjqqt", 5) == "hello"
|
||||
|
||||
|
||||
def test_decrypt_roundtrip():
|
||||
assert decrypt(encrypt("Hello, World!", 7), 7) == "Hello, World!"
|
||||
|
||||
|
||||
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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user