Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion ciphers/porta_cipher.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,125 @@
}


def validate_letters(name: str, value: str, allow_empty: bool = False) -> None:
"""
Check that ``value`` is a ``str`` made only of the ASCII letters A-Z / a-z.

Raises ``TypeError`` if ``value`` is not a ``str`` and ``ValueError`` if it is
empty (unless ``allow_empty``) or contains any other character.

>>> validate_letters("key", "marvin")
>>> validate_letters("text", "", allow_empty=True)
>>> validate_letters("key", None)
Traceback (most recent call last):
...
TypeError: key must be a str, not NoneType
>>> validate_letters("key", "")
Traceback (most recent call last):
...
ValueError: key must not be empty
>>> validate_letters("key", "ab1")
Traceback (most recent call last):
...
ValueError: key must contain only the letters A-Z, found '1'
"""
if not isinstance(value, str):
msg = f"{name} must be a str, not {type(value).__name__}"
raise TypeError(msg)
if not value and not allow_empty:
msg = f"{name} must not be empty"
raise ValueError(msg)
for char in value:
if not (char.isascii() and char.isalpha()):
msg = f"{name} must contain only the letters A-Z, found {char!r}"
raise ValueError(msg)


def generate_table(key: str) -> list[tuple[str, str]]:
"""
Build the list of Porta tables used by ``key``, one per key letter.

``key`` must be a non-empty ``str`` of ASCII letters (upper or lower case).

>>> generate_table('marvin') # doctest: +NORMALIZE_WHITESPACE
[('ABCDEFGHIJKLM', 'UVWXYZNOPQRST'), ('ABCDEFGHIJKLM', 'NOPQRSTUVWXYZ'),
('ABCDEFGHIJKLM', 'STUVWXYZNOPQR'), ('ABCDEFGHIJKLM', 'QRSTUVWXYZNOP'),
('ABCDEFGHIJKLM', 'WXYZNOPQRSTUV'), ('ABCDEFGHIJKLM', 'UVWXYZNOPQRST')]
>>> generate_table('MARVIN') == generate_table('marvin')
True
>>> generate_table('')
Traceback (most recent call last):
...
ValueError: key must not be empty
>>> generate_table('mar vin')
Traceback (most recent call last):
...
ValueError: key must contain only the letters A-Z, found ' '
>>> generate_table('ma5vin')
Traceback (most recent call last):
...
ValueError: key must contain only the letters A-Z, found '5'
>>> generate_table('\u00df')
Traceback (most recent call last):
...
ValueError: key must contain only the letters A-Z, found '\xdf'
>>> generate_table(None)
Traceback (most recent call last):
...
TypeError: key must be a str, not NoneType
>>> generate_table(b'marvin')
Traceback (most recent call last):
...
TypeError: key must be a str, not bytes
"""
validate_letters("key", key)
return [alphabet[char] for char in key.upper()]


def encrypt(key: str, words: str) -> str:
"""
Encrypt ``words`` with the Porta cipher using ``key``.

Input contract: ``key`` must be a non-empty ``str`` of ASCII letters and
``words`` must be a ``str`` of ASCII letters (spaces, digits and punctuation
are not supported). Upper and lower case are treated the same, and the result
is always upper case. An empty ``words`` gives an empty string.

>>> encrypt('marvin', 'jessica')
'QRACRWU'
>>> encrypt('MaRvIn', 'JeSsIcA')
'QRACRWU'
>>> encrypt('marvin', '')
''
>>> encrypt('', 'jessica')
Traceback (most recent call last):
...
ValueError: key must not be empty
>>> encrypt('mar1in', 'jessica')
Traceback (most recent call last):
...
ValueError: key must contain only the letters A-Z, found '1'
>>> encrypt(123, 'jessica')
Traceback (most recent call last):
...
TypeError: key must be a str, not int
>>> encrypt('marvin', None)
Traceback (most recent call last):
...
TypeError: words must be a str, not NoneType
>>> encrypt('marvin', 'hello world')
Traceback (most recent call last):
...
ValueError: words must contain only the letters A-Z, found ' '
>>> encrypt('marvin', 'abc123')
Traceback (most recent call last):
...
ValueError: words must contain only the letters A-Z, found '1'
"""
validate_letters("words", words, allow_empty=True)
table = generate_table(key)
cipher = ""
count = 0
table = generate_table(key)
for char in words.upper():
cipher += get_opponent(table[count], char)
count = (count + 1) % len(table)
Expand All @@ -54,8 +155,19 @@ def encrypt(key: str, words: str) -> str:

def decrypt(key: str, words: str) -> str:
"""
Decrypt ``words`` with ``key``. The Porta cipher is reciprocal, so this is the
same operation as ``encrypt`` and has the same input contract.

>>> decrypt('marvin', 'QRACRWU')
'JESSICA'
>>> decrypt('', 'QRACRWU')
Traceback (most recent call last):
...
ValueError: key must not be empty
>>> decrypt('marvin', 'QRA1')
Traceback (most recent call last):
...
ValueError: words must contain only the letters A-Z, found '1'
"""
return encrypt(key, words)

Expand Down
35 changes: 35 additions & 0 deletions work/R23-T053/PROBE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# R23-T053: bad inputs for ciphers/porta_cipher.py

Probe script: `work/R23-T053/probe_bad_inputs.py` (run from the repo root).
"Before" is the original code (no `raise` statements); "After" is with validation.

| # | Input | Function | Before | After |
|---|---|---|---|---|
| 1 | empty key `('', 'hello')` | `encrypt` | raises `IndexError: list index out of range` | raises `ValueError: key must not be empty` |
| 2 | digits in key `('ab1', 'hello')` | `encrypt` | raises `KeyError: '1'` | raises `ValueError: key must contain only the letters A-Z, found '1'` |
| 3 | space in key `('my key', 'hello')` | `encrypt` | raises `KeyError: ' '` | raises `ValueError: key must contain only the letters A-Z, found ' '` |
| 4 | non-ASCII letter in key `('clé', 'hello')` | `encrypt` | raises `KeyError: 'É'` | raises `ValueError: key must contain only the letters A-Z, found 'é'` |
| 5 | german sharp s in key `('ß', 'hello')` | `encrypt` | returns `'YVPPK'` | raises `ValueError: key must contain only the letters A-Z, found 'ß'` |
| 6 | key is None `(None, 'hello')` | `encrypt` | raises `AttributeError: 'NoneType' object has no attribute 'upper'` | raises `TypeError: key must be a str, not NoneType` |
| 7 | key is int `(123, 'hello')` | `encrypt` | raises `AttributeError: 'int' object has no attribute 'upper'` | raises `TypeError: key must be a str, not int` |
| 8 | key is bytes `(b'key', 'hello')` | `encrypt` | raises `KeyError: 75` | raises `TypeError: key must be a str, not bytes` |
| 9 | text is None `('key', None)` | `encrypt` | raises `AttributeError: 'NoneType' object has no attribute 'upper'` | raises `TypeError: words must be a str, not NoneType` |
| 10 | text has a space `('key', 'hello world')` | `encrypt` | raises `ValueError: substring not found` | raises `ValueError: words must contain only the letters A-Z, found ' '` |
| 11 | text has digits `('key', 'abc123')` | `encrypt` | raises `ValueError: substring not found` | raises `ValueError: words must contain only the letters A-Z, found '1'` |
| 12 | empty text, valid key `('key', '')` | `encrypt` | returns `''` | returns `''` |
| 13 | mixed-case key and text `('MaRvIn', 'JeSsIcA')` | `encrypt` | returns `'QRACRWU'` | returns `'QRACRWU'` |
| 14 | generate_table(empty key) `('',)` | `generate_table` | returns `[]` | raises `ValueError: key must not be empty` |
| 15 | generate_table(None) `(None,)` | `generate_table` | raises `AttributeError: 'NoneType' object has no attribute 'upper'` | raises `TypeError: key must be a str, not NoneType` |
| 16 | decrypt, empty key `('', 'QRACRWU')` | `decrypt` | raises `IndexError: list index out of range` | raises `ValueError: key must not be empty` |
| 17 | decrypt, text has digit `('marvin', 'QRA1')` | `decrypt` | raises `ValueError: substring not found` | raises `ValueError: words must contain only the letters A-Z, found '1'` |
| 18 | very long key (10**6 letters) `('<long>', 'hi')` | `encrypt` | returns `'UV'` | returns `'UV'` |

## Decisions
- **Key** must be a non-empty `str` of ASCII letters A-Z / a-z (case is ignored). Anything else raises: wrong type -> `TypeError`; empty or non-letter character -> `ValueError` naming the bad character.
- **Text** (`words`) must be a `str` of ASCII letters; same errors. Spaces, digits and punctuation are not supported by the cipher tables, so they now raise a clear `ValueError` instead of `ValueError: substring not found`. An empty text is valid and returns `''`.
- `ß` is rejected. Before, `'ß'.upper()` is `'SS'`, so a one-letter key silently became a two-letter key and gave a different cipher text than the user expects (row 5). Requiring ASCII letters closes this.
- Mixed case stays valid (row 13): input is upper-cased.
- A very long key still works (row 18); no limit was added.

## Why TypeError vs ValueError
Wrong type (`None`, `int`, `bytes`) -> `TypeError`; right type but unusable content (empty, digit, space, non-ASCII) -> `ValueError`, as CONTRIBUTING.md and the Python convention suggest.
41 changes: 41 additions & 0 deletions work/R23-T053/probe_bad_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Probe ciphers/porta_cipher.py with bad inputs and print a markdown table.

Run from the repo root: python work/R23-T053/probe_bad_inputs.py
"""

import sys

sys.path.insert(0, ".")
from ciphers.porta_cipher import decrypt, encrypt, generate_table

CASES = [
("empty key", encrypt, ("", "hello")),
("digits in key", encrypt, ("ab1", "hello")),
("space in key", encrypt, ("my key", "hello")),
("non-ASCII letter in key", encrypt, ("clé", "hello")),
("german sharp s in key", encrypt, ("ß", "hello")),
("key is None", encrypt, (None, "hello")),
("key is int", encrypt, (123, "hello")),
("key is bytes", encrypt, (b"key", "hello")),
("text is None", encrypt, ("key", None)),
("text has a space", encrypt, ("key", "hello world")),
("text has digits", encrypt, ("key", "abc123")),
("empty text, valid key", encrypt, ("key", "")),
("mixed-case key and text", encrypt, ("MaRvIn", "JeSsIcA")),
("generate_table(empty key)", generate_table, ("",)),
("generate_table(None)", generate_table, (None,)),
("decrypt, empty key", decrypt, ("", "QRACRWU")),
("decrypt, text has digit", decrypt, ("marvin", "QRA1")),
("very long key (10**6 letters)", encrypt, ("a" * 10**6, "hi")),
]

print("| # | Input | Function | Result |")
print("|---|---|---|---|")
for i, (label, func, args) in enumerate(CASES, 1):
shown = tuple(a if len(repr(a)) < 25 else "<long>" for a in args)
try:
out = func(*args)
res = f"returns `{out!r}`" if len(repr(out)) < 60 else "returns (long result)"
except Exception as e: # noqa: BLE001
res = f"raises `{type(e).__name__}: {e}`"
print(f"| {i} | {label} `{shown}` | `{func.__name__}` | {res} |")