turner-townsend-backend-ass.../stack/README.md

283 lines
12 KiB
Markdown
Raw Permalink Normal View History

2025-08-31 11:34:20 +01:00
# 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.