diff --git a/README.md b/README.md index 18c5abc..8a1be73 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,52 @@ -# Turner & Townsend backend assessment +# Turner & Townsend backend technical assessment -These programs need Python 3.10+ and pytest. +This repository contains my solutions to the Turner & Townsend backend technical assessment. While the brief requested solving only one problem, I started with the "spiciest" Stack task for the first hour and then tackled the other two tasks in the second hour. I did come back the next day and spend some extra time improving the README documentation. -Original assessment: https://github.com/turner-townsend/backend-assessment +## Solutions -## The Collatz Conjecture +### [Stack Calculator](./stack/README.md) -Takes numeric input and calculates the number of steps in the Collatz sequence needed to reach 1 +**Complexity: Medium** -## Usage +A stack-based programming language interpreter (Fifth) that supports arithmetic operations and stack manipulation commands. Features an interactive REPL with comprehensive error handling. -```shell -python collatz/collatz.py -``` +### [Roman Numerals Converter](./roman/README.md) -### Example +**Complexity: Medium** -``` -Please enter a whole number: 1 -Result: 0 steps needed to reach 1 -Please enter a whole number: 3 -Step #1: odd, multiply by 3 and add 1 -> 10 -Step #2: even, divide by 2 -> 5 -Step #3: odd, multiply by 3 and add 1 -> 16 -Step #4: even, divide by 2 -> 8 -Step #5: even, divide by 2 -> 4 -Step #6: even, divide by 2 -> 2 -Step #7: even, divide by 2 -> 1 -Result: 8 steps needed to reach 3 -Please enter a whole number: 0 -Result: 0 steps needed to reach 0 -Please enter a whole number: !£$ -ERROR: integer required -``` +Converts Roman numerals to Arabic numbers using additive logic. Includes flexible input handling with case-insensitive processing and automatic filtering of invalid characters. -## Tests +### [Collatz Conjecture Calculator](./collatz/README.md) + +**Complexity: Low** + +Calculates the number of steps in the Collatz sequence (3n + 1 problem) with step-by-step visualization. Demonstrates mathematical algorithm implementation with interactive feedback. + +## Architecture + +All three solutions follow consistent design principles: + +- Class-based structure for encapsulation and testability +- Interactive command-line interfaces with graceful error handling +- Comprehensive test suites with good coverage +- Modern Python features and best practices + +## Quick Start + +**Requirements:** Python 3.10+, pytest ```bash -pytest collatz +# Install dependencies +pip install -r requirements.txt + +# Run solutions +cd stack && python stack.py +cd roman && python roman.py +cd collatz && python collatz.py + +# Run tests +pytest # All tests +pytest stack/ # Individual solution tests ``` -## Roman Numerals - -A simple Roman numerals number converter - -## Usage - -```shell -python roman/roman.py -``` - -### Example - -``` -Please enter some Roman numerals: I -I = 1 -Please enter some Roman numerals: IV -IV = 1 + 5 = 6 -Please enter some Roman numerals: MCMXCIV -MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216 -Please enter some Roman numerals: !£$ -ERROR: Invalid input -``` - -## Tests - -```bash -pytest roman -``` - -## Fifth Stack - -A simple stack-based language called Fifth - -## Usage - -```shell -python stack/stack.py -``` - -### Commands - -- `PUSH ` - push integer onto stack -- `POP` - remove top element -- `SWAP` - swap top two elements -- `DUP` - duplicate top element -- `+`, `-`, `*`, `/` - arithmetic operations -- `EXIT` - quit - -### Example - -``` -stack is [] -PUSH 3 -stack is [3] -PUSH 11 -stack is [3, 11] -+ -stack is [14] -DUP -stack is [14, 14] -PUSH 2 -stack is [14, 14, 2] -* -stack is [14, 28] -SWAP -stack is [28, 14] -/ -stack is [2] -+ -ERROR: two numbers required -POP -stack is [] -EXIT -``` - -## Tests - -```bash -pytest stack -``` +Click the links above to view detailed documentation for each solution. diff --git a/collatz/README.md b/collatz/README.md new file mode 100644 index 0000000..960d4d6 --- /dev/null +++ b/collatz/README.md @@ -0,0 +1,89 @@ +# The Collatz conjecture + +Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#the-collatz-conjecture + +A Python program that calculates the number of steps in the Collatz sequence (3n + 1 problem) needed to reach 1. + +## What is the Collatz conjecture? + +The Collatz conjecture is an unsolved mathematical problem that defines a sequence: + +- Start with any positive integer n +- If n is even: divide by 2 +- If n is odd: multiply by 3 and add 1 +- Repeat until n reaches 1 + +The conjecture states that this sequence will always eventually reach 1, regardless of the starting number. + +## Requirements + +- Python 3.9+ +- pytest (for running tests) + +## Usage + +```bash +python collatz.py +``` + +### Examples + +``` +Please enter a whole number: 1 +Result: 0 steps needed to reach 1 +Please enter a whole number: 3 +Step #1: odd, multiply by 3 and add 1 -> 10 +Step #2: even, divide by 2 -> 5 +Step #3: odd, multiply by 3 and add 1 -> 16 +Step #4: even, divide by 2 -> 8 +Step #5: even, divide by 2 -> 4 +Step #6: even, divide by 2 -> 2 +Step #7: even, divide by 2 -> 1 +Result: 8 steps needed to reach 3 +Please enter a whole number: 0 +Result: 0 steps needed to reach 0 +Please enter a whole number: !£$ +ERROR: integer required +``` + +## Features + +- **Step-by-step output**: Shows each transformation in the sequence +- **Interactive**: Continuous input until Ctrl+C +- **Input validation**: Handles invalid input gracefully +- **Edge cases**: Handles 1, 0, and negative numbers by returning 0 steps + +## Tests + +```bash +pytest -v +``` + +Example output: + +``` +➜ collatz git:(main) ✗ pytest -v +============================================== test session starts ============================================== +platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/collatz +plugins: mock-3.14.0, asyncio-0.21.2 +asyncio: mode=Mode.STRICT +collected 6 items + +test_collatz.py::test_calculate_steps_for_1 PASSED [ 16%] +test_collatz.py::test_calculate_steps_for_2 PASSED [ 33%] +test_collatz.py::test_calculate_steps_for_3 PASSED [ 50%] +test_collatz.py::test_calculate_steps_for_6 PASSED [ 66%] +test_collatz.py::test_calculate_steps_for_large_number PASSED [ 83%] +test_collatz.py::test_calculate_steps_for_zero_and_negative PASSED [100%] + +=============================================== 6 passed in 0.01s =============================================== +``` + +## Design notes + +- **Class-based structure**: Encapsulates the calculation logic for easy testing and reuse +- **Combined calculation and display**: The calculation method handles both computing steps and showing progress +- **Edge case handling**: Returns 0 steps for numbers ≤ 1 +- **User-friendly**: Clear step-by-step output shows the mathematical process as it happens diff --git a/collatz/collatz.py b/collatz/collatz.py index 14f28fd..226f7fd 100644 --- a/collatz/collatz.py +++ b/collatz/collatz.py @@ -1,5 +1,6 @@ import readline + class CollatzCalculator: def calculate_steps(self, n: int) -> int: if n <= 1: @@ -15,16 +16,20 @@ class CollatzCalculator: steps += 1 return steps + 1 + def main(): calculator = CollatzCalculator() while True: try: n = input("Please enter a whole number: ") - print(f"Result: {calculator.calculate_steps(int(n))} steps needed to reach {n}") + print( + f"Result: {calculator.calculate_steps(int(n))} steps needed to reach {n}" + ) except ValueError: print("ERROR: integer required") except (EOFError, KeyboardInterrupt): break + if __name__ == "__main__": main() diff --git a/collatz/test_collatz.py b/collatz/test_collatz.py index c312d6d..98474c9 100644 --- a/collatz/test_collatz.py +++ b/collatz/test_collatz.py @@ -1,27 +1,33 @@ import pytest from collatz import CollatzCalculator + def test_calculate_steps_for_1(): calc = CollatzCalculator() assert calc.calculate_steps(1) == 0 + def test_calculate_steps_for_2(): calc = CollatzCalculator() assert calc.calculate_steps(2) == 2 # 2 -> 1 (even), returns 2 steps + def test_calculate_steps_for_3(): calc = CollatzCalculator() assert calc.calculate_steps(3) == 8 # 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 + def test_calculate_steps_for_6(): calc = CollatzCalculator() assert calc.calculate_steps(6) == 9 # 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 + def test_calculate_steps_for_large_number(): calc = CollatzCalculator() assert calc.calculate_steps(27) == 112 + def test_calculate_steps_for_zero_and_negative(): calc = CollatzCalculator() assert calc.calculate_steps(0) == 0 - assert calc.calculate_steps(-5) == 0 \ No newline at end of file + assert calc.calculate_steps(-5) == 0 diff --git a/roman/README.md b/roman/README.md new file mode 100644 index 0000000..4b84f56 --- /dev/null +++ b/roman/README.md @@ -0,0 +1,86 @@ +# Roman numerals converter + +Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#roman-numerals + +A simple Python program that converts Roman numerals to Arabic numbers using additive logic. + +## Supported numerals + +| Numeral | Value | +| ------- | ----- | +| I | 1 | +| V | 5 | +| X | 10 | +| C | 100 | +| M | 1000 | + +## Usage + +```bash +python roman.py +``` + +### Examples + +``` +Please enter some Roman numerals: I +I = 1 + +Please enter some Roman numerals: VI +VI = 5 + 1 = 6 + +Please enter some Roman numerals: MXVII +MXVII = 1000 + 10 + 5 + 1 + 1 = 1017 + +Please enter some Roman numerals: ABC +ERROR: Invalid input +``` + +## Features + +- **Additive conversion**: Treats all numerals as additive (IV = 1 + 5 = 6) +- **Case insensitive**: Accepts both uppercase and lowercase +- **Input filtering**: Ignores invalid characters, extracts valid numerals +- **Interactive**: Continuous input until Ctrl+C + +## Requirements + +- Python 3.10+ (uses pattern matching syntax) +- pytest (for running tests) + +## Tests + +```bash +pytest -v +``` + +Example output: + +```shell +➜ roman git:(main) ✗ pytest -v +================================================================ test session starts ================================================================ +platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/roman +plugins: mock-3.14.0, asyncio-0.21.2 +asyncio: mode=Mode.STRICT +collected 9 items + +test_roman.py::test_valid_single_numerals PASSED [ 11%] +test_roman.py::test_valid_multiple_numerals PASSED [ 22%] +test_roman.py::test_lowercase_input PASSED [ 33%] +test_roman.py::test_mixed_case_input PASSED [ 44%] +test_roman.py::test_invalid_characters PASSED [ 55%] +test_roman.py::test_mixed_valid_and_invalid_characters PASSED [ 66%] +test_roman.py::test_empty_string PASSED [ 77%] +test_roman.py::test_only_spaces PASSED [ 88%] +test_roman.py::test_spaces_and_valid_numerals PASSED [100%] + +================================================================= 9 passed in 0.02s ================================================================= +``` + +## Design notes + +- **Simple approach**: Uses additive logic only, as the brief states complex Roman numeral rules are not required +- **User-friendly**: Filters out invalid characters rather than rejecting entire input +- **Modern Python**: Uses pattern matching (requires Python 3.10+) diff --git a/roman/roman.py b/roman/roman.py index 4a9faa0..8f00400 100644 --- a/roman/roman.py +++ b/roman/roman.py @@ -1,36 +1,61 @@ import readline -def convert_to_number(numerals: str) -> str: - filtered = list(filter(lambda x: x in "IVXCM", list(numerals.upper()))) - if len(filtered) == 0: - return "ERROR: Invalid input" - values: list[int] = [] - for i in range(len(filtered)): - match filtered[i]: - case "I": - values.append(1) - case "V": - values.append(5) - case "X": - values.append(10) - case "C": - values.append(100) - case "M": - values.append(1000) - - if len(values) > 1: - return f"{''.join(filtered)} = {' + '.join(str(v) for v in values)} = {sum(values)}" - else: - return f"{''.join(filtered)} = {values[0]}" - +class RomanNumeralConverter: + def __init__(self): + self.valid_numerals = "IVXCM" + self.values = {"I": 1, "V": 5, "X": 10, "C": 100, "M": 1000} + + def convert_to_number(self, numerals: str) -> str: + filtered = self._filter_valid_numerals(numerals) + if not filtered: + return "ERROR: Invalid input" + + values = self._convert_numerals_to_values(filtered) + return self._format_output(filtered, values) + + def _filter_valid_numerals(self, numerals: str) -> str: + """Extract only valid Roman numerals from input.""" + filtered_chars = [ + char for char in numerals.upper() if char in self.valid_numerals + ] + return "".join(filtered_chars) + + def _convert_numerals_to_values(self, numerals: str) -> list[int]: + """Convert Roman numeral characters to their integer values.""" + values = [] + for char in numerals: + match char: + case "I": + values.append(1) + case "V": + values.append(5) + case "X": + values.append(10) + case "C": + values.append(100) + case "M": + values.append(1000) + return values + + def _format_output(self, numerals: str, values: list[int]) -> str: + """Format the conversion result for display.""" + if len(values) == 1: + return f"{numerals} = {values[0]}" + else: + values_str = " + ".join(str(v) for v in values) + return f"{numerals} = {values_str} = {sum(values)}" + + def main(): + converter = RomanNumeralConverter() while True: try: numerals = input("Please enter some Roman numerals: ") - print(convert_to_number(numerals)) + print(converter.convert_to_number(numerals)) except (EOFError, KeyboardInterrupt): break + if __name__ == "__main__": main() diff --git a/roman/test_roman.py b/roman/test_roman.py index 0aeea5d..8a974c5 100644 --- a/roman/test_roman.py +++ b/roman/test_roman.py @@ -1,38 +1,88 @@ import pytest -from roman import convert_to_number +from roman import RomanNumeralConverter + +ERROR_MESSAGE = "ERROR: Invalid input" + def test_valid_single_numerals(): - assert convert_to_number("I") == "I = 1" - assert convert_to_number("V") == "V = 5" - assert convert_to_number("X") == "X = 10" - assert convert_to_number("C") == "C = 100" - assert convert_to_number("M") == "M = 1000" + converter = RomanNumeralConverter() + assert converter.convert_to_number("I") == "I = 1" + assert converter.convert_to_number("V") == "V = 5" + assert converter.convert_to_number("X") == "X = 10" + assert converter.convert_to_number("C") == "C = 100" + assert converter.convert_to_number("M") == "M = 1000" + def test_valid_multiple_numerals(): - assert convert_to_number("IV") == "IV = 1 + 5 = 6" - assert convert_to_number("XCM") == "XCM = 10 + 100 + 1000 = 1110" - assert convert_to_number("MCMXCIV") == "MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216" + converter = RomanNumeralConverter() + assert converter.convert_to_number("IV") == "IV = 1 + 5 = 6" + assert converter.convert_to_number("XCM") == "XCM = 10 + 100 + 1000 = 1110" + assert ( + converter.convert_to_number("MCMXCIV") + == "MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216" + ) + def test_lowercase_input(): - assert convert_to_number("ivxcm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116" + converter = RomanNumeralConverter() + assert ( + converter.convert_to_number("ivxcm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116" + ) + def test_mixed_case_input(): - assert convert_to_number("iVxCm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116" + converter = RomanNumeralConverter() + assert ( + converter.convert_to_number("iVxCm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116" + ) + def test_invalid_characters(): - assert convert_to_number("EFGH") == "Invalid input" - assert convert_to_number("123") == "Invalid input" - assert convert_to_number("!@#") == "Invalid input" + converter = RomanNumeralConverter() + assert converter.convert_to_number("EFGH") == ERROR_MESSAGE + assert converter.convert_to_number("123") == ERROR_MESSAGE + assert converter.convert_to_number("!@#") == ERROR_MESSAGE + def test_mixed_valid_and_invalid_characters(): - assert convert_to_number("AIXB") == "IX = 1 + 10 = 11" - assert convert_to_number("M1C2") == "MC = 1000 + 100 = 1100" + converter = RomanNumeralConverter() + assert converter.convert_to_number("AIXB") == "IX = 1 + 10 = 11" + assert converter.convert_to_number("M1C2") == "MC = 1000 + 100 = 1100" + def test_empty_string(): - assert convert_to_number("") == "Invalid input" + converter = RomanNumeralConverter() + assert converter.convert_to_number("") == ERROR_MESSAGE + def test_only_spaces(): - assert convert_to_number(" ") == "Invalid input" + converter = RomanNumeralConverter() + assert converter.convert_to_number(" ") == ERROR_MESSAGE + def test_spaces_and_valid_numerals(): - assert convert_to_number(" I V ") == "IV = 1 + 5 = 6" + converter = RomanNumeralConverter() + assert converter.convert_to_number(" I V ") == "IV = 1 + 5 = 6" + + +def test_filter_valid_numerals(): + converter = RomanNumeralConverter() + assert converter._filter_valid_numerals("M1X2V") == "MXV" + assert converter._filter_valid_numerals("def") == "" + assert converter._filter_valid_numerals(" I V ") == "IV" + + +def test_convert_numerals_to_values(): + converter = RomanNumeralConverter() + assert converter._convert_numerals_to_values("IV") == [1, 5] + assert converter._convert_numerals_to_values("MXV") == [1000, 10, 5] + assert converter._convert_numerals_to_values("C") == [100] + + +def test_format_output(): + converter = RomanNumeralConverter() + assert converter._format_output("I", [1]) == "I = 1" + assert converter._format_output("IV", [1, 5]) == "IV = 1 + 5 = 6" + assert ( + converter._format_output("MXV", [1000, 10, 5]) == "MXV = 1000 + 10 + 5 = 1015" + ) diff --git a/stack/README.md b/stack/README.md new file mode 100644 index 0000000..f0abf2f --- /dev/null +++ b/stack/README.md @@ -0,0 +1,282 @@ +# Stack + +Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#stack + +A Python implementation of the Fifth stack-based programming language, demonstrating clean code practices, robust error handling, and modern Python features. + +## What is Fifth? + +Fifth is a stack-based language that operates on a stack of integers. All operations work with the top elements of the stack, following the last-in-first-out (LIFO) principle. This implementation provides an interactive REPL (read-eval-print loop) for executing Fifth commands. + +## Features + +- **Stack operations**: `PUSH`, `POP`, `SWAP`, `DUP` +- **Arithmetic operations**: `+`, `-`, `*`, `/` (floor division) +- **Comprehensive error handling**: Input validation, stack state checking, and clear error messages +- **Interactive REPL**: Command-line interface with readline support for command history (↑/↓) +- **Case-insensitive commands**: Commands work in both upper and lowercase + +## Installation and usage + +### Requirements + +- Python 3.9+ (uses modern type hint syntax `list[int]`) +- No external dependencies + +### Running the interpreter + +```bash +python .py +``` + +The interpreter will start with a help message and enter interactive mode, displaying the current stack state before each command. + +## Commands reference + +| Command | Description | Stack effect | Example | +| ---------- | --------------------------- | ----------------------------- | --------- | +| `PUSH ` | Push integer n onto stack | `[] → [n]` | `PUSH 42` | +| `POP` | Remove top element | `[..., n] → [...]` | `POP` | +| `SWAP` | Swap top two elements | `[..., a, b] → [..., b, a]` | `SWAP` | +| `DUP` | Duplicate top element | `[..., n] → [..., n, n]` | `DUP` | +| `+` | Add top two elements | `[..., a, b] → [..., (a+b)]` | `+` | +| `-` | Subtract (second - top) | `[..., a, b] → [..., (a-b)]` | `-` | +| `*` | Multiply top two elements | `[..., a, b] → [..., (a*b)]` | `*` | +| `/` | Floor divide (second / top) | `[..., a, b] → [..., (a//b)]` | `/` | +| `HELP` | Show available commands | No change | `HELP` | +| `EXIT` | Quit the program | - | `EXIT` | + +### Example session + +``` +Available commands: HELP, PUSH, POP, SWAP, DUP +Available operations: +, -, *, / +stack is [] +PUSH 3 +stack is [3] +PUSH 11 +stack is [3, 11] ++ +stack is [14] +DUP +stack is [14, 14] +PUSH 2 +stack is [14, 14, 2] +* +stack is [14, 28] +SWAP +stack is [28, 14] +/ +stack is [2] ++ +ERROR: two numbers required +POP +stack is [] +exit +``` + +## Tests + +Run the test suite: + +```bash +pytest -v +``` + +Example output: + +```shell +➜ stack git:(main) ✗ pytest -v +================================================================ test session starts ================================================================ +platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/stack +plugins: mock-3.14.0, asyncio-0.21.2 +asyncio: mode=Mode.STRICT +collected 22 items + +test_stack.py::test_push_and_pop PASSED [ 4%] +test_stack.py::test_push_invalid_value PASSED [ 9%] +test_stack.py::test_pop_empty_stack PASSED [ 13%] +test_stack.py::test_swap PASSED [ 18%] +test_stack.py::test_swap_insufficient_elements PASSED [ 22%] +test_stack.py::test_dup PASSED [ 27%] +test_stack.py::test_dup_empty_stack PASSED [ 31%] +test_stack.py::test_addition PASSED [ 36%] +test_stack.py::test_subtraction PASSED [ 40%] +test_stack.py::test_multiplication PASSED [ 45%] +test_stack.py::test_division PASSED [ 50%] +test_stack.py::test_division_by_zero PASSED [ 54%] +test_stack.py::test_binary_op_insufficient_elements PASSED [ 59%] +test_stack.py::test_execute_unknown_command PASSED [ 63%] +test_stack.py::test_execute_push_command PASSED [ 68%] +test_stack.py::test_execute_pop_command PASSED [ 72%] +test_stack.py::test_execute_swap_command PASSED [ 77%] +test_stack.py::test_execute_dup_command PASSED [ 81%] +test_stack.py::test_help PASSED [ 86%] +test_stack.py::test_execute_empty_command PASSED [ 90%] +test_stack.py::test_execute_whitespace_command PASSED [ 95%] +test_stack.py::test_case_insensitivity PASSED [100%] + +================================================================ 22 passed in 0.03s ================================================================= +``` + +## Design decisions and architecture + +### Core design philosophy + +The implementation prioritises **clarity and maintainability** over performance optimisation, following the principle of being "explicit over implicit" and "discoverable over performant" as requested in the brief. + +### Key architectural choices + +#### 1. Class-based structure (`FifthStack`) + +- **Reasoning**: Encapsulates state (the stack) with behaviour (operations), making the code more organised and testable +- **Alternative considered**: Functional approach with global state, rejected for testing difficulties and state management complexity + +#### 2. Command dictionary pattern + +```python +self.commands: dict[str, Callable] = { + "help": self.help, + "push": self.push, + # ... +} +``` + +- **Reasoning**: Makes adding new commands trivial and eliminates long if/elif chains +- **Benefit**: Extensible design - new commands require only adding to the dictionary +- **Trade-off**: Slight memory overhead for the dictionary, but gains significant maintainability + +#### 3. Separate binary operations dictionary + +```python +self.binary_ops: dict[str, Callable[[int, int], int]] = { + "+": operator.add, + # ... +} +``` + +- **Reasoning**: Leverages Python's `operator` module for built-in mathematical operations +- **Benefit**: Consistent behaviour with Python's arithmetic, less error-prone than custom implementations +- **Design**: Separate from commands because they have different signatures and error handling needs + +#### 4. Method extraction for each operation + +Each stack operation is implemented as a separate method rather than inline logic: + +- **Testing**: Each operation can be unit tested independently +- **Debugging**: Stack traces clearly identify which operation failed +- **Extensibility**: New operations are self-contained and don't affect existing code +- **Readability**: Each method has a single, clear responsibility + +#### 5. Defensive programming with `_require()` helper + +```python +def _require(self, number, message): + if len(self.stack) < number: + print(f"ERROR: {message}") + return False + return True +``` + +- **Reasoning**: Centralizes stack size validation logic +- **Benefit**: Consistent error messages and reduces code duplication +- **DRY principle**: Single source of truth for stack requirement checking + +#### 6. Error recovery strategy + +- **Division by zero**: Restores original stack state (`self.stack.extend([a, b])`) +- **Invalid input**: Provides clear error messages without crashing +- **Insufficient stack**: Fails gracefully with descriptive messages +- **Reasoning**: Interpreter should be robust and not crash on user errors + +#### 7. Modern Python features used + +**Type hints with generic collections (Python 3.9+)**: + +```python +self.stack: list[int] = [] +``` + +- **Reasoning**: Improves code readability and enables better IDE support +- **Choice**: Used `list[int]` instead of `typing.List[int]` for cleaner, modern syntax + +**Input processing centralization**: + +```python +def _normalize_input(self, command: str) -> list[str]: + return command.lower().strip().split() +``` + +- **Reasoning**: Single source of truth for command normalization and tokenization +- **Benefit**: Both REPL and direct execution paths use identical processing logic +- **Design choice**: Avoided walrus operator in favour of explicit method extraction for better testability and clarity + +**f-string formatting**: + +- **Reasoning**: More readable than `.format()` or `%` formatting +- **Performance**: Faster than alternative string formatting methods + +#### 8. REPL design choices + +**Stack display**: Shows stack state after each command + +- **Reasoning**: Follows the specified output format from the brief +- **Implementation choice**: Used Python's native list representation for consistency with the expected format +- **Benefit**: Maintains familiar syntax for Python developers while meeting requirements + +**Case insensitive commands**: + +```python +command.lower().strip().split() +``` + +- **Reasoning**: More user-friendly, follows principle of least surprise +- **Benefit**: Reduces user errors from capitalisation mistakes + +**Graceful exit**: Handles both `exit` command and Ctrl+C/EOF + +- **Reasoning**: Proper CLI applications should handle interrupts gracefully +- **Implementation**: Try/catch for `EOFError` and `KeyboardInterrupt` + +### Alternative approaches considered + +1. **Parser-based approach**: Could have used a formal parser (e.g. PLY), but rejected as over-engineering for this simple language + +2. **Functional approach**: Could have used pure functions with immutable data but rejected because: + + - Stack operations naturally mutate state + - Would require returning new stack state from every function + - Less intuitive for this particular problem domain + +3. **Command pattern with classes**: Could have made each command a separate class but rejected due to: + + - Unnecessary complexity for simple operations + - Would require more boilerplate code + - Current approach is more discoverable + +4. **eval() for arithmetic**: Could have used `eval()` for mathematical expressions but rejected due to: + - Security concerns + - Need for custom stack-based evaluation order + - Loss of control over error handling + +### Testing strategy + +The design facilitates testing through: + +- **Isolated methods**: Each operation can be tested independently +- **Dependency injection**: Stack state can be set up for specific test scenarios +- **Clear return values**: Methods have predictable behaviour and side effects +- **Error conditions**: All error paths are reachable and testable + +### Extensibility + +Adding new features is straightforward: + +- **New commands**: Add to `self.commands` dictionary and implement method +- **New operators**: Add to `self.binary_ops` dictionary +- **New data types**: Modify type hints and validation logic +- **New output formats**: Modify display logic in main loop + +This architecture balances simplicity with software engineering practices, to keep the code readable and maintainable. diff --git a/stack/stack.py b/stack/stack.py index 46cfd67..0287557 100644 --- a/stack/stack.py +++ b/stack/stack.py @@ -20,6 +20,10 @@ class FifthStack: "/": operator.floordiv, } + def _normalize_input(self, command: str) -> list[str]: + """Normalize and tokenize command input.""" + return command.lower().strip().split() + def _require(self, number, message): if len(self.stack) < number: print(f"ERROR: {message}") @@ -40,7 +44,10 @@ class FifthStack: self.stack.extend([a, b]) def help(self): - print("Available commands:", ", ".join(cmd.upper() for cmd in self.commands.keys())) + print( + "Available commands:", + ", ".join(cmd.upper() for cmd in self.commands.keys()), + ) print("Available operations:", ", ".join(self.binary_ops.keys())) def push(self, value): @@ -65,7 +72,7 @@ class FifthStack: self.stack.append(self.stack[-1]) def execute(self, command: str): - tokens = command.lower().strip().split() + tokens = self._normalize_input(command) if not tokens: return @@ -90,10 +97,12 @@ def main(): while True: print(f"stack is {fifth.stack}") try: - if (command := input().strip().lower()) == "exit": + raw_input = input() + tokens = fifth._normalize_input(raw_input) + if tokens and tokens[0] == "exit": break - if command: - fifth.execute(command) + if tokens: + fifth.execute(raw_input) except (EOFError, KeyboardInterrupt): break diff --git a/stack/test_stack.py b/stack/test_stack.py index 6f370c4..82cb41b 100644 --- a/stack/test_stack.py +++ b/stack/test_stack.py @@ -155,6 +155,7 @@ def test_help(capsys): assert "Available commands:" in captured.out assert "Available operations:" in captured.out + def test_execute_empty_command(): stack = FifthStack() stack.execute("") # Should do nothing