turner-townsend-backend-ass.../stack
2025-08-31 11:34:20 +01:00
..
README.md improve docs, black formatting 2025-08-31 11:34:20 +01:00
stack.py improve docs, black formatting 2025-08-31 11:34:20 +01:00
test_stack.py improve docs, black formatting 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

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:

pytest -v

Example output:

➜  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

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

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

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

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:

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:

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.