improve docs, black formatting

This commit is contained in:
James Greenwood 2025-08-31 11:34:20 +01:00
parent 6488ea0ede
commit df11a33a54
10 changed files with 642 additions and 159 deletions

148
README.md
View File

@ -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 ### [Roman Numerals Converter](./roman/README.md)
python collatz/collatz.py
```
### Example **Complexity: Medium**
``` Converts Roman numerals to Arabic numbers using additive logic. Includes flexible input handling with case-insensitive processing and automatic filtering of invalid characters.
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
```
## 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 ```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 Click the links above to view detailed documentation for each solution.
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 <n>` - 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
```

89
collatz/README.md Normal file
View File

@ -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

View File

@ -1,5 +1,6 @@
import readline import readline
class CollatzCalculator: class CollatzCalculator:
def calculate_steps(self, n: int) -> int: def calculate_steps(self, n: int) -> int:
if n <= 1: if n <= 1:
@ -15,16 +16,20 @@ class CollatzCalculator:
steps += 1 steps += 1
return steps + 1 return steps + 1
def main(): def main():
calculator = CollatzCalculator() calculator = CollatzCalculator()
while True: while True:
try: try:
n = input("Please enter a whole number: ") 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: except ValueError:
print("ERROR: integer required") print("ERROR: integer required")
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
break break
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,26 +1,32 @@
import pytest import pytest
from collatz import CollatzCalculator from collatz import CollatzCalculator
def test_calculate_steps_for_1(): def test_calculate_steps_for_1():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(1) == 0 assert calc.calculate_steps(1) == 0
def test_calculate_steps_for_2(): def test_calculate_steps_for_2():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(2) == 2 # 2 -> 1 (even), returns 2 steps assert calc.calculate_steps(2) == 2 # 2 -> 1 (even), returns 2 steps
def test_calculate_steps_for_3(): def test_calculate_steps_for_3():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(3) == 8 # 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 assert calc.calculate_steps(3) == 8 # 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
def test_calculate_steps_for_6(): def test_calculate_steps_for_6():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(6) == 9 # 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 assert calc.calculate_steps(6) == 9 # 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
def test_calculate_steps_for_large_number(): def test_calculate_steps_for_large_number():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(27) == 112 assert calc.calculate_steps(27) == 112
def test_calculate_steps_for_zero_and_negative(): def test_calculate_steps_for_zero_and_negative():
calc = CollatzCalculator() calc = CollatzCalculator()
assert calc.calculate_steps(0) == 0 assert calc.calculate_steps(0) == 0

86
roman/README.md Normal file
View File

@ -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+)

View File

@ -1,13 +1,31 @@
import readline import readline
def convert_to_number(numerals: str) -> str:
filtered = list(filter(lambda x: x in "IVXCM", list(numerals.upper()))) class RomanNumeralConverter:
if len(filtered) == 0: 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" return "ERROR: Invalid input"
values: list[int] = [] values = self._convert_numerals_to_values(filtered)
for i in range(len(filtered)): return self._format_output(filtered, values)
match filtered[i]:
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": case "I":
values.append(1) values.append(1)
case "V": case "V":
@ -18,19 +36,26 @@ def convert_to_number(numerals: str) -> str:
values.append(100) values.append(100)
case "M": case "M":
values.append(1000) values.append(1000)
return values
if len(values) > 1: def _format_output(self, numerals: str, values: list[int]) -> str:
return f"{''.join(filtered)} = {' + '.join(str(v) for v in values)} = {sum(values)}" """Format the conversion result for display."""
if len(values) == 1:
return f"{numerals} = {values[0]}"
else: else:
return f"{''.join(filtered)} = {values[0]}" values_str = " + ".join(str(v) for v in values)
return f"{numerals} = {values_str} = {sum(values)}"
def main(): def main():
converter = RomanNumeralConverter()
while True: while True:
try: try:
numerals = input("Please enter some Roman numerals: ") numerals = input("Please enter some Roman numerals: ")
print(convert_to_number(numerals)) print(converter.convert_to_number(numerals))
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
break break
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,38 +1,88 @@
import pytest import pytest
from roman import convert_to_number from roman import RomanNumeralConverter
ERROR_MESSAGE = "ERROR: Invalid input"
def test_valid_single_numerals(): def test_valid_single_numerals():
assert convert_to_number("I") == "I = 1" converter = RomanNumeralConverter()
assert convert_to_number("V") == "V = 5" assert converter.convert_to_number("I") == "I = 1"
assert convert_to_number("X") == "X = 10" assert converter.convert_to_number("V") == "V = 5"
assert convert_to_number("C") == "C = 100" assert converter.convert_to_number("X") == "X = 10"
assert convert_to_number("M") == "M = 1000" assert converter.convert_to_number("C") == "C = 100"
assert converter.convert_to_number("M") == "M = 1000"
def test_valid_multiple_numerals(): def test_valid_multiple_numerals():
assert convert_to_number("IV") == "IV = 1 + 5 = 6" converter = RomanNumeralConverter()
assert convert_to_number("XCM") == "XCM = 10 + 100 + 1000 = 1110" assert converter.convert_to_number("IV") == "IV = 1 + 5 = 6"
assert convert_to_number("MCMXCIV") == "MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216" 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(): 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(): 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(): def test_invalid_characters():
assert convert_to_number("EFGH") == "Invalid input" converter = RomanNumeralConverter()
assert convert_to_number("123") == "Invalid input" assert converter.convert_to_number("EFGH") == ERROR_MESSAGE
assert convert_to_number("!@#") == "Invalid input" assert converter.convert_to_number("123") == ERROR_MESSAGE
assert converter.convert_to_number("!@#") == ERROR_MESSAGE
def test_mixed_valid_and_invalid_characters(): def test_mixed_valid_and_invalid_characters():
assert convert_to_number("AIXB") == "IX = 1 + 10 = 11" converter = RomanNumeralConverter()
assert convert_to_number("M1C2") == "MC = 1000 + 100 = 1100" assert converter.convert_to_number("AIXB") == "IX = 1 + 10 = 11"
assert converter.convert_to_number("M1C2") == "MC = 1000 + 100 = 1100"
def test_empty_string(): def test_empty_string():
assert convert_to_number("") == "Invalid input" converter = RomanNumeralConverter()
assert converter.convert_to_number("") == ERROR_MESSAGE
def test_only_spaces(): 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(): 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"
)

282
stack/README.md Normal file
View File

@ -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 <filename>.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 <n>` | 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.

View File

@ -20,6 +20,10 @@ class FifthStack:
"/": operator.floordiv, "/": 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): def _require(self, number, message):
if len(self.stack) < number: if len(self.stack) < number:
print(f"ERROR: {message}") print(f"ERROR: {message}")
@ -40,7 +44,10 @@ class FifthStack:
self.stack.extend([a, b]) self.stack.extend([a, b])
def help(self): 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())) print("Available operations:", ", ".join(self.binary_ops.keys()))
def push(self, value): def push(self, value):
@ -65,7 +72,7 @@ class FifthStack:
self.stack.append(self.stack[-1]) self.stack.append(self.stack[-1])
def execute(self, command: str): def execute(self, command: str):
tokens = command.lower().strip().split() tokens = self._normalize_input(command)
if not tokens: if not tokens:
return return
@ -90,10 +97,12 @@ def main():
while True: while True:
print(f"stack is {fifth.stack}") print(f"stack is {fifth.stack}")
try: try:
if (command := input().strip().lower()) == "exit": raw_input = input()
tokens = fifth._normalize_input(raw_input)
if tokens and tokens[0] == "exit":
break break
if command: if tokens:
fifth.execute(command) fifth.execute(raw_input)
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
break break

View File

@ -155,6 +155,7 @@ def test_help(capsys):
assert "Available commands:" in captured.out assert "Available commands:" in captured.out
assert "Available operations:" in captured.out assert "Available operations:" in captured.out
def test_execute_empty_command(): def test_execute_empty_command():
stack = FifthStack() stack = FifthStack()
stack.execute("") # Should do nothing stack.execute("") # Should do nothing