616 lines
19 KiB
Markdown
616 lines
19 KiB
Markdown
# Implementation Guide
|
|
|
|
This document walks through the actual code. We'll build key features step by step and explain the decisions along the way.
|
|
|
|
## File Structure Walkthrough
|
|
```
|
|
caesar-cipher/
|
|
├── src/caesar_cipher/
|
|
│ ├── __init__.py # Package exports for CaesarCipher and FrequencyAnalyzer
|
|
│ ├── cipher.py # Core encryption/decryption logic (62 lines)
|
|
│ ├── analyzer.py # Chi-squared frequency analysis (60 lines)
|
|
│ ├── constants.py # English letter frequencies, alphabet (43 lines)
|
|
│ ├── main.py # Typer CLI with 3 commands (171 lines)
|
|
│ └── utils.py # File I/O and validation (40 lines)
|
|
├── tests/
|
|
│ ├── test_cipher.py # Cipher tests with edge cases
|
|
│ ├── test_analyzer.py # Frequency analysis tests
|
|
│ └── test_cli.py # End-to-end CLI tests
|
|
└── pyproject.toml # Dependencies and tool config
|
|
```
|
|
|
|
## Building the Core Cipher
|
|
|
|
### Step 1: Character Shifting
|
|
|
|
What we're building: The fundamental operation that takes one letter and shifts it by N positions.
|
|
|
|
In `cipher.py:30-40`:
|
|
```python
|
|
def _shift_char(self, char: str, shift: int) -> str:
|
|
"""
|
|
Shift a single character by the specified amount while preserving case
|
|
"""
|
|
if char in UPPERCASE_LETTERS:
|
|
idx = UPPERCASE_LETTERS.index(char)
|
|
return UPPERCASE_LETTERS[(idx + shift) % ALPHABET_SIZE]
|
|
if char in LOWERCASE_LETTERS:
|
|
idx = LOWERCASE_LETTERS.index(char)
|
|
return LOWERCASE_LETTERS[(idx + shift) % ALPHABET_SIZE]
|
|
return char
|
|
```
|
|
|
|
**Why this code works:**
|
|
- Line 35-36: Uppercase letters shift within uppercase. 'A' + 3 = 'D'. The modulo handles wrapping ('Z' + 1 = 'A').
|
|
- Line 37-39: Lowercase letters shift within lowercase independently. Preserves case.
|
|
- Line 40: Non-letters return unchanged. Spaces, punctuation, numbers pass through.
|
|
|
|
**Common mistakes here:**
|
|
```python
|
|
# Wrong approach - converts everything to uppercase
|
|
def shift_char(char, shift):
|
|
if char.isalpha():
|
|
idx = ord(char.upper()) - ord('A')
|
|
return chr((idx + shift) % 26 + ord('A'))
|
|
return char
|
|
|
|
# Why this fails: "Hello" becomes "KHOOR" instead of "Khoor"
|
|
```
|
|
|
|
You need separate handling for upper and lower case. The code uses two different alphabet strings (`UPPERCASE_LETTERS` and `LOWERCASE_LETTERS` from constants.py) to preserve case naturally.
|
|
|
|
### Step 2: Full Message Encryption
|
|
|
|
Now we need to apply the shift to every character in the message.
|
|
|
|
In `cipher.py:43-46`:
|
|
```python
|
|
def encrypt(self, plaintext: str) -> str:
|
|
"""
|
|
Encrypt plaintext using the configured shift key
|
|
"""
|
|
return "".join(self._shift_char(char, self.key) for char in plaintext)
|
|
```
|
|
|
|
**What's happening:**
|
|
1. Generator expression iterates each character: `for char in plaintext`
|
|
2. Calls `_shift_char()` with the instance's key
|
|
3. Joins all results into a string
|
|
|
|
**Why we do it this way:**
|
|
This is more memory efficient than building a list and joining. For huge texts, the generator yields characters one at a time instead of storing all shifted characters in memory.
|
|
|
|
**Alternative approaches:**
|
|
- Loop with `result += shifted_char`: Works but creates intermediate strings (O(n²) time in the worst case)
|
|
- List comprehension then join: `"".join([...])`: Uses more memory but same speed
|
|
|
|
### Step 3: Decryption (The Inverse Operation)
|
|
|
|
In `cipher.py:48-52`:
|
|
```python
|
|
def decrypt(self, ciphertext: str) -> str:
|
|
"""
|
|
Decrypt ciphertext using the configured shift key
|
|
"""
|
|
return "".join(self._shift_char(char, -self.key) for char in ciphertext)
|
|
```
|
|
|
|
**Key parts explained:**
|
|
|
|
Decryption is just encryption with a negative key. If we encrypted with +3, we decrypt with -3. The `_shift_char()` method handles negative shifts automatically via modulo arithmetic:
|
|
```
|
|
'K' is position 10
|
|
Shift by -3: (10 + (-3)) % 26 = 7 % 26 = 7
|
|
Position 7 is 'H'
|
|
```
|
|
|
|
Python's modulo operator handles negatives correctly: `(-1) % 26 = 25`, which wraps 'A' back to 'Z'.
|
|
|
|
## Building the Brute Force Cracker
|
|
|
|
### The Problem
|
|
|
|
You have ciphertext but don't know the key. With only 26 possibilities, try them all.
|
|
|
|
### The Solution
|
|
|
|
Generate all 26 decryptions and let frequency analysis pick the best one.
|
|
|
|
### Implementation
|
|
|
|
In `cipher.py:53-60`:
|
|
```python
|
|
@staticmethod
|
|
def crack(ciphertext: str) -> list[tuple[int, str]]:
|
|
"""
|
|
Attempt all possible shifts to decrypt ciphertext without knowing the key
|
|
"""
|
|
results = []
|
|
for shift in range(ALPHABET_SIZE):
|
|
cipher = CaesarCipher(key=shift)
|
|
decrypted = cipher.decrypt(ciphertext)
|
|
results.append((shift, decrypted))
|
|
return results
|
|
```
|
|
|
|
**Why static method:**
|
|
This doesn't operate on a specific instance. It generates all possible instances. Making it static makes the intent clear: `CaesarCipher.crack(text)` is like asking "what are all Caesar decryptions of this text?"
|
|
|
|
**Optimization we didn't do:**
|
|
We could avoid creating 26 cipher objects and just call `_shift_char()` directly. But clarity beats micro-optimization here. Creating 26 objects is negligible.
|
|
|
|
## Building Frequency Analysis
|
|
|
|
### The Problem
|
|
|
|
When you crack a message, you get 26 candidates. Which one is real English?
|
|
|
|
### The Solution
|
|
|
|
Score each candidate by how closely its letter frequencies match known English frequencies. Lower chi-squared scores mean better matches.
|
|
|
|
### Implementation
|
|
|
|
In `analyzer.py:27-42`:
|
|
```python
|
|
def calculate_chi_squared(self, text: str) -> float:
|
|
"""
|
|
Calculate chi-squared statistic comparing text to expected English frequencies
|
|
"""
|
|
text_upper = text.upper()
|
|
letter_counts = Counter(char for char in text_upper if char.isalpha())
|
|
|
|
if not letter_counts:
|
|
return float("inf")
|
|
|
|
total_letters = sum(letter_counts.values())
|
|
chi_squared = 0.0
|
|
|
|
for letter, expected_freq in self.reference_frequencies.items():
|
|
observed_count = letter_counts.get(letter, 0)
|
|
expected_count = (expected_freq / 100) * total_letters
|
|
|
|
if expected_count > 0:
|
|
chi_squared += ((observed_count - expected_count)**2) / expected_count
|
|
|
|
return chi_squared
|
|
```
|
|
|
|
**Breaking it down:**
|
|
|
|
**Lines 29-30:** Convert to uppercase and count only letters. `Counter` gives us `{'H': 1, 'E': 1, 'L': 2, 'O': 1}` for "HELLO".
|
|
|
|
**Lines 32-33:** Empty text has infinite badness. Can't score nothing.
|
|
|
|
**Line 35:** Total count needed to convert percentages to expected counts.
|
|
|
|
**Lines 38-41:** For each letter A-Z, compare observed vs expected:
|
|
- Expected: If the text has 100 letters and E should be 12.7%, we expect 12.7 letters of E
|
|
- Observed: How many E's are actually there
|
|
- Chi-squared adds up squared differences normalized by expected values
|
|
|
|
Lower scores mean closer to English. Gibberish has wild frequency distributions and scores high.
|
|
|
|
### Ranking Candidates
|
|
|
|
In `analyzer.py:53-60`:
|
|
```python
|
|
def rank_candidates(self, candidates: list[tuple[int, str]]) -> list[tuple[int, str, float]]:
|
|
"""
|
|
Rank decryption candidates by their English frequency score
|
|
"""
|
|
scored = [
|
|
(shift, text, self.score_text(text))
|
|
for shift, text in candidates
|
|
]
|
|
return sorted(scored, key=lambda x: x[2])
|
|
```
|
|
|
|
Takes list of `(shift, decrypted_text)` tuples, adds scores, sorts by score (ascending, so best is first).
|
|
|
|
## CLI Implementation
|
|
|
|
### Command Structure
|
|
|
|
The tool has three commands: encrypt, decrypt, crack. All use similar patterns.
|
|
|
|
**Encrypt command** (`main.py:24-60`):
|
|
```python
|
|
@app.command()
|
|
def encrypt(
|
|
text: Annotated[str | None, typer.Argument(...)] = None,
|
|
key: Annotated[int, typer.Option("--key", "-k", ...)] = 3,
|
|
input_file: Annotated[Path | None, typer.Option(...)] = None,
|
|
output_file: Annotated[Path | None, typer.Option(...)] = None,
|
|
quiet: Annotated[bool, typer.Option(...)] = False,
|
|
) -> None:
|
|
```
|
|
|
|
**The Annotated syntax:**
|
|
Typer uses type hints to generate the CLI. `Annotated[str | None, typer.Argument(...)]` means "this is an optional string argument". The help text is in the `Argument()` call.
|
|
|
|
**Validation flow:**
|
|
```python
|
|
try:
|
|
validate_key(key) # utils.py:36
|
|
plaintext = read_input(text, input_file) # utils.py:11
|
|
cipher = CaesarCipher(key=key)
|
|
encrypted = cipher.encrypt(plaintext)
|
|
# ... output ...
|
|
except (ValueError, OSError) as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(code=1) from None
|
|
```
|
|
|
|
**Error handling:**
|
|
Catch both `ValueError` (from validation) and `OSError` (from file operations). Print error in red using Rich, then exit with code 1. The `from None` suppresses the exception chain in the output.
|
|
|
|
### Input Handling
|
|
|
|
Three ways to provide input: command line argument, file, or stdin.
|
|
|
|
In `utils.py:11-24`:
|
|
```python
|
|
def read_input(text: str | None, input_file: Path | None) -> str:
|
|
"""
|
|
Read input from text argument, file, or stdin
|
|
"""
|
|
if text:
|
|
return text
|
|
|
|
if input_file:
|
|
return input_file.read_text(encoding="utf-8")
|
|
|
|
if not sys.stdin.isatty():
|
|
return sys.stdin.read()
|
|
|
|
raise ValueError("No input provided. Use TEXT argument, --input-file, or pipe to stdin")
|
|
```
|
|
|
|
**Priority order:**
|
|
1. Explicit text argument
|
|
2. Input file
|
|
3. Stdin (but only if it's piped, not interactive terminal)
|
|
|
|
**The `sys.stdin.isatty()` check:**
|
|
Returns `False` when input is piped: `echo "test" | caesar-cipher encrypt`. Returns `True` when running interactively. We only read stdin if it's piped to avoid hanging waiting for user input that won't come.
|
|
|
|
### Rich Table Output
|
|
|
|
The crack command displays results in a formatted table.
|
|
|
|
In `main.py:135-148`:
|
|
```python
|
|
table = Table(title="Caesar Cipher Brute Force Results")
|
|
table.add_column("Rank", style="cyan", justify="right")
|
|
table.add_column("Shift", style="magenta", justify="right")
|
|
table.add_column("Score", style="yellow", justify="right")
|
|
table.add_column("Decrypted Text", style="green")
|
|
|
|
display_count = len(ranked) if show_all else min(top, len(ranked))
|
|
|
|
for rank, (shift, text_result, score) in enumerate(ranked[:display_count], 1):
|
|
table.add_row(
|
|
str(rank),
|
|
str(shift),
|
|
f"{score:.2f}",
|
|
text_result[:80] # Truncate long text
|
|
)
|
|
|
|
console.print(table)
|
|
```
|
|
|
|
**Why Rich:**
|
|
Colored output makes it obvious which columns are which. The table auto-formats and aligns columns. Much nicer than printing tab-separated values.
|
|
|
|
**Truncation:**
|
|
`text_result[:80]` limits displayed text to 80 characters. Otherwise really long decryptions make the table unreadable.
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests for Cipher
|
|
|
|
Example test for encryption (`test_cipher.py:14-16`):
|
|
```python
|
|
def test_encrypt_basic(self) -> None:
|
|
cipher = CaesarCipher(key=3)
|
|
assert cipher.encrypt("HELLO") == "KHOOR"
|
|
```
|
|
|
|
**What this tests:**
|
|
Basic shift functionality. If this fails, everything is broken.
|
|
|
|
**Edge cases tested:**
|
|
- Wraparound: `test_alphabet_wraparound_uppercase()` checks 'XYZ' → 'ABC'
|
|
- Case preservation: `test_encrypt_mixed_case()` checks "Hello World" → "Khoor Zruog"
|
|
- Non-letters: `test_encrypt_preserves_punctuation()` checks "Hello!" → "Khoor!"
|
|
- Empty string: `test_empty_string()` checks "" → ""
|
|
|
|
**Roundtrip test** (`test_cipher.py:42-46`):
|
|
```python
|
|
def test_encrypt_decrypt_roundtrip(self) -> None:
|
|
cipher = CaesarCipher(key=13)
|
|
original = "The Quick Brown Fox Jumps Over The Lazy Dog!"
|
|
encrypted = cipher.encrypt(original)
|
|
decrypted = cipher.decrypt(encrypted)
|
|
assert decrypted == original
|
|
```
|
|
|
|
This catches asymmetry bugs. If encrypt and decrypt aren't true inverses, this fails.
|
|
|
|
### Integration Tests for Analyzer
|
|
|
|
Testing that frequency analysis actually picks the right answer (`test_analyzer.py:44-54`):
|
|
```python
|
|
def test_rank_candidates_with_actual_cipher(self) -> None:
|
|
cipher = CaesarCipher(key=3)
|
|
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
|
ciphertext = cipher.encrypt(plaintext)
|
|
|
|
candidates = CaesarCipher.crack(ciphertext)
|
|
analyzer = FrequencyAnalyzer()
|
|
ranked = analyzer.rank_candidates(candidates)
|
|
|
|
best_shift, best_text, _best_score = ranked[0]
|
|
assert best_shift == 3
|
|
assert best_text == plaintext
|
|
```
|
|
|
|
This is an integration test because it uses both cipher and analyzer together. It verifies the whole crack workflow works end to end.
|
|
|
|
### CLI Tests
|
|
|
|
Testing the actual commands (`test_cli.py:14-18`):
|
|
```python
|
|
def test_encrypt_basic(self) -> None:
|
|
result = runner.invoke(app, ["encrypt", "HELLO", "--key", "3"])
|
|
assert result.exit_code == 0
|
|
assert "KHOOR" in result.stdout
|
|
```
|
|
|
|
Uses Typer's test runner to simulate command line invocation. Checks exit code and output.
|
|
|
|
**File I/O test** (`test_cli.py:63-73`):
|
|
```python
|
|
def test_encrypt_from_file(self, tmp_path: Path) -> None:
|
|
input_file = tmp_path / "input.txt"
|
|
input_file.write_text("HELLO WORLD")
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["encrypt", "--input-file", str(input_file), "--key", "3"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "KHOOR ZRUOG" in result.stdout
|
|
```
|
|
|
|
Creates a temp file, reads from it, verifies output. This tests the file handling path without leaving artifacts.
|
|
|
|
## Common Implementation Pitfalls
|
|
|
|
### Pitfall 1: Forgetting to Handle Negative Keys
|
|
|
|
**Symptom:**
|
|
Crash or wrong output when using `--key -3`.
|
|
|
|
**Cause:**
|
|
```python
|
|
# Problematic code - doesn't normalize negative keys
|
|
def __init__(self, key: int):
|
|
self.key = key # If key=-3, modulo in _shift_char breaks
|
|
```
|
|
|
|
**Fix:**
|
|
```python
|
|
# Correct approach (cipher.py:23)
|
|
self.key = key % ALPHABET_SIZE
|
|
```
|
|
|
|
Normalizing during construction means all other code can assume key is 0-25.
|
|
|
|
**Why this matters:**
|
|
Caesar cipher with key=-3 is the same as key=23. Both shift left by 3. Normalizing makes them equivalent.
|
|
|
|
### Pitfall 2: Not Handling Empty Input
|
|
|
|
**Symptom:**
|
|
Division by zero in chi-squared calculation.
|
|
|
|
**Cause:**
|
|
```python
|
|
# Bad - crashes if text is empty
|
|
def calculate_chi_squared(text):
|
|
total = sum(counts.values())
|
|
# ... expected_count / total will divide by zero
|
|
```
|
|
|
|
**Fix:**
|
|
```python
|
|
# Good - early return (analyzer.py:32-33)
|
|
if not letter_counts:
|
|
return float("inf")
|
|
```
|
|
|
|
Empty text has infinitely bad frequency match. Can't be English if there are no letters.
|
|
|
|
### Pitfall 3: Mixing Up Encryption and Decryption
|
|
|
|
**Symptom:**
|
|
`decrypt("KHOOR")` with key=3 gives gibberish instead of "HELLO".
|
|
|
|
**Cause:**
|
|
```python
|
|
# Wrong - decrypt shifts forward instead of backward
|
|
def decrypt(self, ciphertext):
|
|
return self.encrypt(ciphertext) # This is just double encryption!
|
|
```
|
|
|
|
**Fix:**
|
|
```python
|
|
# Right - decrypt shifts backward (cipher.py:51)
|
|
return "".join(self._shift_char(char, -self.key) for char in ciphertext)
|
|
```
|
|
|
|
Decryption must reverse the shift. Negative key does this.
|
|
|
|
## Debugging Tips
|
|
|
|
### Issue Type 1: Frequency Analysis Gives Wrong Answer
|
|
|
|
**Problem:** The best ranked candidate isn't the correct decryption.
|
|
|
|
**How to debug:**
|
|
1. Check `constants.py:17-43` - Are the English frequencies correct?
|
|
2. Print scores for all 26 candidates - Is the correct one close to the top?
|
|
3. Try with longer ciphertext - Short text doesn't have enough letters for reliable frequency analysis
|
|
|
|
**Common causes:**
|
|
- Text is too short (under 50 letters)
|
|
- Text isn't English (frequency analysis assumes English)
|
|
- Text has unusual word distribution (technical jargon, names)
|
|
|
|
### Issue Type 2: CLI Hangs When Running Command
|
|
|
|
**Problem:** `caesar-cipher encrypt --key 3` hangs forever.
|
|
|
|
**How to debug:**
|
|
1. Check if you forgot to provide input text
|
|
2. Look at `utils.py:20-21` - If stdin isn't a TTY, it waits for piped input
|
|
3. Either provide text: `caesar-cipher encrypt "HELLO" --key 3` or pipe it: `echo "HELLO" | caesar-cipher encrypt --key 3`
|
|
|
|
**Common cause:**
|
|
Running the command without text and without piping stdin. The code waits for input that never comes.
|
|
|
|
## Code Organization Principles
|
|
|
|
### Why cipher.py is Independent
|
|
```
|
|
cipher.py
|
|
├── Only imports from constants
|
|
├── No CLI dependencies
|
|
└── No analyzer dependencies
|
|
```
|
|
|
|
We separate the cipher from its applications because:
|
|
- You can unit test encryption without mocking CLI
|
|
- The cipher could be used in a web API without changing anything
|
|
- Testing focuses on algorithm correctness, not I/O
|
|
|
|
This makes the codebase easier to reason about. If tests in `test_cipher.py` pass, the algorithm is correct regardless of how it's invoked.
|
|
|
|
### Naming Conventions
|
|
|
|
- `_shift_char` (leading underscore) = Internal helper method, not part of public API
|
|
- `UPPERCASE_LETTERS` (all caps) = Constant that shouldn't change
|
|
- `crack` (verb) = Methods are actions
|
|
|
|
Following these patterns makes it easier to distinguish between public API, internal implementation, and configuration.
|
|
|
|
## Extending the Code
|
|
|
|
### Adding a New Alphabet
|
|
|
|
Want to support numbers in encryption? Here's the process:
|
|
|
|
1. **Modify constants** in `constants.py:10-11`
|
|
```python
|
|
DIGITS = string.digits
|
|
ALL_CHARS = UPPERCASE_LETTERS + LOWERCASE_LETTERS + DIGITS
|
|
```
|
|
|
|
2. **Update _shift_char** in `cipher.py:30-40`
|
|
```python
|
|
def _shift_char(self, char: str, shift: int) -> str:
|
|
if char in UPPERCASE_LETTERS:
|
|
idx = UPPERCASE_LETTERS.index(char)
|
|
return UPPERCASE_LETTERS[(idx + shift) % 26]
|
|
if char in LOWERCASE_LETTERS:
|
|
idx = LOWERCASE_LETTERS.index(char)
|
|
return LOWERCASE_LETTERS[(idx + shift) % 26]
|
|
if char in DIGITS:
|
|
idx = DIGITS.index(char)
|
|
return DIGITS[(idx + shift) % 10] # 10 digits
|
|
return char
|
|
```
|
|
|
|
3. **Add tests** in `test_cipher.py`
|
|
```python
|
|
def test_encrypt_digits(self) -> None:
|
|
cipher = CaesarCipher(key=3)
|
|
assert cipher.encrypt("Test123") == "Whvw456"
|
|
```
|
|
|
|
The architecture makes this easy because cipher logic is isolated from everything else.
|
|
|
|
## Dependencies
|
|
|
|
### Why Each Dependency
|
|
|
|
- **typer** (0.20.0): CLI framework with automatic help and type hints. Better than argparse for modern Python.
|
|
- **rich** (14.2.0): Colored terminal output and table formatting. Makes the crack command output readable.
|
|
- **pytest** (9.0.2): Test framework. Standard for Python testing.
|
|
- **mypy** (1.19.0): Static type checker. Catches type errors before runtime.
|
|
- **ruff** (0.14.8): Fast linter combining many tools. Replaces flake8, isort, black.
|
|
|
|
### Dependency Security
|
|
|
|
Check for vulnerabilities:
|
|
```bash
|
|
pip install pip-audit
|
|
pip-audit
|
|
```
|
|
|
|
If you see a vulnerability in typer or rich, check if there's a newer version that fixes it. Update the version constraints in `pyproject.toml:7-9`.
|
|
|
|
## Build and Deploy
|
|
|
|
### Building
|
|
```bash
|
|
# Install in development mode
|
|
pip install -e .
|
|
|
|
# Or build a wheel
|
|
pip install build
|
|
python -m build
|
|
```
|
|
|
|
This produces a wheel in `dist/` that can be installed anywhere.
|
|
|
|
### Local Development
|
|
```bash
|
|
# Install with dev dependencies
|
|
pip install -e ".[dev]"
|
|
|
|
# Run tests
|
|
pytest
|
|
|
|
# Run linter
|
|
ruff check src/ tests/
|
|
|
|
# Run type checker
|
|
mypy src/
|
|
```
|
|
|
|
The `[dev]` extra includes testing and linting tools from `pyproject.toml:10-17`.
|
|
|
|
### Production Deployment
|
|
|
|
For a CLI tool, "production" means publishing to PyPI:
|
|
```bash
|
|
# Build
|
|
python -m build
|
|
|
|
# Upload to PyPI
|
|
python -m twine upload dist/*
|
|
```
|
|
|
|
Then users install with `pip install caesar-salad-cipher` and get the `caesar-cipher` command in their PATH.
|
|
|
|
## Next Steps
|
|
|
|
You've seen how the code works. Now:
|
|
|
|
1. **Try the challenges** - [04-CHALLENGES.md](./04-CHALLENGES.md) has extension ideas like Vigenère cipher
|
|
2. **Modify the code** - Change the frequency scoring to use bigrams (two-letter pairs) instead of single letters
|
|
3. **Read related projects** - Look at real-world frequency analysis tools for inspiration
|