improve docs, black formatting
This commit is contained in:
parent
6488ea0ede
commit
df11a33a54
148
README.md
148
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 <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
|
||||
```
|
||||
Click the links above to view detailed documentation for each solution.
|
||||
|
||||
89
collatz/README.md
Normal file
89
collatz/README.md
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
assert calc.calculate_steps(-5) == 0
|
||||
|
||||
86
roman/README.md
Normal file
86
roman/README.md
Normal 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+)
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
282
stack/README.md
Normal file
282
stack/README.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user